mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
Compare commits
10 Commits
72cb192447
...
769378f0f7
| Author | SHA1 | Date | |
|---|---|---|---|
| 769378f0f7 | |||
| ab8b1b8463 | |||
| 9d09662f16 | |||
| 00c28e642b | |||
| 196a18fe1a | |||
| 28689254ca | |||
| 855d344b2c | |||
| 6c32d76d7e | |||
| 04f559037c | |||
| ffb8bcb6ad |
@@ -1,10 +1,11 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faPhone, faPhoneHangup, faMicrophone, faMicrophoneSlash,
|
||||
faPause, faPlay, faCalendarPlus,
|
||||
faPause, faPlay, faCalendarPlus, faPlus, faPenToSquare,
|
||||
faPhoneArrowRight, faRecordVinyl, faClipboardQuestion,
|
||||
} from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { useSetAtom } from 'jotai';
|
||||
@@ -13,10 +14,11 @@ import { setOutboundPending } from '@/state/sip-manager';
|
||||
import { useSip } from '@/providers/sip-provider';
|
||||
import { DispositionModal } from './disposition-modal';
|
||||
import type { CallAction } from './disposition-modal';
|
||||
import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal';
|
||||
import { AppointmentForm } from './appointment-form';
|
||||
import { TransferDialog } from './transfer-dialog';
|
||||
import { EnquiryForm } from './enquiry-form';
|
||||
import { formatPhone } from '@/lib/format';
|
||||
import { formatPhone, formatShortDate } from '@/lib/format';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import { useAgentState } from '@/hooks/use-agent-state';
|
||||
@@ -44,6 +46,10 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
|
||||
const setCallUcid = useSetAtom(sipCallUcidAtom);
|
||||
const [appointmentOpen, setAppointmentOpen] = useState(false);
|
||||
// Which existing appointment is being edited (null = creating a new one).
|
||||
// The Book Appt drawer shows pills: [+ New] + one per upcoming appointment.
|
||||
// Clicking Edit on a pill sets this; clicking + New clears it.
|
||||
const [editingApptId, setEditingApptId] = useState<string | null>(null);
|
||||
const [transferOpen, setTransferOpen] = useState(false);
|
||||
const [recordingPaused, setRecordingPaused] = useState(false);
|
||||
const [enquiryOpen, setEnquiryOpen] = useState(false);
|
||||
@@ -62,6 +68,39 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
});
|
||||
};
|
||||
|
||||
// Upcoming appointments for this caller (if returning patient) — drives
|
||||
// the pill row above AppointmentForm so the agent can edit existing
|
||||
// bookings in addition to creating new ones.
|
||||
const { appointments } = useData();
|
||||
const leadAppointments = useMemo(() => {
|
||||
const patientId = (lead as any)?.patientId;
|
||||
if (!patientId) return [];
|
||||
const now = Date.now();
|
||||
return appointments
|
||||
.filter((a) =>
|
||||
a.patientId === patientId
|
||||
&& a.appointmentStatus !== 'CANCELLED'
|
||||
&& a.appointmentStatus !== 'NO_SHOW'
|
||||
&& a.appointmentStatus !== 'COMPLETED'
|
||||
// Only future appointments make sense as reschedule targets.
|
||||
// Past ones can't be edited — they already happened.
|
||||
&& a.scheduledAt
|
||||
&& new Date(a.scheduledAt).getTime() >= now,
|
||||
)
|
||||
.sort((a, b) => new Date(a.scheduledAt ?? '').getTime() - new Date(b.scheduledAt ?? '').getTime());
|
||||
}, [appointments, lead]);
|
||||
|
||||
const editingAppt = useMemo(
|
||||
() => (editingApptId ? leadAppointments.find((a) => a.id === editingApptId) ?? null : null),
|
||||
[leadAppointments, editingApptId],
|
||||
);
|
||||
|
||||
// Pending pill click awaiting the reschedule-confirm modal. When the
|
||||
// agent clicks a pill, we store the appointment id here + open the modal.
|
||||
// Yes → promote to editingApptId in edit mode. No → promote in view mode.
|
||||
const [pendingApptId, setPendingApptId] = useState<string | null>(null);
|
||||
const [apptMode, setApptMode] = useState<'edit' | 'view'>('edit');
|
||||
|
||||
const agentConfig = localStorage.getItem('helix_agent_config');
|
||||
const agentIdForState = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null;
|
||||
const { supervisorPresence } = useAgentState(agentIdForState);
|
||||
@@ -335,14 +374,84 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{appointmentOpen && leadAppointments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingApptId(null)}
|
||||
className={cx(
|
||||
'flex items-center gap-1.5 rounded-lg border-2 px-3 py-2 text-xs font-semibold transition duration-100 ease-linear',
|
||||
!editingApptId
|
||||
? 'border-brand bg-brand-primary text-brand-secondary'
|
||||
: 'border-secondary bg-primary text-secondary hover:bg-primary_hover',
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} className="size-3" />
|
||||
New
|
||||
</button>
|
||||
{leadAppointments.map((appt) => (
|
||||
<div
|
||||
key={appt.id}
|
||||
className={cx(
|
||||
'flex items-center gap-2 rounded-lg border-2 px-3 py-2 text-xs',
|
||||
editingApptId === appt.id
|
||||
? 'border-brand bg-brand-primary'
|
||||
: 'border-secondary bg-primary',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold text-primary">
|
||||
{appt.scheduledAt ? formatShortDate(appt.scheduledAt) : 'No date'}
|
||||
</span>
|
||||
<span className="text-[11px] text-tertiary">
|
||||
{appt.doctorName ?? 'Doctor'}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingApptId(appt.id)}
|
||||
className="flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-semibold text-brand-secondary hover:bg-brand-primary_hover transition duration-100 ease-linear"
|
||||
>
|
||||
<FontAwesomeIcon icon={faPenToSquare} className="size-3" />
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Key forces a full remount when switching between
|
||||
pills (or between edit/view modes) so the form's
|
||||
internal state re-initializes from the new
|
||||
existingAppointment prop instead of staying
|
||||
stuck on the first-mounted values. */}
|
||||
<AppointmentForm
|
||||
key={`${editingApptId ?? 'new'}-${apptMode}`}
|
||||
isOpen={appointmentOpen}
|
||||
onOpenChange={setAppointmentOpen}
|
||||
onOpenChange={(open) => {
|
||||
setAppointmentOpen(open);
|
||||
if (!open) { setEditingApptId(null); setApptMode('edit'); }
|
||||
}}
|
||||
callerNumber={callerPhone}
|
||||
leadName={fullName || null}
|
||||
leadId={lead?.id ?? null}
|
||||
patientId={(lead as any)?.patientId ?? null}
|
||||
onSaved={handleAppointmentSaved}
|
||||
readOnly={apptMode === 'view'}
|
||||
existingAppointment={editingAppt ? {
|
||||
id: editingAppt.id,
|
||||
scheduledAt: editingAppt.scheduledAt ?? '',
|
||||
doctorName: editingAppt.doctorName ?? '',
|
||||
doctorId: editingAppt.doctorId ?? undefined,
|
||||
department: editingAppt.department ?? '',
|
||||
clinicId: editingAppt.clinicId ?? undefined,
|
||||
reasonForVisit: editingAppt.reasonForVisit ?? undefined,
|
||||
status: editingAppt.appointmentStatus ?? 'SCHEDULED',
|
||||
} : null}
|
||||
onSaved={(outcome) => {
|
||||
setEditingApptId(null);
|
||||
setApptMode('edit');
|
||||
handleAppointmentSaved(outcome);
|
||||
}}
|
||||
/>
|
||||
|
||||
<EnquiryForm
|
||||
@@ -362,6 +471,58 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reschedule confirm modal — fires when the agent clicks Edit
|
||||
on an upcoming-appointment pill. Yes → open the form in
|
||||
edit mode (fields editable, Save button). No → open in
|
||||
view-only mode (fields disabled, Close button). */}
|
||||
<ModalOverlay
|
||||
isOpen={pendingApptId !== null}
|
||||
onOpenChange={(open) => { if (!open) setPendingApptId(null); }}
|
||||
isDismissable
|
||||
>
|
||||
<Modal className="sm:max-w-md">
|
||||
<Dialog>
|
||||
{() => (
|
||||
<div className="flex flex-col gap-4 rounded-xl bg-primary p-6 shadow-xl ring-1 ring-secondary">
|
||||
<h2 className="text-lg font-semibold text-primary">Reschedule this appointment?</h2>
|
||||
<p className="text-sm text-tertiary">
|
||||
Choose "Yes, reschedule" to change the date, time, or doctor.
|
||||
Choose "No, just view" to see the details without changing anything.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
if (pendingApptId) {
|
||||
setEditingApptId(pendingApptId);
|
||||
setApptMode('view');
|
||||
setPendingApptId(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
No, just view
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
if (pendingApptId) {
|
||||
setEditingApptId(pendingApptId);
|
||||
setApptMode('edit');
|
||||
setPendingApptId(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Yes, reschedule
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
|
||||
{/* Disposition Modal — the ONLY path to end a call */}
|
||||
<DispositionModal
|
||||
isOpen={dispositionOpen}
|
||||
|
||||
@@ -19,9 +19,25 @@ interface AiChatPanelProps {
|
||||
onChatStart?: () => void;
|
||||
}
|
||||
|
||||
// Supervisor has different quick-action prompts than the CC agent — they
|
||||
// ask about team metrics, not patient / doctor info. Hardcoded here rather
|
||||
// than in theme tokens because the prompts map 1:1 to the supervisor tool
|
||||
// set in ai-chat.controller.ts (get_agent_performance, get_call_summary,
|
||||
// get_campaign_stats) — changing the tools means changing these prompts.
|
||||
const SUPERVISOR_QUICK_ACTIONS = [
|
||||
{ label: 'Agent performance', prompt: 'Show me agent performance this week.' },
|
||||
{ label: 'Call summary', prompt: 'Summarize call activity this week.' },
|
||||
{ label: 'Campaign stats', prompt: 'How are the campaigns performing?' },
|
||||
{ label: 'Who needs attention?', prompt: 'Which agents are underperforming or need attention?' },
|
||||
];
|
||||
|
||||
const SUPERVISOR_INTRO = 'Ask me about agent performance, call trends, or campaign stats.';
|
||||
|
||||
export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) => {
|
||||
const { tokens } = useThemeTokens();
|
||||
const quickActions = tokens.ai.quickActions;
|
||||
const isSupervisor = callerContext?.type === 'supervisor';
|
||||
const quickActions = isSupervisor ? SUPERVISOR_QUICK_ACTIONS : tokens.ai.quickActions;
|
||||
const introText = isSupervisor ? SUPERVISOR_INTRO : 'Ask me about doctors, clinics, packages, or patient info.';
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const chatStartedRef = useRef(false);
|
||||
|
||||
@@ -50,14 +66,26 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
|
||||
}, [messages, onChatStart]);
|
||||
|
||||
// Auto-fire a patient-summary request when a caller with a leadId appears
|
||||
// on the panel. Resets whenever the caller changes (new incoming call) so
|
||||
// each call starts fresh. The sidecar's AI agent inspects the leadId and
|
||||
// replies with appointment/disposition/notes history when the caller is
|
||||
// a returning patient, or a brief "net-new caller" ack otherwise.
|
||||
// on the panel. Resets whenever the caller changes (new incoming call) or
|
||||
// the call ends (leadId clears), so each call starts fresh. The sidecar's
|
||||
// AI agent inspects the leadId and replies with appointment/disposition/
|
||||
// notes history when the caller is a returning patient.
|
||||
const autoFiredForLeadRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
const leadId = callerContext?.leadId ?? null;
|
||||
if (!leadId) return;
|
||||
|
||||
// Call ended or no caller — wipe the panel so the next caller's
|
||||
// context doesn't bleed over and the agent isn't staring at a stale
|
||||
// summary in the worklist view between calls.
|
||||
if (!leadId) {
|
||||
if (autoFiredForLeadRef.current !== null) {
|
||||
autoFiredForLeadRef.current = null;
|
||||
setMessages([]);
|
||||
chatStartedRef.current = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (autoFiredForLeadRef.current === leadId) return;
|
||||
|
||||
// New caller — clear any prior chat state and fire the summary prompt.
|
||||
@@ -82,7 +110,7 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||
<FontAwesomeIcon icon={faSparkles} className="mb-2 size-6 text-fg-brand-primary" />
|
||||
<p className="text-xs text-tertiary">
|
||||
Ask me about doctors, clinics, packages, or patient info.
|
||||
{introText}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap justify-center gap-1.5">
|
||||
{quickActions.map((action) => (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faUserPen } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
@@ -35,6 +35,11 @@ type AppointmentFormProps = {
|
||||
// CANCELLED each map to distinct disposition outcomes).
|
||||
onSaved?: (outcome: 'BOOKED' | 'RESCHEDULED' | 'CANCELLED') => void;
|
||||
existingAppointment?: ExistingAppointment | null;
|
||||
// When true, the form shows the existing appointment's data in a
|
||||
// disabled state — no input editing, no Save/Cancel. Only a Close
|
||||
// button. Used by the reschedule-confirm flow when the agent picks
|
||||
// "No, just view" on an upcoming-appointment pill.
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
type DoctorRecord = { id: string; name: string; department: string; clinic: string };
|
||||
@@ -60,6 +65,7 @@ export const AppointmentForm = ({
|
||||
patientId,
|
||||
onSaved,
|
||||
existingAppointment,
|
||||
readOnly = false,
|
||||
}: AppointmentFormProps) => {
|
||||
const isEditMode = !!existingAppointment;
|
||||
|
||||
@@ -236,7 +242,19 @@ export const AppointmentForm = ({
|
||||
const filteredDoctors = department
|
||||
? doctors.filter(d => d.department === department)
|
||||
: doctors;
|
||||
const doctorSelectItems = filteredDoctors.map(d => ({ id: d.id, label: d.name }));
|
||||
// Always include the currently-selected doctor even if the department
|
||||
// filter would exclude them. Needed for edit mode: the saved
|
||||
// Appointment.department may be stored as a display string ("ENT") or
|
||||
// a legacy value that doesn't match the doctor's current department
|
||||
// enum — without this, the Select renders blank.
|
||||
const doctorSelectItems = useMemo(() => {
|
||||
const items = filteredDoctors.map(d => ({ id: d.id, label: d.name }));
|
||||
if (doctor && !items.some(i => i.id === doctor)) {
|
||||
const selected = doctors.find(d => d.id === doctor);
|
||||
if (selected) items.unshift({ id: selected.id, label: selected.name });
|
||||
}
|
||||
return items;
|
||||
}, [filteredDoctors, doctors, doctor]);
|
||||
|
||||
const timeSlotSelectItems = timeSlotItems.map(slot => ({
|
||||
...slot,
|
||||
@@ -471,7 +489,7 @@ export const AppointmentForm = ({
|
||||
placeholder="Full name"
|
||||
value={patientName}
|
||||
onChange={setPatientName}
|
||||
isDisabled={!isNameEditable}
|
||||
isDisabled={readOnly || !isNameEditable}
|
||||
/>
|
||||
</div>
|
||||
{!isNameEditable && initialLeadName.length > 0 && (
|
||||
@@ -544,7 +562,7 @@ export const AppointmentForm = ({
|
||||
items={departmentItems}
|
||||
selectedKey={department}
|
||||
onSelectionChange={(key) => setDepartment(key as string)}
|
||||
isDisabled={doctors.length === 0}
|
||||
isDisabled={readOnly || doctors.length === 0}
|
||||
>
|
||||
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||
</Select>
|
||||
@@ -555,7 +573,7 @@ export const AppointmentForm = ({
|
||||
items={doctorSelectItems}
|
||||
selectedKey={doctor}
|
||||
onSelectionChange={(key) => setDoctor(key as string)}
|
||||
isDisabled={!department}
|
||||
isDisabled={readOnly || !department}
|
||||
>
|
||||
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||
</Select>
|
||||
@@ -567,7 +585,7 @@ export const AppointmentForm = ({
|
||||
value={date ? parseDate(date) : null}
|
||||
onChange={(val) => setDate(val ? val.toString() : '')}
|
||||
granularity="day"
|
||||
isDisabled={!doctor}
|
||||
isDisabled={readOnly || !doctor}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -585,7 +603,7 @@ export const AppointmentForm = ({
|
||||
<button
|
||||
key={slot.id}
|
||||
type="button"
|
||||
disabled={isBooked}
|
||||
disabled={readOnly || isBooked}
|
||||
onClick={() => setTimeSlot(slot.id)}
|
||||
className={cx(
|
||||
'rounded-lg py-2 px-1 text-xs font-medium transition duration-100 ease-linear',
|
||||
@@ -613,6 +631,7 @@ export const AppointmentForm = ({
|
||||
placeholder="Describe the reason for visit..."
|
||||
value={chiefComplaint}
|
||||
onChange={setChiefComplaint}
|
||||
isDisabled={readOnly}
|
||||
rows={2}
|
||||
/>
|
||||
|
||||
@@ -649,7 +668,7 @@ export const AppointmentForm = ({
|
||||
{/* Footer — pinned */}
|
||||
<div className="shrink-0 flex items-center justify-between pt-4 border-t border-secondary">
|
||||
<div>
|
||||
{isEditMode && (
|
||||
{isEditMode && !readOnly && (
|
||||
<Button size="sm" color="primary-destructive" isLoading={isSaving} onClick={handleCancel}>
|
||||
Cancel Appointment
|
||||
</Button>
|
||||
@@ -659,9 +678,11 @@ export const AppointmentForm = ({
|
||||
<Button size="sm" color="secondary" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button size="sm" color="primary" isLoading={isSaving} showTextWhileLoading onClick={handleSave}>
|
||||
{isSaving ? 'Saving...' : isEditMode ? 'Update Appointment' : 'Book Appointment'}
|
||||
</Button>
|
||||
{!readOnly && (
|
||||
<Button size="sm" color="primary" isLoading={isSaving} showTextWhileLoading onClick={handleSave}>
|
||||
{isSaving ? 'Saving...' : isEditMode ? 'Update Appointment' : 'Book Appointment'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -91,13 +91,6 @@ export const CampaignHero = ({ campaign }: CampaignHeroProps) => {
|
||||
View on Platform
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
color="primary"
|
||||
size="sm"
|
||||
href={`/leads`}
|
||||
>
|
||||
View Leads
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
401
src/components/dashboard/supervisor-rollup.tsx
Normal file
401
src/components/dashboard/supervisor-rollup.tsx
Normal file
@@ -0,0 +1,401 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faTriangleExclamation } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { Table } from '@/components/application/table/table';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
// Shared rollup surfaces for the supervisor dashboard: agent performance
|
||||
// table (richer — NPS, idle, follow-ups, leads), per-agent time breakdown,
|
||||
// NPS gauge + conversion metrics, and performance alerts. Kept in one file
|
||||
// so both the Team Dashboard and the legacy Team Performance page render
|
||||
// identically from a single data fetch.
|
||||
|
||||
type DateRange = 'today' | 'week' | 'month' | 'year';
|
||||
|
||||
type AgentPerf = {
|
||||
name: string;
|
||||
ozonetelAgentId: string;
|
||||
npsScore: number | null;
|
||||
maxIdleMinutes: number | null;
|
||||
minNpsThreshold: number | null;
|
||||
minConversionPercent: number | null;
|
||||
calls: number;
|
||||
inbound: number;
|
||||
missed: number;
|
||||
followUps: number;
|
||||
leads: number;
|
||||
appointments: number;
|
||||
convPercent: number;
|
||||
idleMinutes: number;
|
||||
activeMinutes: number;
|
||||
wrapMinutes: number;
|
||||
breakMinutes: number;
|
||||
};
|
||||
|
||||
const getDateRange = (range: DateRange): { gte: string; lte: string } => {
|
||||
const now = new Date();
|
||||
const lte = now.toISOString();
|
||||
const start = new Date(now);
|
||||
if (range === 'today') start.setHours(0, 0, 0, 0);
|
||||
else if (range === 'week') { start.setDate(start.getDate() - start.getDay() + 1); start.setHours(0, 0, 0, 0); }
|
||||
else if (range === 'month') { start.setDate(1); start.setHours(0, 0, 0, 0); }
|
||||
else if (range === 'year') { start.setMonth(0, 1); start.setHours(0, 0, 0, 0); }
|
||||
return { gte: start.toISOString(), lte };
|
||||
};
|
||||
|
||||
const parseTime = (timeStr: string): number => {
|
||||
if (!timeStr) return 0;
|
||||
const parts = timeStr.split(':').map(Number);
|
||||
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
return 0;
|
||||
};
|
||||
|
||||
export const useSupervisorRollup = (range: DateRange) => {
|
||||
const [agents, setAgents] = useState<AgentPerf[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
const { gte, lte } = getDateRange(range);
|
||||
const dateStr = new Date().toISOString().split('T')[0];
|
||||
|
||||
try {
|
||||
const [callsData, leadsData, followUpsData, teamData] = await Promise.all([
|
||||
apiClient.graphql<any>(`{ calls(first: 500, filter: { startedAt: { gte: "${gte}", lte: "${lte}" } }) { edges { node { id direction callStatus agentName startedAt agentId agent { id name ozonetelAgentId } } } } }`, undefined, { silent: true }),
|
||||
apiClient.graphql<any>(`{ leads(first: 200) { edges { node { id assignedAgent status } } } }`, undefined, { silent: true }),
|
||||
apiClient.graphql<any>(`{ followUps(first: 200) { edges { node { id assignedAgent } } } }`, undefined, { silent: true }),
|
||||
apiClient.get<any>(`/api/supervisor/team-performance?date=${dateStr}`, { silent: true }).catch(() => ({ agents: [] })),
|
||||
]);
|
||||
|
||||
const calls = callsData?.calls?.edges?.map((e: any) => e.node) ?? [];
|
||||
const leads = leadsData?.leads?.edges?.map((e: any) => e.node) ?? [];
|
||||
const followUps = followUpsData?.followUps?.edges?.map((e: any) => e.node) ?? [];
|
||||
const teamAgents = teamData?.agents ?? [];
|
||||
|
||||
let agentPerfs: AgentPerf[];
|
||||
|
||||
if (teamAgents.length > 0) {
|
||||
agentPerfs = teamAgents.map((agent: any) => {
|
||||
const agentCalls = calls.filter((c: any) => {
|
||||
if (c.agentId && c.agentId === agent.id) return true;
|
||||
if (!c.agentId && (c.agentName === agent.name || c.agentName === agent.ozonetelAgentId)) return true;
|
||||
return false;
|
||||
});
|
||||
const agentLeads = leads.filter((l: any) => l.assignedAgent === agent.name);
|
||||
const agentFollowUps = followUps.filter((f: any) => f.assignedAgent === agent.name);
|
||||
const agentAppts = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length;
|
||||
const totalCalls = agentCalls.length;
|
||||
const inbound = agentCalls.filter((c: any) => c.direction === 'INBOUND').length;
|
||||
const missed = agentCalls.filter((c: any) => c.callStatus === 'MISSED').length;
|
||||
|
||||
const tb = agent.timeBreakdown;
|
||||
const idleSec = tb ? parseTime(tb.totalIdleTime ?? '0:0:0') : 0;
|
||||
const activeSec = tb ? parseTime(tb.totalBusyTime ?? '0:0:0') : 0;
|
||||
const wrapSec = tb ? parseTime(tb.totalWrapupTime ?? '0:0:0') : 0;
|
||||
const breakSec = tb ? parseTime(tb.totalPauseTime ?? '0:0:0') : 0;
|
||||
|
||||
return {
|
||||
name: agent.name ?? agent.ozonetelAgentId,
|
||||
ozonetelAgentId: agent.ozonetelAgentId,
|
||||
npsScore: agent.npsScore,
|
||||
maxIdleMinutes: agent.maxIdleMinutes,
|
||||
minNpsThreshold: agent.minNpsThreshold,
|
||||
minConversionPercent: agent.minConversionPercent,
|
||||
calls: totalCalls,
|
||||
inbound,
|
||||
missed,
|
||||
followUps: agentFollowUps.length,
|
||||
leads: agentLeads.length,
|
||||
appointments: agentAppts,
|
||||
convPercent: totalCalls > 0 ? Math.round((agentAppts / totalCalls) * 100) : 0,
|
||||
idleMinutes: Math.round(idleSec / 60),
|
||||
activeMinutes: Math.round(activeSec / 60),
|
||||
wrapMinutes: Math.round(wrapSec / 60),
|
||||
breakMinutes: Math.round(breakSec / 60),
|
||||
};
|
||||
});
|
||||
} else {
|
||||
const byKey = new Map<string, { key: string; name: string }>();
|
||||
for (const c of calls) {
|
||||
if (c.agent?.id) byKey.set(c.agent.id, { key: c.agent.id, name: c.agent.name ?? c.agent.ozonetelAgentId });
|
||||
else if (c.agentName) byKey.set(`legacy:${c.agentName}`, { key: `legacy:${c.agentName}`, name: c.agentName });
|
||||
}
|
||||
agentPerfs = Array.from(byKey.values()).map(({ key, name }) => {
|
||||
const agentCalls = calls.filter((c: any) => {
|
||||
if (key.startsWith('legacy:')) return c.agentName === name && !c.agent?.id;
|
||||
return c.agent?.id === key;
|
||||
});
|
||||
const agentLeads = leads.filter((l: any) => l.assignedAgent === name);
|
||||
const agentFollowUps = followUps.filter((f: any) => f.assignedAgent === name);
|
||||
const completed = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length;
|
||||
const totalCalls = agentCalls.length;
|
||||
|
||||
return {
|
||||
name,
|
||||
ozonetelAgentId: name,
|
||||
npsScore: null,
|
||||
maxIdleMinutes: null,
|
||||
minNpsThreshold: null,
|
||||
minConversionPercent: null,
|
||||
calls: totalCalls,
|
||||
inbound: agentCalls.filter((c: any) => c.direction === 'INBOUND').length,
|
||||
missed: agentCalls.filter((c: any) => c.callStatus === 'MISSED').length,
|
||||
followUps: agentFollowUps.length,
|
||||
leads: agentLeads.length,
|
||||
appointments: completed,
|
||||
convPercent: totalCalls > 0 ? Math.round((completed / totalCalls) * 100) : 0,
|
||||
idleMinutes: 0,
|
||||
activeMinutes: 0,
|
||||
wrapMinutes: 0,
|
||||
breakMinutes: 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
setAgents(agentPerfs);
|
||||
} catch (err) {
|
||||
console.error('Failed to load supervisor rollup:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, [range]);
|
||||
|
||||
return { agents, loading };
|
||||
};
|
||||
|
||||
export const RichAgentTable = ({ agents }: { agents: AgentPerf[] }) => (
|
||||
<div className="rounded-xl border border-secondary bg-primary p-4">
|
||||
<h3 className="text-sm font-semibold text-secondary mb-3">Agent Performance</h3>
|
||||
<Table size="sm">
|
||||
<Table.Header>
|
||||
<Table.Head label="Agent" isRowHeader />
|
||||
<Table.Head label="Calls" />
|
||||
<Table.Head label="Inbound" />
|
||||
<Table.Head label="Missed" />
|
||||
<Table.Head label="Follow-ups" />
|
||||
<Table.Head label="Leads" />
|
||||
<Table.Head label="Conv%" />
|
||||
<Table.Head label="NPS" />
|
||||
<Table.Head label="Idle" />
|
||||
</Table.Header>
|
||||
<Table.Body items={agents}>
|
||||
{(agent) => (
|
||||
<Table.Row id={agent.ozonetelAgentId || agent.name}>
|
||||
<Table.Cell><span className="text-sm font-medium text-primary">{agent.name}</span></Table.Cell>
|
||||
<Table.Cell><span className="text-sm text-primary">{agent.calls}</span></Table.Cell>
|
||||
<Table.Cell><span className="text-sm text-primary">{agent.inbound}</span></Table.Cell>
|
||||
<Table.Cell><span className="text-sm text-primary">{agent.missed}</span></Table.Cell>
|
||||
<Table.Cell><span className="text-sm text-primary">{agent.followUps}</span></Table.Cell>
|
||||
<Table.Cell><span className="text-sm text-primary">{agent.leads}</span></Table.Cell>
|
||||
<Table.Cell>
|
||||
<span className={cx('text-sm font-medium', agent.convPercent >= 25 ? 'text-success-primary' : 'text-error-primary')}>
|
||||
{agent.convPercent}%
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span className={cx('text-sm font-bold', (agent.npsScore ?? 0) >= 70 ? 'text-success-primary' : (agent.npsScore ?? 0) >= 50 ? 'text-warning-primary' : 'text-error-primary')}>
|
||||
{agent.npsScore ?? '—'}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span className={cx('text-sm', agent.maxIdleMinutes && agent.idleMinutes > agent.maxIdleMinutes ? 'text-error-primary font-bold' : 'text-primary')}>
|
||||
{agent.idleMinutes}m
|
||||
</span>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
)}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const TimeBreakdown = ({ agents }: { agents: AgentPerf[] }) => {
|
||||
const teamAvg = useMemo(() => {
|
||||
if (agents.length === 0) return { active: 0, wrap: 0, idle: 0, break_: 0 };
|
||||
return {
|
||||
active: Math.round(agents.reduce((s, a) => s + a.activeMinutes, 0) / agents.length),
|
||||
wrap: Math.round(agents.reduce((s, a) => s + a.wrapMinutes, 0) / agents.length),
|
||||
idle: Math.round(agents.reduce((s, a) => s + a.idleMinutes, 0) / agents.length),
|
||||
break_: Math.round(agents.reduce((s, a) => s + a.breakMinutes, 0) / agents.length),
|
||||
};
|
||||
}, [agents]);
|
||||
|
||||
// QA flagged the earlier stacked-bar rendering as misleading — per-agent
|
||||
// totals varied wildly, making the visual width comparison meaningless.
|
||||
// Rendered as a table so the numbers speak for themselves; team-average
|
||||
// row sits at the top as the reference point.
|
||||
return (
|
||||
<div className="rounded-xl border border-secondary bg-primary p-4">
|
||||
<h3 className="text-sm font-semibold text-secondary mb-3">Time Breakdown</h3>
|
||||
{teamAvg.active === 0 && teamAvg.idle === 0 && teamAvg.wrap === 0 && teamAvg.break_ === 0 && (
|
||||
<p className="text-xs text-tertiary mb-3">Time utilisation data unavailable — requires Ozonetel agent session data.</p>
|
||||
)}
|
||||
<Table size="sm">
|
||||
<Table.Header>
|
||||
<Table.Head label="Agent" isRowHeader />
|
||||
<Table.Head label="Active" />
|
||||
<Table.Head label="Wrap" />
|
||||
<Table.Head label="Idle" />
|
||||
<Table.Head label="Break" />
|
||||
<Table.Head label="Total" />
|
||||
</Table.Header>
|
||||
<Table.Body
|
||||
items={[
|
||||
{ id: '__team_avg__', name: 'Team average', isAvg: true, agent: null },
|
||||
...agents.map((a) => ({ id: a.ozonetelAgentId || a.name, name: a.name, isAvg: false, agent: a })),
|
||||
]}
|
||||
>
|
||||
{(item) => {
|
||||
const active = item.isAvg ? teamAvg.active : item.agent!.activeMinutes;
|
||||
const wrap = item.isAvg ? teamAvg.wrap : item.agent!.wrapMinutes;
|
||||
const idle = item.isAvg ? teamAvg.idle : item.agent!.idleMinutes;
|
||||
const breakM = item.isAvg ? teamAvg.break_ : item.agent!.breakMinutes;
|
||||
const total = active + wrap + idle + breakM;
|
||||
const isHighIdle = !item.isAvg && item.agent!.maxIdleMinutes && idle > (item.agent!.maxIdleMinutes ?? 0);
|
||||
return (
|
||||
<Table.Row id={item.id}>
|
||||
<Table.Cell>
|
||||
<span className={cx('text-sm', item.isAvg ? 'font-bold text-secondary' : 'font-medium text-primary')}>
|
||||
{item.name}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell><span className="text-sm text-primary">{active}m</span></Table.Cell>
|
||||
<Table.Cell><span className="text-sm text-primary">{wrap}m</span></Table.Cell>
|
||||
<Table.Cell>
|
||||
<span className={cx('text-sm', isHighIdle ? 'font-bold text-error-primary' : 'text-primary')}>
|
||||
{idle}m
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell><span className="text-sm text-primary">{breakM}m</span></Table.Cell>
|
||||
<Table.Cell><span className="text-sm text-secondary">{total}m</span></Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
}}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const NpsConversion = ({ agents, convRate }: { agents: AgentPerf[]; convRate: number }) => {
|
||||
const avgNps = useMemo(() => {
|
||||
const withNps = agents.filter(a => a.npsScore != null);
|
||||
if (withNps.length === 0) return 0;
|
||||
return Math.round(withNps.reduce((sum, a) => sum + (a.npsScore ?? 0), 0) / withNps.length);
|
||||
}, [agents]);
|
||||
|
||||
const npsOption = useMemo(() => ({
|
||||
tooltip: { trigger: 'item' },
|
||||
series: [{
|
||||
type: 'gauge', startAngle: 180, endAngle: 0,
|
||||
min: 0, max: 100,
|
||||
pointer: { show: false },
|
||||
progress: { show: true, width: 18, roundCap: true, itemStyle: { color: avgNps >= 70 ? '#22C55E' : avgNps >= 50 ? '#F59E0B' : '#EF4444' } },
|
||||
axisLine: { lineStyle: { width: 18, color: [[1, '#E5E7EB']] } },
|
||||
axisTick: { show: false }, splitLine: { show: false }, axisLabel: { show: false },
|
||||
detail: { valueAnimation: true, fontSize: 28, fontWeight: 'bold', offsetCenter: [0, '-10%'], formatter: '{value}' },
|
||||
data: [{ value: avgNps }],
|
||||
}],
|
||||
}), [avgNps]);
|
||||
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1 rounded-xl border border-secondary bg-primary p-4">
|
||||
<h3 className="text-sm font-semibold text-secondary mb-2">Overall NPS</h3>
|
||||
{agents.every(a => a.npsScore == null) ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<p className="text-xs text-tertiary">NPS data unavailable — configure NPS scores on agent profiles.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ReactECharts option={npsOption} style={{ height: 150 }} />
|
||||
<div className="space-y-1 mt-2">
|
||||
{agents.filter(a => a.npsScore != null).map(a => (
|
||||
<div key={a.name} className="flex items-center gap-2">
|
||||
<span className="text-xs text-secondary w-28 truncate">{a.name}</span>
|
||||
<div className="flex-1 h-2 rounded-full bg-tertiary overflow-hidden">
|
||||
<div className={cx('h-full rounded-full', (a.npsScore ?? 0) >= 70 ? 'bg-success-solid' : (a.npsScore ?? 0) >= 50 ? 'bg-warning-solid' : 'bg-error-solid')} style={{ width: `${a.npsScore ?? 0}%` }} />
|
||||
</div>
|
||||
<span className="text-xs font-bold text-primary w-8 text-right">{a.npsScore}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 rounded-xl border border-secondary bg-primary p-4">
|
||||
<h3 className="text-sm font-semibold text-secondary mb-3">Conversion Metrics</h3>
|
||||
<div className="flex gap-3 mb-4">
|
||||
<div className="flex-1 rounded-lg bg-secondary p-4 text-center">
|
||||
<p className="text-2xl font-bold text-brand-secondary">{convRate}%</p>
|
||||
<p className="text-xs text-tertiary">Call → Appointment</p>
|
||||
</div>
|
||||
<div className="flex-1 rounded-lg bg-secondary p-4 text-center">
|
||||
<p className="text-2xl font-bold text-brand-secondary">
|
||||
{agents.length > 0 ? Math.round(agents.reduce((s, a) => s + (a.leads > 0 ? 1 : 0), 0) / agents.length * 100) : 0}%
|
||||
</p>
|
||||
<p className="text-xs text-tertiary">Lead → Contact</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{agents.map(a => (
|
||||
<div key={a.name} className="flex items-center gap-2 text-xs">
|
||||
<span className="text-secondary w-28 truncate">{a.name}</span>
|
||||
<Badge size="sm" color={a.convPercent >= 25 ? 'success' : 'error'}>{a.convPercent}%</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const PerformanceAlerts = ({ agents }: { agents: AgentPerf[] }) => {
|
||||
const alerts = useMemo(() => {
|
||||
const list: { agent: string; type: string; value: string; severity: 'error' | 'warning' }[] = [];
|
||||
for (const a of agents) {
|
||||
if (a.maxIdleMinutes && a.idleMinutes > a.maxIdleMinutes) {
|
||||
list.push({ agent: a.name, type: 'Excessive Idle Time', value: `${a.idleMinutes}m`, severity: 'error' });
|
||||
}
|
||||
if (a.minNpsThreshold && (a.npsScore ?? 100) < a.minNpsThreshold) {
|
||||
list.push({ agent: a.name, type: 'Low NPS', value: String(a.npsScore ?? 0), severity: 'warning' });
|
||||
}
|
||||
if (a.minConversionPercent && a.convPercent < a.minConversionPercent) {
|
||||
list.push({ agent: a.name, type: 'Low Conversion', value: `${a.convPercent}%`, severity: 'warning' });
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}, [agents]);
|
||||
|
||||
if (alerts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-secondary bg-primary p-4">
|
||||
<h3 className="text-sm font-semibold text-error-primary mb-3">
|
||||
<FontAwesomeIcon icon={faTriangleExclamation} className="size-3.5 mr-1.5" />
|
||||
Performance Alerts ({alerts.length})
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{alerts.map((alert, i) => (
|
||||
<div key={i} className={cx(
|
||||
'flex items-center justify-between rounded-lg px-4 py-3',
|
||||
alert.severity === 'error' ? 'bg-error-secondary' : 'bg-warning-secondary',
|
||||
)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faTriangleExclamation} className={cx('size-3.5', alert.severity === 'error' ? 'text-fg-error-primary' : 'text-fg-warning-primary')} />
|
||||
<span className="text-sm font-medium text-primary">{alert.agent}</span>
|
||||
<span className="text-sm text-secondary">— {alert.type}</span>
|
||||
</div>
|
||||
<Badge size="sm" color={alert.severity}>{alert.value}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
faUsers,
|
||||
faArrowRightFromBracket,
|
||||
faTowerBroadcast,
|
||||
faChartLine,
|
||||
faFileAudio,
|
||||
faPhoneMissed,
|
||||
} from "@fortawesome/pro-duotone-svg-icons";
|
||||
@@ -30,6 +29,7 @@ import { NavItemBase } from "@/components/application/app-navigation/base-compon
|
||||
import type { NavItemType } from "@/components/application/app-navigation/config";
|
||||
import { Avatar } from "@/components/base/avatar/avatar";
|
||||
import { useAuth } from "@/providers/auth-provider";
|
||||
import { useUiFlags } from "@/hooks/use-ui-flags";
|
||||
import { useAgentState } from "@/hooks/use-agent-state";
|
||||
import { useThemeTokens } from "@/providers/theme-token-provider";
|
||||
import { sidebarCollapsedAtom } from "@/state/sidebar-state";
|
||||
@@ -49,7 +49,6 @@ const IconUsers = faIcon(faUsers);
|
||||
const IconHospitalUser = faIcon(faHospitalUser);
|
||||
const IconCalendarCheck = faIcon(faCalendarCheck);
|
||||
const IconTowerBroadcast = faIcon(faTowerBroadcast);
|
||||
const IconChartLine = faIcon(faChartLine);
|
||||
const IconFileAudio = faIcon(faFileAudio);
|
||||
const IconPhoneMissed = faIcon(faPhoneMissed);
|
||||
|
||||
@@ -62,8 +61,11 @@ const getNavSections = (role: string): NavSection[] => {
|
||||
if (role === 'admin') {
|
||||
return [
|
||||
{ label: 'Supervisor', items: [
|
||||
// Team Performance retired as a nav entry — its surfaces
|
||||
// (time breakdown, NPS/conversion, alerts, richer agent
|
||||
// table) are now rolled into the Dashboard. The route is
|
||||
// kept alive for reference but not linked in the sidebar.
|
||||
{ label: 'Dashboard', href: '/', icon: IconGrid2 },
|
||||
{ label: 'Team Performance', href: '/team-performance', icon: IconChartLine },
|
||||
{ label: 'Live Call Monitor', href: '/live-monitor', icon: IconTowerBroadcast },
|
||||
]},
|
||||
{ label: 'Data & Reports', items: [
|
||||
@@ -149,7 +151,16 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const navSections = getNavSections(user.role);
|
||||
const uiFlags = useUiFlags();
|
||||
const navSections = getNavSections(user.role).map((section) => ({
|
||||
...section,
|
||||
items: uiFlags.setupManaged
|
||||
// When setup is managed by the product team (per-tenant flag),
|
||||
// hide the Settings entry from the nav. The route is also
|
||||
// blocked in router-provider so a stray bookmark doesn't work.
|
||||
? section.items.filter((item) => item.href !== '/settings')
|
||||
: section.items,
|
||||
})).filter((section) => section.items.length > 0);
|
||||
|
||||
const content = (
|
||||
<aside
|
||||
|
||||
@@ -5,9 +5,10 @@ import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/mod
|
||||
import { PinInput } from '@/components/base/pin-input/pin-input';
|
||||
import { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faShieldKeyhole } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { faShieldKeyhole, faLock, faLockOpen } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import type { FC } from 'react';
|
||||
import { notify } from '@/lib/toast';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
const ShieldIcon: FC<{ className?: string }> = ({ className }) => (
|
||||
<FontAwesomeIcon icon={faShieldKeyhole} className={className} />
|
||||
@@ -20,9 +21,14 @@ type MaintAction = {
|
||||
label: string;
|
||||
description: string;
|
||||
needsPreStep?: boolean;
|
||||
agentPickerEndpoint?: string;
|
||||
clientSideHandler?: (payload: any) => Promise<{ status: string; message: string }>;
|
||||
};
|
||||
|
||||
type LockedRow = { agentId: string; displayName: string; heldByIp: string; lockedAt: string };
|
||||
type FreeRow = { agentId: string; displayName: string };
|
||||
type SessionStatus = { locked: LockedRow[]; free: FreeRow[] };
|
||||
|
||||
type MaintOtpModalProps = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -36,6 +42,55 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
|
||||
const [otp, setOtp] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Phase-2 state: once the OTP passes and the action uses an agent
|
||||
// picker, we swap the PIN input for a two-bucket list (Locked / Free)
|
||||
// fetched from `agentPickerEndpoint`. The operator picks a locked
|
||||
// agent, then Confirm posts to the real `endpoint`.
|
||||
const [sessionStatus, setSessionStatus] = useState<SessionStatus | null>(null);
|
||||
const [pickedAgentId, setPickedAgentId] = useState<string | null>(null);
|
||||
// OTP is held across the two-phase flow so we don't force the user
|
||||
// to re-enter it after the picker loads.
|
||||
const [verifiedOtp, setVerifiedOtp] = useState<string | null>(null);
|
||||
|
||||
const reset = () => {
|
||||
setOtp('');
|
||||
setError(null);
|
||||
setSessionStatus(null);
|
||||
setPickedAgentId(null);
|
||||
setVerifiedOtp(null);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onOpenChange(false);
|
||||
reset();
|
||||
};
|
||||
|
||||
const postMaint = async (endpoint: string, body: Record<string, any>, otpHeader: string) => {
|
||||
const res = await fetch(`${API_URL}/api/maint/${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-maint-otp': otpHeader },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return { ok: res.ok, data };
|
||||
};
|
||||
|
||||
const runPickerAction = async (pickedId: string, otpHeader: string) => {
|
||||
if (!action) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const payload = { ...preStepPayload, agentId: pickedId };
|
||||
const { ok, data } = await postMaint(action.endpoint, payload, otpHeader);
|
||||
setLoading(false);
|
||||
if (ok) {
|
||||
notify.success(action.label, data.message ?? 'Completed successfully');
|
||||
onOpenChange(false);
|
||||
reset();
|
||||
} else {
|
||||
setError(data.message ?? 'Failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!action || otp.length < 6) return;
|
||||
@@ -43,44 +98,49 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Two-phase agent-picker flow — OTP first, then fetch list,
|
||||
// then the operator picks which agent to act on.
|
||||
if (action.agentPickerEndpoint) {
|
||||
const { ok, data } = await postMaint(action.agentPickerEndpoint, {}, otp);
|
||||
if (!ok) {
|
||||
setError(data.message ?? 'Invalid maintenance code');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setSessionStatus(data as SessionStatus);
|
||||
setVerifiedOtp(otp);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.clientSideHandler) {
|
||||
// Client-side action — OTP verified by calling a dummy maint endpoint first
|
||||
const otpRes = await fetch(`${API_URL}/api/maint/force-ready`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-maint-otp': otp },
|
||||
});
|
||||
if (!otpRes.ok) {
|
||||
setError('Invalid maintenance code');
|
||||
const { ok, data } = await postMaint('force-ready', {}, otp);
|
||||
if (!ok) {
|
||||
setError(data.message ?? 'Invalid maintenance code');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const result = await action.clientSideHandler(preStepPayload);
|
||||
notify.success(action.label, result.message ?? 'Completed');
|
||||
onOpenChange(false);
|
||||
setOtp('');
|
||||
} else {
|
||||
// Standard sidecar endpoint — include agentId from agent config
|
||||
const agentCfg = localStorage.getItem('helix_agent_config');
|
||||
const agentId = agentCfg ? JSON.parse(agentCfg).ozonetelAgentId : undefined;
|
||||
const payload = { ...preStepPayload, ...(agentId ? { agentId } : {}) };
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_URL}/api/maint/${action.endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-maint-otp': otp,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
console.log(`[MAINT] ${action.label}:`, data);
|
||||
notify.success(action.label, data.message ?? 'Completed successfully');
|
||||
onOpenChange(false);
|
||||
setOtp('');
|
||||
} else {
|
||||
setError(data.message ?? 'Failed');
|
||||
}
|
||||
// Default: single-shot endpoint with agentId from the CC agent's
|
||||
// own local config (cc-agent context). Supervisors hitting this
|
||||
// path without agent config used to get 400 — the agent-picker
|
||||
// branch above is the fix.
|
||||
const agentCfg = localStorage.getItem('helix_agent_config');
|
||||
const agentId = agentCfg ? JSON.parse(agentCfg).ozonetelAgentId : undefined;
|
||||
const payload = { ...preStepPayload, ...(agentId ? { agentId } : {}) };
|
||||
const { ok, data } = await postMaint(action.endpoint, payload, otp);
|
||||
if (ok) {
|
||||
notify.success(action.label, data.message ?? 'Completed successfully');
|
||||
onOpenChange(false);
|
||||
reset();
|
||||
} else {
|
||||
setError(data.message ?? 'Failed');
|
||||
}
|
||||
} catch {
|
||||
setError('Request failed');
|
||||
@@ -94,19 +154,25 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onOpenChange(false);
|
||||
setOtp('');
|
||||
setError(null);
|
||||
};
|
||||
|
||||
if (!action) return null;
|
||||
|
||||
const showOtp = !action.needsPreStep || preStepReady;
|
||||
const showPicker = Boolean(action.agentPickerEndpoint && sessionStatus && verifiedOtp);
|
||||
const showOtp = (!action.needsPreStep || preStepReady) && !showPicker;
|
||||
const confirmDisabled = showPicker
|
||||
? !pickedAgentId || loading
|
||||
: otp.length < 6 || loading || (action.needsPreStep && !preStepReady);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (showPicker && pickedAgentId && verifiedOtp) {
|
||||
await runPickerAction(pickedAgentId, verifiedOtp);
|
||||
} else {
|
||||
await handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalOverlay isOpen={isOpen} onOpenChange={handleClose} isDismissable>
|
||||
<Modal className="sm:max-w-[400px]">
|
||||
<Modal className="sm:max-w-[440px]">
|
||||
<Dialog>
|
||||
{() => (
|
||||
<div className="flex w-full flex-col rounded-xl bg-primary shadow-xl ring-1 ring-secondary overflow-hidden">
|
||||
@@ -120,13 +186,12 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
|
||||
</div>
|
||||
|
||||
{/* Pre-step content (e.g., campaign selection) */}
|
||||
{action.needsPreStep && preStepContent && (
|
||||
{action.needsPreStep && preStepContent && !showPicker && (
|
||||
<div className="px-6 pb-4">
|
||||
{preStepContent}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pin Input — shown when pre-step is ready (or no pre-step needed) */}
|
||||
{showOtp && (
|
||||
<div className="flex flex-col items-center gap-2 px-6 pb-5">
|
||||
<PinInput size="sm">
|
||||
@@ -154,6 +219,87 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPicker && sessionStatus && (
|
||||
<div className="px-6 pb-5 space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FontAwesomeIcon icon={faLock} className="size-3.5 text-fg-error-primary" />
|
||||
<p className="text-xs font-semibold uppercase text-secondary">
|
||||
Locked ({sessionStatus.locked.length})
|
||||
</p>
|
||||
</div>
|
||||
{sessionStatus.locked.length === 0 ? (
|
||||
<p className="text-sm text-tertiary pl-5">No active session locks.</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{sessionStatus.locked.map((row) => {
|
||||
const selected = pickedAgentId === row.agentId;
|
||||
return (
|
||||
<button
|
||||
key={row.agentId}
|
||||
type="button"
|
||||
onClick={() => setPickedAgentId(row.agentId)}
|
||||
className={cx(
|
||||
'w-full flex items-start justify-between gap-3 rounded-lg border p-3 text-left transition duration-100 ease-linear',
|
||||
selected
|
||||
? 'border-brand bg-brand-primary_alt'
|
||||
: 'border-secondary hover:border-brand hover:bg-secondary',
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-primary truncate">{row.displayName}</p>
|
||||
<p className="text-xs text-tertiary truncate">
|
||||
<code className="font-mono">{row.agentId}</code> — held by {row.heldByIp}
|
||||
</p>
|
||||
<p className="text-xs text-quaternary">
|
||||
since {new Date(row.lockedAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
{selected && (
|
||||
<span className="shrink-0 text-xs font-semibold text-brand-secondary">Selected</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<FontAwesomeIcon icon={faLockOpen} className="size-3.5 text-fg-success-primary" />
|
||||
<p className="text-xs font-semibold uppercase text-secondary">
|
||||
Free ({sessionStatus.free.length})
|
||||
</p>
|
||||
</div>
|
||||
{sessionStatus.free.length === 0 ? (
|
||||
<p className="text-sm text-tertiary pl-5">No free agents.</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{sessionStatus.free.map((row) => (
|
||||
<div
|
||||
key={row.agentId}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-secondary bg-disabled_subtle p-3 opacity-70"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-secondary truncate">{row.displayName}</p>
|
||||
<p className="text-xs text-quaternary truncate">
|
||||
<code className="font-mono">{row.agentId}</code>
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs font-medium text-success-primary">Already free</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-error-primary">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center gap-3 border-t border-secondary px-6 py-4">
|
||||
<Button size="md" color="secondary" onClick={handleClose} className="flex-1">
|
||||
@@ -162,9 +308,9 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
|
||||
<Button
|
||||
size="md"
|
||||
color="primary"
|
||||
isDisabled={otp.length < 6 || loading || (action.needsPreStep && !preStepReady)}
|
||||
isDisabled={confirmDisabled}
|
||||
isLoading={loading}
|
||||
onClick={handleSubmit}
|
||||
onClick={handleConfirm}
|
||||
className="flex-1"
|
||||
>
|
||||
Confirm
|
||||
|
||||
@@ -4,6 +4,7 @@ import { faCircleInfo, faXmark, faArrowRight } from '@fortawesome/pro-duotone-sv
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { getSetupState, SETUP_STEP_NAMES, type SetupState } from '@/lib/setup-state';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import { useUiFlags } from '@/hooks/use-ui-flags';
|
||||
|
||||
// Dismissible banner shown across the top of authenticated pages when
|
||||
// the hospital workspace has incomplete setup steps AND the admin has
|
||||
@@ -19,22 +20,23 @@ import { useAuth } from '@/providers/auth-provider';
|
||||
// - Not dismissed in the current browser session (resets on reload)
|
||||
export const ResumeSetupBanner = () => {
|
||||
const { isAdmin } = useAuth();
|
||||
const { setupManaged } = useUiFlags();
|
||||
const [state, setState] = useState<SetupState | null>(null);
|
||||
const [dismissed, setDismissed] = useState(
|
||||
() => sessionStorage.getItem('helix_resume_setup_dismissed') === '1',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin || dismissed) return;
|
||||
if (!isAdmin || dismissed || setupManaged) return;
|
||||
getSetupState()
|
||||
.then(setState)
|
||||
.catch(() => {
|
||||
// Non-fatal — if setup-state isn't reachable, just
|
||||
// skip the banner. The wizard still works.
|
||||
});
|
||||
}, [isAdmin, dismissed]);
|
||||
}, [isAdmin, dismissed, setupManaged]);
|
||||
|
||||
if (!isAdmin || !state || dismissed) return null;
|
||||
if (!isAdmin || !state || dismissed || setupManaged) return null;
|
||||
|
||||
const incompleteCount = SETUP_STEP_NAMES.filter((s) => !state.steps[s].completed).length;
|
||||
if (incompleteCount === 0) return null;
|
||||
|
||||
@@ -10,27 +10,32 @@ type SectionCardProps = {
|
||||
description: string;
|
||||
icon: any;
|
||||
iconColor?: string;
|
||||
href: string;
|
||||
// Either navigate (href) OR intercept the click (onClick). When onClick
|
||||
// is provided, href is ignored and the card renders as a button. Used
|
||||
// while self-serve setup is disabled — all clicks go through a
|
||||
// "contact product team" modal in settings.tsx.
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
status?: SectionStatus;
|
||||
};
|
||||
|
||||
// Settings hub card. Each card represents one setup-able section (Branding,
|
||||
// Clinics, Doctors, Team, Telephony, AI, Widget, Rules) and links to its
|
||||
// dedicated page. The status badge mirrors the wizard's setup-state so an
|
||||
// admin can see at a glance which sections still need attention.
|
||||
// Clinics, Doctors, Team, Telephony, AI, Widget, Rules) and either links to
|
||||
// its dedicated page or triggers a parent-owned callback.
|
||||
export const SectionCard = ({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
iconColor = 'text-brand-primary',
|
||||
href,
|
||||
onClick,
|
||||
status = 'unknown',
|
||||
}: SectionCardProps) => {
|
||||
return (
|
||||
<Link
|
||||
to={href}
|
||||
className="group block rounded-xl border border-secondary bg-primary p-5 shadow-xs transition hover:border-brand hover:shadow-md"
|
||||
>
|
||||
const className = cx(
|
||||
'group block w-full text-left rounded-xl border border-secondary bg-primary p-5 shadow-xs transition hover:border-brand hover:shadow-md',
|
||||
);
|
||||
const body = (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-secondary">
|
||||
@@ -62,6 +67,19 @@ export const SectionCard = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<button type="button" onClick={onClick} className={className}>
|
||||
{body}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Link to={href ?? '#'} className={className}>
|
||||
{body}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,6 +5,10 @@ export type MaintAction = {
|
||||
label: string;
|
||||
description: string;
|
||||
needsPreStep?: boolean;
|
||||
// When set, after OTP passes the modal calls this endpoint to fetch
|
||||
// `{ locked, free }` agent buckets and shows a picker. Confirm then
|
||||
// POSTs to `endpoint` with { agentId } from the selection.
|
||||
agentPickerEndpoint?: string;
|
||||
clientSideHandler?: (payload: any) => Promise<{ status: string; message: string }>;
|
||||
};
|
||||
|
||||
@@ -13,11 +17,13 @@ const MAINT_ACTIONS: Record<string, MaintAction> = {
|
||||
endpoint: 'force-ready',
|
||||
label: 'Force Ready',
|
||||
description: 'Logout and re-login the agent to force Ready state on Ozonetel.',
|
||||
agentPickerEndpoint: 'session-status',
|
||||
},
|
||||
unlockAgent: {
|
||||
endpoint: 'unlock-agent',
|
||||
label: 'Unlock Agent',
|
||||
description: 'Release the Redis session lock so the agent can log in again.',
|
||||
agentPickerEndpoint: 'session-status',
|
||||
},
|
||||
backfill: {
|
||||
endpoint: 'backfill-missed-calls',
|
||||
|
||||
50
src/hooks/use-ui-flags.ts
Normal file
50
src/hooks/use-ui-flags.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
|
||||
// Per-tenant UI flags the sidecar controls via env vars. Read once at
|
||||
// app mount; cached in module scope so every consumer gets the same
|
||||
// snapshot without re-fetching. Safe defaults when the sidecar doesn't
|
||||
// respond (all flags off) so the UI stays functional.
|
||||
export type UiFlags = {
|
||||
setupManaged: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_FLAGS: UiFlags = {
|
||||
setupManaged: false,
|
||||
};
|
||||
|
||||
let cachedFlags: UiFlags | null = null;
|
||||
let inflight: Promise<UiFlags> | null = null;
|
||||
|
||||
export const getUiFlags = (): Promise<UiFlags> => fetchFlags();
|
||||
|
||||
const fetchFlags = (): Promise<UiFlags> => {
|
||||
if (cachedFlags) return Promise.resolve(cachedFlags);
|
||||
if (inflight) return inflight;
|
||||
inflight = apiClient
|
||||
.get<UiFlags>('/api/config/ui-flags', { silent: true })
|
||||
.then((res) => {
|
||||
cachedFlags = { ...DEFAULT_FLAGS, ...res };
|
||||
return cachedFlags;
|
||||
})
|
||||
.catch(() => {
|
||||
cachedFlags = { ...DEFAULT_FLAGS };
|
||||
return cachedFlags;
|
||||
})
|
||||
.finally(() => {
|
||||
inflight = null;
|
||||
});
|
||||
return inflight;
|
||||
};
|
||||
|
||||
export const useUiFlags = (): UiFlags => {
|
||||
const [flags, setFlags] = useState<UiFlags>(cachedFlags ?? DEFAULT_FLAGS);
|
||||
useEffect(() => {
|
||||
if (cachedFlags) {
|
||||
setFlags(cachedFlags);
|
||||
return;
|
||||
}
|
||||
fetchFlags().then(setFlags);
|
||||
}, []);
|
||||
return flags;
|
||||
};
|
||||
@@ -1,5 +1,36 @@
|
||||
export type CSVRow = Record<string, string>;
|
||||
|
||||
// CSV write-side. Quote every value and escape embedded quotes. Prefix
|
||||
// ="+-@ with a single quote so Excel doesn't interpret them as formulas
|
||||
// (classic CSV-injection vector on exports opened in spreadsheet apps).
|
||||
const escapeCsvCell = (raw: unknown): string => {
|
||||
const value = raw == null ? '' : String(raw);
|
||||
const sanitized = /^[=+\-@]/.test(value) ? `'${value}` : value;
|
||||
return `"${sanitized.replace(/"/g, '""')}"`;
|
||||
};
|
||||
|
||||
export const rowsToCsv = (headers: string[], rows: Array<Record<string, unknown>>): string => {
|
||||
const lines = [headers.map(escapeCsvCell).join(',')];
|
||||
for (const row of rows) {
|
||||
lines.push(headers.map((h) => escapeCsvCell(row[h])).join(','));
|
||||
}
|
||||
return lines.join('\r\n');
|
||||
};
|
||||
|
||||
export const downloadCsv = (filename: string, csv: string): void => {
|
||||
// BOM prefix so Excel recognises UTF-8 for non-ASCII names/addresses.
|
||||
const blob = new Blob(['\ufeff', csv], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
|
||||
export type CSVParseResult = {
|
||||
headers: string[];
|
||||
rows: CSVRow[];
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
// GraphQL queries for platform entities
|
||||
// Platform remaps SDK field names — all LINKS/PHONES fields need subfield selection
|
||||
// Platform remaps SDK field names — all LINKS/PHONES fields need subfield selection.
|
||||
//
|
||||
// Each entity exports a query *builder* that accepts an optional `after`
|
||||
// cursor. The data-provider paginates until `hasNextPage=false` so the
|
||||
// dashboard KPIs reflect real totals instead of the first 100 rows. The
|
||||
// previous hardcoded `first: 100` caps caused supervisor KPI cards to
|
||||
// quietly plateau at 100 on busy tenants.
|
||||
//
|
||||
// `pageSize` is intentionally large (200) to keep round-trips low. The
|
||||
// platform Relay pagination accepts up to 1000 but 200 is a good balance
|
||||
// between latency per page and number of pages on active workspaces.
|
||||
|
||||
export const LEADS_QUERY = `{ leads(first: 100, orderBy: [{ createdAt: DescNullsLast }]) { edges { node {
|
||||
const PAGE_SIZE = 200;
|
||||
|
||||
const cursorArg = (after?: string): string => (after ? `, after: "${after}"` : '');
|
||||
|
||||
export const leadsQuery = (after?: string) => `{ leads(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ createdAt: DescNullsLast }]) { edges { node {
|
||||
id name createdAt updatedAt
|
||||
contactName { firstName lastName }
|
||||
contactPhone { primaryPhoneNumber primaryPhoneCallingCode }
|
||||
@@ -12,9 +26,9 @@ export const LEADS_QUERY = `{ leads(first: 100, orderBy: [{ createdAt: DescNulls
|
||||
firstContacted lastContacted contactAttempts convertedAt
|
||||
patientId campaignId
|
||||
aiSummary aiSuggestedAction
|
||||
} } } }`;
|
||||
} } pageInfo { hasNextPage endCursor } } }`;
|
||||
|
||||
export const CAMPAIGNS_QUERY = `{ campaigns(first: 50, orderBy: [{ createdAt: DescNullsLast }]) { edges { node {
|
||||
export const campaignsQuery = (after?: string) => `{ campaigns(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ createdAt: DescNullsLast }]) { edges { node {
|
||||
id name createdAt updatedAt
|
||||
campaignName typeCustom status platform
|
||||
startDate endDate
|
||||
@@ -22,33 +36,33 @@ export const CAMPAIGNS_QUERY = `{ campaigns(first: 50, orderBy: [{ createdAt: De
|
||||
amountSpent { amountMicros currencyCode }
|
||||
impressions clicks targetCount contacted converted leadsGenerated
|
||||
externalCampaignId platformUrl { primaryLinkUrl }
|
||||
} } } }`;
|
||||
} } pageInfo { hasNextPage endCursor } } }`;
|
||||
|
||||
export const ADS_QUERY = `{ ads(first: 100, orderBy: [{ createdAt: DescNullsLast }]) { edges { node {
|
||||
export const adsQuery = (after?: string) => `{ ads(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ createdAt: DescNullsLast }]) { edges { node {
|
||||
id name createdAt updatedAt
|
||||
adName externalAdId status format
|
||||
headline adDescription destinationUrl { primaryLinkUrl } previewUrl { primaryLinkUrl }
|
||||
impressions clicks conversions
|
||||
spend { amountMicros currencyCode }
|
||||
campaignId
|
||||
} } } }`;
|
||||
} } pageInfo { hasNextPage endCursor } } }`;
|
||||
|
||||
export const FOLLOW_UPS_QUERY = `{ followUps(first: 50, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||
export const followUpsQuery = (after?: string) => `{ followUps(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||
id name createdAt
|
||||
typeCustom status scheduledAt completedAt
|
||||
priority assignedAgent
|
||||
patientId
|
||||
} } } }`;
|
||||
} } pageInfo { hasNextPage endCursor } } }`;
|
||||
|
||||
export const LEAD_ACTIVITIES_QUERY = `{ leadActivities(first: 200, orderBy: [{ occurredAt: DescNullsLast }]) { edges { node {
|
||||
export const leadActivitiesQuery = (after?: string) => `{ leadActivities(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ occurredAt: DescNullsLast }]) { edges { node {
|
||||
id name createdAt
|
||||
activityType summary occurredAt performedBy
|
||||
previousValue newValue
|
||||
channel durationSec outcome
|
||||
leadId
|
||||
} } } }`;
|
||||
} } pageInfo { hasNextPage endCursor } } }`;
|
||||
|
||||
export const CALLS_QUERY = `{ calls(first: 100, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
||||
export const callsQuery = (after?: string) => `{ calls(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
||||
id name createdAt
|
||||
direction callStatus callerNumber { primaryPhoneNumber } agentName
|
||||
startedAt endedAt durationSec
|
||||
@@ -56,9 +70,27 @@ export const CALLS_QUERY = `{ calls(first: 100, orderBy: [{ startedAt: DescNulls
|
||||
patientId appointmentId leadId
|
||||
agentId agent { id name ozonetelAgentId }
|
||||
transferredTo transferType
|
||||
} } } }`;
|
||||
} } pageInfo { hasNextPage endCursor } } }`;
|
||||
|
||||
export const DOCTORS_QUERY = `{ doctors(first: 20) { edges { node {
|
||||
export const appointmentsQuery = (after?: string) => `{ appointments(first: ${PAGE_SIZE}${cursorArg(after)}, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||
id name createdAt
|
||||
scheduledAt durationMin appointmentType status
|
||||
doctorName department reasonForVisit
|
||||
patient { id fullName { firstName lastName } phones { primaryPhoneNumber } }
|
||||
doctor { id fullName { firstName lastName } }
|
||||
clinicId clinic { id clinicName }
|
||||
} } pageInfo { hasNextPage endCursor } } }`;
|
||||
|
||||
export const patientsQuery = (after?: string) => `{ patients(first: ${PAGE_SIZE}${cursorArg(after)}) { edges { node {
|
||||
id name fullName { firstName lastName }
|
||||
phones { primaryPhoneNumber }
|
||||
emails { primaryEmail }
|
||||
dateOfBirth gender patientType
|
||||
} } pageInfo { hasNextPage endCursor } } }`;
|
||||
|
||||
// Doctors are a small reference set (< 50 per workspace) — no pagination
|
||||
// needed. Left as a plain string for the single consumer that reads it.
|
||||
export const DOCTORS_QUERY = `{ doctors(first: 50) { edges { node {
|
||||
id name fullName { firstName lastName }
|
||||
department specialty qualifications yearsOfExperience
|
||||
visitingHours
|
||||
@@ -67,19 +99,3 @@ export const DOCTORS_QUERY = `{ doctors(first: 20) { edges { node {
|
||||
active registrationNumber
|
||||
clinic { id name clinicName }
|
||||
} } } }`;
|
||||
|
||||
export const APPOINTMENTS_QUERY = `{ appointments(first: 100, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||
id name createdAt
|
||||
scheduledAt durationMin appointmentType status
|
||||
doctorName department reasonForVisit
|
||||
patient { id fullName { firstName lastName } phones { primaryPhoneNumber } }
|
||||
doctor { id fullName { firstName lastName } }
|
||||
clinicId clinic { id clinicName }
|
||||
} } } }`;
|
||||
|
||||
export const PATIENTS_QUERY = `{ patients(first: 50) { edges { node {
|
||||
id name fullName { firstName lastName }
|
||||
phones { primaryPhoneNumber }
|
||||
emails { primaryEmail }
|
||||
dateOfBirth gender patientType
|
||||
} } } }`;
|
||||
|
||||
33
src/main.tsx
33
src/main.tsx
@@ -5,16 +5,31 @@ import { AppShell } from "@/components/layout/app-shell";
|
||||
import { AuthGuard } from "@/components/layout/auth-guard";
|
||||
import { useAuth } from "@/providers/auth-provider";
|
||||
import { SetupWizardPage } from "@/pages/setup-wizard";
|
||||
import { useUiFlags } from "@/hooks/use-ui-flags";
|
||||
|
||||
const AdminSetupGuard = () => {
|
||||
const { isAdmin } = useAuth();
|
||||
return isAdmin ? <SetupWizardPage /> : <Navigate to="/" replace />;
|
||||
const { setupManaged } = useUiFlags();
|
||||
if (!isAdmin) return <Navigate to="/" replace />;
|
||||
// When setup is managed by the product team for this tenant, there's
|
||||
// nothing for an admin to do in the wizard — bounce them to the
|
||||
// dashboard instead of rendering a dead-end page.
|
||||
if (setupManaged) return <Navigate to="/" replace />;
|
||||
return <SetupWizardPage />;
|
||||
};
|
||||
|
||||
const RequireAdmin = () => {
|
||||
const { isAdmin } = useAuth();
|
||||
return isAdmin ? <Outlet /> : <Navigate to="/" replace />;
|
||||
};
|
||||
|
||||
const RequireSelfServeSetup = () => {
|
||||
const { setupManaged } = useUiFlags();
|
||||
// Blocks /settings/* when the tenant's setup is product-team managed.
|
||||
// Sidebar already hides the nav entry, but this catches stray bookmarks
|
||||
// and deep links.
|
||||
return setupManaged ? <Navigate to="/" replace /> : <Outlet />;
|
||||
};
|
||||
import { RoleRouter } from "@/components/layout/role-router";
|
||||
import { NotFound } from "@/pages/not-found";
|
||||
import { AllLeadsPage } from "@/pages/all-leads";
|
||||
@@ -99,13 +114,15 @@ createRoot(document.getElementById("root")!).render(
|
||||
<Route path="/team-dashboard" element={<TeamDashboardPage />} />
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
<Route path="/integrations" element={<IntegrationsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/settings/team" element={<TeamSettingsPage />} />
|
||||
<Route path="/settings/clinics" element={<ClinicsPage />} />
|
||||
<Route path="/settings/doctors" element={<DoctorsPage />} />
|
||||
<Route path="/settings/telephony" element={<TelephonySettingsPage />} />
|
||||
<Route path="/settings/ai" element={<AiSettingsPage />} />
|
||||
<Route path="/settings/widget" element={<WidgetSettingsPage />} />
|
||||
<Route element={<RequireSelfServeSetup />}>
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/settings/team" element={<TeamSettingsPage />} />
|
||||
<Route path="/settings/clinics" element={<ClinicsPage />} />
|
||||
<Route path="/settings/doctors" element={<DoctorsPage />} />
|
||||
<Route path="/settings/telephony" element={<TelephonySettingsPage />} />
|
||||
<Route path="/settings/ai" element={<AiSettingsPage />} />
|
||||
<Route path="/settings/widget" element={<WidgetSettingsPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
<Route path="/agent/:id" element={<AgentDetailPage />} />
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { FC } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faArrowLeft, faArrowDownToLine, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { faArrowDownToLine, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons';
|
||||
|
||||
const ArrowLeft: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowLeft} className={className} />;
|
||||
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';
|
||||
@@ -24,6 +23,8 @@ import { useLeads } from '@/hooks/use-leads';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
import { cx } from '@/utils/cx';
|
||||
import { rowsToCsv, downloadCsv } from '@/lib/csv-utils';
|
||||
import { notify } from '@/lib/toast';
|
||||
import type { Lead, LeadSource, LeadStatus } from '@/types/entities';
|
||||
|
||||
type TabKey = 'new' | 'my-leads' | 'all';
|
||||
@@ -38,7 +39,6 @@ const PAGE_SIZE = 15;
|
||||
|
||||
export const AllLeadsPage = () => {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialSource = searchParams.get('source') as LeadSource | null;
|
||||
const [tab, setTab] = useState<TabKey>('new');
|
||||
@@ -166,6 +166,44 @@ export const AllLeadsPage = () => {
|
||||
setSelectedIds([]);
|
||||
};
|
||||
|
||||
const handleExportCsv = () => {
|
||||
// Export exactly what the user currently sees — same filters, same
|
||||
// sort, same tab/campaign scope. Ignores pagination so the file
|
||||
// contains every matching row, not just the current page.
|
||||
if (displayLeads.length === 0) {
|
||||
notify.error('Export CSV', 'No leads to export');
|
||||
return;
|
||||
}
|
||||
const headers = [
|
||||
'Phone', 'First Name', 'Last Name', 'Email',
|
||||
'Source', 'Status', 'Campaign', 'Assigned Agent',
|
||||
'First Contact', 'Last Contact', 'Created', 'Age (days)',
|
||||
];
|
||||
const campaignNameById = new Map(campaigns.map((c) => [c.id, c.campaignName]));
|
||||
const now = Date.now();
|
||||
const rows = displayLeads.map((l) => {
|
||||
const createdMs = l.createdAt ? new Date(l.createdAt).getTime() : null;
|
||||
return {
|
||||
'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 ?? '',
|
||||
'Campaign': l.campaignId ? (campaignNameById.get(l.campaignId) ?? '') : '',
|
||||
'Assigned Agent': l.assignedAgent ?? '',
|
||||
'First Contact': l.firstContactedAt ?? '',
|
||||
'Last Contact': l.lastContactedAt ?? '',
|
||||
'Created': l.createdAt ?? '',
|
||||
'Age (days)': createdMs ? String(Math.floor((now - createdMs) / 86400000)) : '',
|
||||
};
|
||||
});
|
||||
const csv = rowsToCsv(headers, rows);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
downloadCsv(`leads-${tab}-${today}.csv`, csv);
|
||||
notify.success('Export CSV', `${rows.length} lead${rows.length === 1 ? '' : 's'} exported`);
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
setSelectedIds([]);
|
||||
@@ -231,14 +269,6 @@ export const AllLeadsPage = () => {
|
||||
{/* 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">
|
||||
<Button
|
||||
onClick={() => navigate(-1)}
|
||||
color="secondary"
|
||||
size="sm"
|
||||
iconLeading={ArrowLeft}
|
||||
aria-label="Back"
|
||||
/>
|
||||
|
||||
<Tabs selectedKey={tab} onSelectionChange={handleTabChange}>
|
||||
<TabList items={tabItems} type="button-gray" size="sm">
|
||||
{(item) => (
|
||||
@@ -267,6 +297,7 @@ export const AllLeadsPage = () => {
|
||||
size="sm"
|
||||
color="secondary"
|
||||
iconLeading={Download01}
|
||||
onClick={handleExportCsv}
|
||||
>
|
||||
Export CSV
|
||||
</Button>
|
||||
|
||||
@@ -108,14 +108,22 @@ export const CallDeskPage = () => {
|
||||
}
|
||||
}, [isInCall]);
|
||||
|
||||
// Build activeLead from resolved caller or fallback to client-side match
|
||||
// Build activeLead from resolved caller or fallback to client-side match.
|
||||
// The resolver is the authoritative source for patientId (it just joined
|
||||
// lead↔patient by phone), so overlay it on top of any worklist row that
|
||||
// pre-dates the linkage. Without this, the Book Appt pills can't find
|
||||
// a returning caller's prior appointments because the frontend loses
|
||||
// sight of which patient they are.
|
||||
const workLead = resolvedCaller ? marketingLeads.find((l) => l.id === resolvedCaller.leadId) : null;
|
||||
const callerLead = resolvedCaller
|
||||
? marketingLeads.find((l) => l.id === resolvedCaller.leadId) ?? {
|
||||
id: resolvedCaller.leadId,
|
||||
contactName: { firstName: resolvedCaller.firstName, lastName: resolvedCaller.lastName },
|
||||
contactPhone: [{ number: resolvedCaller.phone, callingCode: '+91' }],
|
||||
patientId: resolvedCaller.patientId,
|
||||
}
|
||||
? workLead
|
||||
? { ...workLead, patientId: (workLead as any).patientId ?? resolvedCaller.patientId }
|
||||
: {
|
||||
id: resolvedCaller.leadId,
|
||||
contactName: { firstName: resolvedCaller.firstName, lastName: resolvedCaller.lastName },
|
||||
contactPhone: [{ number: resolvedCaller.phone, callingCode: '+91' }],
|
||||
patientId: resolvedCaller.patientId,
|
||||
}
|
||||
: callerNumber
|
||||
? marketingLeads.find((l) => l.contactPhone?.[0]?.number?.endsWith(callerNumber) || callerNumber.endsWith(l.contactPhone?.[0]?.number ?? '---'))
|
||||
: null;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { Tabs, TabList, Tab, TabPanel } from '@/components/application/tabs/tabs';
|
||||
import { CampaignHero } from '@/components/campaigns/campaign-hero';
|
||||
import { KpiStrip } from '@/components/campaigns/kpi-strip';
|
||||
import { AdCard } from '@/components/campaigns/ad-card';
|
||||
@@ -9,28 +8,52 @@ import { ConversionFunnel } from '@/components/campaigns/conversion-funnel';
|
||||
import { SourceBreakdown } from '@/components/campaigns/source-breakdown';
|
||||
import { BudgetBar } from '@/components/campaigns/budget-bar';
|
||||
import { HealthIndicator } from '@/components/campaigns/health-indicator';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { LeadTable } from '@/components/leads/lead-table';
|
||||
import { LeadActivitySlideout } from '@/components/leads/lead-activity-slideout';
|
||||
import { useCampaigns } from '@/hooks/use-campaigns';
|
||||
import { useLeads } from '@/hooks/use-leads';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
import { formatCurrency, formatDateOnly } from '@/lib/format';
|
||||
|
||||
const detailTabs = [
|
||||
{ id: 'overview', label: 'Overview' },
|
||||
{ id: 'leads', label: 'Leads' },
|
||||
];
|
||||
import type { Lead } from '@/types/entities';
|
||||
|
||||
export const CampaignDetailPage = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [activeTab, setActiveTab] = useState<string>('overview');
|
||||
|
||||
const { campaigns, ads } = useCampaigns();
|
||||
const { leads } = useLeads();
|
||||
const { leadActivities } = useData();
|
||||
|
||||
const campaign = campaigns.find((c) => c.id === id);
|
||||
|
||||
const campaignAds = useMemo(() => ads.filter((ad) => ad.campaignId === id), [ads, id]);
|
||||
const campaignLeads = useMemo(() => leads.filter((lead) => lead.campaignId === id), [leads, id]);
|
||||
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [sortField, setSortField] = useState('createdAt');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||
const [activityLead, setActivityLead] = useState<Lead | null>(null);
|
||||
|
||||
const handleSort = (field: string) => {
|
||||
if (field === sortField) {
|
||||
setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection('desc');
|
||||
}
|
||||
};
|
||||
|
||||
const sortedLeads = useMemo(() => {
|
||||
const copy = [...campaignLeads];
|
||||
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;
|
||||
}, [campaignLeads, sortField, sortDirection]);
|
||||
|
||||
if (!campaign) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center p-8">
|
||||
@@ -46,126 +69,122 @@ export const CampaignDetailPage = () => {
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-y-auto">
|
||||
{/* Hero header */}
|
||||
<CampaignHero campaign={campaign} />
|
||||
|
||||
{/* KPI strip */}
|
||||
<KpiStrip campaign={campaign} />
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="px-7 pt-5">
|
||||
<Tabs selectedKey={activeTab} onSelectionChange={(key) => setActiveTab(String(key))}>
|
||||
<TabList
|
||||
type="underline"
|
||||
size="sm"
|
||||
items={detailTabs}
|
||||
>
|
||||
{(item) => <Tab key={item.id} id={item.id} label={item.label} />}
|
||||
</TabList>
|
||||
|
||||
<TabPanel id="overview">
|
||||
<div className="mt-5 grid grid-cols-1 gap-5 pb-7 xl:grid-cols-[1fr_340px]">
|
||||
{/* Left: Ads list */}
|
||||
<div className="space-y-3">
|
||||
{/* 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]">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-md font-bold text-primary">
|
||||
Leads ({campaignLeads.length})
|
||||
</h3>
|
||||
</div>
|
||||
{campaignLeads.length === 0 ? (
|
||||
<div className="rounded-xl border border-secondary bg-primary p-8 text-center text-sm text-tertiary">
|
||||
No leads from this campaign yet.
|
||||
</div>
|
||||
) : (
|
||||
<LeadTable
|
||||
leads={sortedLeads}
|
||||
selectedIds={selectedIds}
|
||||
onSelectionChange={setSelectedIds}
|
||||
sortField={sortField}
|
||||
sortDirection={sortDirection}
|
||||
onSort={handleSort}
|
||||
onViewActivity={(lead) => setActivityLead(lead)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{campaignAds.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-3 text-md font-bold text-primary">
|
||||
Ads ({campaignAds.length})
|
||||
</h3>
|
||||
{campaignAds.map((ad) => (
|
||||
<AdCard key={ad.id} ad={ad} />
|
||||
))}
|
||||
{campaignAds.length === 0 && (
|
||||
<p className="py-8 text-center text-sm text-tertiary">
|
||||
No ads for this campaign.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Details + Funnel + Source */}
|
||||
<div className="space-y-4">
|
||||
{/* Campaign Details card */}
|
||||
<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>
|
||||
|
||||
{/* Conversion Funnel */}
|
||||
<ConversionFunnel campaign={campaign} leads={campaignLeads} />
|
||||
|
||||
{/* Source Breakdown */}
|
||||
<SourceBreakdown leads={campaignLeads} />
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel id="leads">
|
||||
<div className="mt-5 pb-7">
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-secondary bg-primary p-12 text-center">
|
||||
<p className="text-md font-bold text-primary">
|
||||
{campaignLeads.length} lead{campaignLeads.length !== 1 ? 's' : ''} from this campaign
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-tertiary">
|
||||
View the full leads table filtered by this campaign on the All Leads page.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Button color="primary" size="sm" href="/leads">
|
||||
Go to All Leads
|
||||
</Button>
|
||||
<div className="space-y-3">
|
||||
{campaignAds.map((ad) => (
|
||||
<AdCard key={ad.id} ad={ad} />
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
|
||||
<ConversionFunnel campaign={campaign} leads={campaignLeads} />
|
||||
|
||||
<SourceBreakdown leads={campaignLeads} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activityLead && (
|
||||
<LeadActivitySlideout
|
||||
isOpen={!!activityLead}
|
||||
onOpenChange={(open) => !open && setActivityLead(null)}
|
||||
lead={activityLead}
|
||||
activities={leadActivities}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import { MaintOtpModal } from '@/components/modals/maint-otp-modal';
|
||||
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
|
||||
import { useThemeTokens } from '@/providers/theme-token-provider';
|
||||
import { getSetupState } from '@/lib/setup-state';
|
||||
import { getUiFlags } from '@/hooks/use-ui-flags';
|
||||
|
||||
export const LoginPage = () => {
|
||||
const { loginWithUser } = useAuth();
|
||||
@@ -118,11 +119,13 @@ export const LoginPage = () => {
|
||||
|
||||
// First-run detection: if the workspace's setup is incomplete and
|
||||
// the wizard hasn't been dismissed, route the admin to /setup so
|
||||
// they finish onboarding before reaching the dashboard. Failures
|
||||
// are non-blocking — we always have a fallback to /.
|
||||
// they finish onboarding before reaching the dashboard. Skip when
|
||||
// the tenant's setup is product-team managed — there's nothing
|
||||
// for the admin to do in the wizard. Failures are non-blocking —
|
||||
// we always have a fallback to /.
|
||||
try {
|
||||
const state = await getSetupState();
|
||||
if (state.wizardRequired) {
|
||||
const [state, flags] = await Promise.all([getSetupState(), getUiFlags()]);
|
||||
if (state.wizardRequired && !flags.setupManaged) {
|
||||
navigate('/setup');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,13 +3,18 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { AiChatPanel } from '@/components/call-desk/ai-chat-panel';
|
||||
import { DashboardKpi } from '@/components/dashboard/kpi-cards';
|
||||
import { AgentTable } from '@/components/dashboard/agent-table';
|
||||
import { MissedQueue } from '@/components/dashboard/missed-queue';
|
||||
import {
|
||||
RichAgentTable,
|
||||
TimeBreakdown,
|
||||
NpsConversion,
|
||||
PerformanceAlerts,
|
||||
useSupervisorRollup,
|
||||
} from '@/components/dashboard/supervisor-rollup';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
type DateRange = 'today' | 'week' | 'month';
|
||||
type DashboardTab = 'agents' | 'missed' | 'campaigns';
|
||||
|
||||
const getDateRangeStart = (range: DateRange): Date => {
|
||||
const now = new Date();
|
||||
@@ -23,9 +28,13 @@ const getDateRangeStart = (range: DateRange): Date => {
|
||||
export const TeamDashboardPage = () => {
|
||||
const { calls, leads, campaigns, loading } = useData();
|
||||
const [dateRange, setDateRange] = useState<DateRange>('week');
|
||||
const [tab, setTab] = useState<DashboardTab>('agents');
|
||||
const [aiOpen, setAiOpen] = useState(true);
|
||||
|
||||
// Pull the richer supervisor rollup (NPS, idle, time breakdown, alerts)
|
||||
// from the sidecar. Only `today`/`week`/`month` overlap with the rollup's
|
||||
// date-range semantics — map them through directly.
|
||||
const { agents: rollupAgents } = useSupervisorRollup(dateRange);
|
||||
|
||||
const filteredCalls = useMemo(() => {
|
||||
const rangeStart = getDateRangeStart(dateRange);
|
||||
return calls.filter((call) => {
|
||||
@@ -36,11 +45,13 @@ export const TeamDashboardPage = () => {
|
||||
|
||||
const dateRangeLabel = dateRange === 'today' ? 'Today' : dateRange === 'week' ? 'This Week' : 'This Month';
|
||||
|
||||
const tabs = [
|
||||
{ id: 'agents' as const, label: 'Agent Performance' },
|
||||
{ id: 'missed' as const, label: `Missed Queue (${filteredCalls.filter(c => c.callStatus === 'MISSED').length})` },
|
||||
{ id: 'campaigns' as const, label: `Campaigns (${campaigns.length})` },
|
||||
];
|
||||
const convRate = useMemo(() => {
|
||||
if (filteredCalls.length === 0) return 0;
|
||||
const completed = filteredCalls.filter((c) => c.callStatus === 'COMPLETED').length;
|
||||
return Math.round((completed / filteredCalls.length) * 100);
|
||||
}, [filteredCalls]);
|
||||
|
||||
const missedQueueCount = filteredCalls.filter((c) => c.callStatus === 'MISSED').length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
@@ -76,72 +87,68 @@ export const TeamDashboardPage = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Main content */}
|
||||
{/* Main content — scrollable column with KPIs pinned at the
|
||||
top, then stacked supervisor sections (Agent table, Time
|
||||
breakdown, NPS/Conv, Alerts, Missed Queue, Campaigns).
|
||||
No tabs: everything is scroll-visible so a supervisor
|
||||
doesn't have to hunt across surfaces for their metrics. */}
|
||||
<div className="flex flex-1 flex-col overflow-y-auto">
|
||||
{/* KPI cards — always visible */}
|
||||
<div className="px-6 pt-5 pb-3">
|
||||
<DashboardKpi calls={filteredCalls} leads={leads} />
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-1 border-b border-secondary px-6">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className={cx(
|
||||
"px-3 py-2.5 text-xs font-semibold transition duration-100 ease-linear border-b-2",
|
||||
tab === t.id
|
||||
? "border-brand text-brand-secondary"
|
||||
: "border-transparent text-tertiary hover:text-secondary",
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="flex-1 p-6">
|
||||
{loading && (
|
||||
<div className="flex-1 space-y-5 px-6 pb-8">
|
||||
{loading && rollupAgents.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<p className="text-sm text-tertiary">Loading...</p>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<>
|
||||
<RichAgentTable agents={rollupAgents} />
|
||||
|
||||
{!loading && tab === 'agents' && (
|
||||
<AgentTable calls={filteredCalls} />
|
||||
)}
|
||||
<TimeBreakdown agents={rollupAgents} />
|
||||
|
||||
{!loading && tab === 'missed' && (
|
||||
<MissedQueue calls={filteredCalls} />
|
||||
)}
|
||||
<NpsConversion agents={rollupAgents} convRate={convRate} />
|
||||
|
||||
{!loading && tab === 'campaigns' && (
|
||||
<div className="space-y-3">
|
||||
{campaigns.length === 0 ? (
|
||||
<p className="text-sm text-tertiary py-12 text-center">No campaigns</p>
|
||||
) : (
|
||||
campaigns.map((c) => (
|
||||
<div key={c.id} className="flex items-center justify-between rounded-xl border border-secondary bg-primary p-4 shadow-xs">
|
||||
<div>
|
||||
<span className="text-sm font-semibold text-primary">{c.campaignName}</span>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-tertiary">
|
||||
<span>{c.campaignStatus}</span>
|
||||
<span>{c.platform}</span>
|
||||
<span>{c.leadCount} leads</span>
|
||||
<span>{c.convertedCount} converted</span>
|
||||
<PerformanceAlerts agents={rollupAgents} />
|
||||
|
||||
<div className="rounded-xl border border-secondary bg-primary p-4">
|
||||
<h3 className="text-sm font-semibold text-secondary mb-3">
|
||||
Missed Queue ({missedQueueCount})
|
||||
</h3>
|
||||
<MissedQueue calls={filteredCalls} />
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-secondary bg-primary p-4">
|
||||
<h3 className="text-sm font-semibold text-secondary mb-3">
|
||||
Campaigns ({campaigns.length})
|
||||
</h3>
|
||||
{campaigns.length === 0 ? (
|
||||
<p className="text-sm text-tertiary py-4 text-center">No campaigns</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{campaigns.map((c) => (
|
||||
<div key={c.id} className="flex items-center justify-between rounded-lg border border-secondary bg-primary p-4">
|
||||
<div>
|
||||
<span className="text-sm font-semibold text-primary">{c.campaignName}</span>
|
||||
<div className="flex items-center gap-3 mt-1 text-xs text-tertiary">
|
||||
<span>{c.campaignStatus}</span>
|
||||
<span>{c.platform}</span>
|
||||
<span>{c.leadCount} leads</span>
|
||||
<span>{c.convertedCount} converted</span>
|
||||
</div>
|
||||
</div>
|
||||
{c.budget && (
|
||||
<span className="text-sm font-medium text-secondary">
|
||||
₹{Math.round(c.budget.amountMicros / 1_000_000).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{c.budget && (
|
||||
<span className="text-sm font-medium text-secondary">
|
||||
₹{Math.round(c.budget.amountMicros / 1_000_000).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import {
|
||||
LEADS_QUERY,
|
||||
CAMPAIGNS_QUERY,
|
||||
ADS_QUERY,
|
||||
FOLLOW_UPS_QUERY,
|
||||
LEAD_ACTIVITIES_QUERY,
|
||||
CALLS_QUERY,
|
||||
APPOINTMENTS_QUERY,
|
||||
PATIENTS_QUERY,
|
||||
leadsQuery,
|
||||
campaignsQuery,
|
||||
adsQuery,
|
||||
followUpsQuery,
|
||||
leadActivitiesQuery,
|
||||
callsQuery,
|
||||
appointmentsQuery,
|
||||
patientsQuery,
|
||||
} from '@/lib/queries';
|
||||
import {
|
||||
transformLeads,
|
||||
@@ -70,6 +70,7 @@ export const DataProvider = ({ children }: DataProviderProps) => {
|
||||
const [patients, setPatients] = useState<Patient[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const hasLoadedRef = useRef(false);
|
||||
|
||||
// These don't have platform entities yet — empty for now
|
||||
const [templates] = useState<WhatsAppTemplate[]>([]);
|
||||
@@ -82,21 +83,48 @@ export const DataProvider = ({ children }: DataProviderProps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
// Only flip the global loading flag on the very first fetch. Background
|
||||
// polls refresh data in place so the UI doesn't flash "Loading..." —
|
||||
// QA reported this as the supervisor surfaces randomly refreshing.
|
||||
if (!hasLoadedRef.current) {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const gql = <T,>(query: string) => apiClient.graphql<T>(query, undefined, { silent: true }).catch(() => null);
|
||||
|
||||
// Generic Relay pagination. Keeps paging until hasNextPage=false
|
||||
// or we hit MAX_PAGES (guard against runaway loops on bad data).
|
||||
// Returned shape mirrors the original single-page response so
|
||||
// transformX functions work unchanged — they already read
|
||||
// `{ <rootField>: { edges } }`.
|
||||
const MAX_PAGES = 25;
|
||||
const fetchAll = async (rootField: string, builder: (after?: string) => string): Promise<any | null> => {
|
||||
const allEdges: any[] = [];
|
||||
let after: string | undefined = undefined;
|
||||
for (let page = 0; page < MAX_PAGES; page++) {
|
||||
const data: any = await gql<any>(builder(after));
|
||||
if (!data) return null;
|
||||
const root: any = data[rootField];
|
||||
if (!root) break;
|
||||
if (Array.isArray(root.edges)) allEdges.push(...root.edges);
|
||||
if (!root.pageInfo?.hasNextPage) break;
|
||||
after = root.pageInfo.endCursor;
|
||||
if (!after) break;
|
||||
}
|
||||
return { [rootField]: { edges: allEdges } };
|
||||
};
|
||||
|
||||
const [leadsData, campaignsData, adsData, followUpsData, activitiesData, callsData, appointmentsData, patientsData] = await Promise.all([
|
||||
gql<any>(LEADS_QUERY),
|
||||
gql<any>(CAMPAIGNS_QUERY),
|
||||
gql<any>(ADS_QUERY),
|
||||
gql<any>(FOLLOW_UPS_QUERY),
|
||||
gql<any>(LEAD_ACTIVITIES_QUERY),
|
||||
gql<any>(CALLS_QUERY),
|
||||
gql<any>(APPOINTMENTS_QUERY),
|
||||
gql<any>(PATIENTS_QUERY),
|
||||
fetchAll('leads', leadsQuery),
|
||||
fetchAll('campaigns', campaignsQuery),
|
||||
fetchAll('ads', adsQuery),
|
||||
fetchAll('followUps', followUpsQuery),
|
||||
fetchAll('leadActivities', leadActivitiesQuery),
|
||||
fetchAll('calls', callsQuery),
|
||||
fetchAll('appointments', appointmentsQuery),
|
||||
fetchAll('patients', patientsQuery),
|
||||
]);
|
||||
|
||||
if (leadsData) setLeads(transformLeads(leadsData));
|
||||
@@ -110,6 +138,7 @@ export const DataProvider = ({ children }: DataProviderProps) => {
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to load data');
|
||||
} finally {
|
||||
hasLoadedRef.current = true;
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
Reference in New Issue
Block a user