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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import {
|
import {
|
||||||
faPhone, faPhoneHangup, faMicrophone, faMicrophoneSlash,
|
faPhone, faPhoneHangup, faMicrophone, faMicrophoneSlash,
|
||||||
faPause, faPlay, faCalendarPlus,
|
faPause, faPlay, faCalendarPlus, faPlus, faPenToSquare,
|
||||||
faPhoneArrowRight, faRecordVinyl, faClipboardQuestion,
|
faPhoneArrowRight, faRecordVinyl, faClipboardQuestion,
|
||||||
} from '@fortawesome/pro-duotone-svg-icons';
|
} from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { useData } from '@/providers/data-provider';
|
||||||
import { Button } from '@/components/base/buttons/button';
|
import { Button } from '@/components/base/buttons/button';
|
||||||
import { Badge } from '@/components/base/badges/badges';
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
import { useSetAtom } from 'jotai';
|
import { useSetAtom } from 'jotai';
|
||||||
@@ -13,10 +14,11 @@ import { setOutboundPending } from '@/state/sip-manager';
|
|||||||
import { useSip } from '@/providers/sip-provider';
|
import { useSip } from '@/providers/sip-provider';
|
||||||
import { DispositionModal } from './disposition-modal';
|
import { DispositionModal } from './disposition-modal';
|
||||||
import type { CallAction } from './disposition-modal';
|
import type { CallAction } from './disposition-modal';
|
||||||
|
import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal';
|
||||||
import { AppointmentForm } from './appointment-form';
|
import { AppointmentForm } from './appointment-form';
|
||||||
import { TransferDialog } from './transfer-dialog';
|
import { TransferDialog } from './transfer-dialog';
|
||||||
import { EnquiryForm } from './enquiry-form';
|
import { EnquiryForm } from './enquiry-form';
|
||||||
import { formatPhone } from '@/lib/format';
|
import { formatPhone, formatShortDate } from '@/lib/format';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { useAuth } from '@/providers/auth-provider';
|
import { useAuth } from '@/providers/auth-provider';
|
||||||
import { useAgentState } from '@/hooks/use-agent-state';
|
import { useAgentState } from '@/hooks/use-agent-state';
|
||||||
@@ -44,6 +46,10 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
|
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
|
||||||
const setCallUcid = useSetAtom(sipCallUcidAtom);
|
const setCallUcid = useSetAtom(sipCallUcidAtom);
|
||||||
const [appointmentOpen, setAppointmentOpen] = useState(false);
|
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 [transferOpen, setTransferOpen] = useState(false);
|
||||||
const [recordingPaused, setRecordingPaused] = useState(false);
|
const [recordingPaused, setRecordingPaused] = useState(false);
|
||||||
const [enquiryOpen, setEnquiryOpen] = 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 agentConfig = localStorage.getItem('helix_agent_config');
|
||||||
const agentIdForState = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null;
|
const agentIdForState = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null;
|
||||||
const { supervisorPresence } = useAgentState(agentIdForState);
|
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
|
<AppointmentForm
|
||||||
|
key={`${editingApptId ?? 'new'}-${apptMode}`}
|
||||||
isOpen={appointmentOpen}
|
isOpen={appointmentOpen}
|
||||||
onOpenChange={setAppointmentOpen}
|
onOpenChange={(open) => {
|
||||||
|
setAppointmentOpen(open);
|
||||||
|
if (!open) { setEditingApptId(null); setApptMode('edit'); }
|
||||||
|
}}
|
||||||
callerNumber={callerPhone}
|
callerNumber={callerPhone}
|
||||||
leadName={fullName || null}
|
leadName={fullName || null}
|
||||||
leadId={lead?.id ?? null}
|
leadId={lead?.id ?? null}
|
||||||
patientId={(lead as any)?.patientId ?? 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
|
<EnquiryForm
|
||||||
@@ -362,6 +471,58 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* Disposition Modal — the ONLY path to end a call */}
|
||||||
<DispositionModal
|
<DispositionModal
|
||||||
isOpen={dispositionOpen}
|
isOpen={dispositionOpen}
|
||||||
|
|||||||
@@ -19,9 +19,25 @@ interface AiChatPanelProps {
|
|||||||
onChatStart?: () => void;
|
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) => {
|
export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) => {
|
||||||
const { tokens } = useThemeTokens();
|
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 messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const chatStartedRef = useRef(false);
|
const chatStartedRef = useRef(false);
|
||||||
|
|
||||||
@@ -50,14 +66,26 @@ export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) =>
|
|||||||
}, [messages, onChatStart]);
|
}, [messages, onChatStart]);
|
||||||
|
|
||||||
// Auto-fire a patient-summary request when a caller with a leadId appears
|
// 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
|
// on the panel. Resets whenever the caller changes (new incoming call) or
|
||||||
// each call starts fresh. The sidecar's AI agent inspects the leadId and
|
// the call ends (leadId clears), so each call starts fresh. The sidecar's
|
||||||
// replies with appointment/disposition/notes history when the caller is
|
// AI agent inspects the leadId and replies with appointment/disposition/
|
||||||
// a returning patient, or a brief "net-new caller" ack otherwise.
|
// notes history when the caller is a returning patient.
|
||||||
const autoFiredForLeadRef = useRef<string | null>(null);
|
const autoFiredForLeadRef = useRef<string | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const leadId = callerContext?.leadId ?? null;
|
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;
|
if (autoFiredForLeadRef.current === leadId) return;
|
||||||
|
|
||||||
// New caller — clear any prior chat state and fire the summary prompt.
|
// 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">
|
<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" />
|
<FontAwesomeIcon icon={faSparkles} className="mb-2 size-6 text-fg-brand-primary" />
|
||||||
<p className="text-xs text-tertiary">
|
<p className="text-xs text-tertiary">
|
||||||
Ask me about doctors, clinics, packages, or patient info.
|
{introText}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-3 flex flex-wrap justify-center gap-1.5">
|
<div className="mt-3 flex flex-wrap justify-center gap-1.5">
|
||||||
{quickActions.map((action) => (
|
{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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faUserPen } from '@fortawesome/pro-duotone-svg-icons';
|
import { faUserPen } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { Input } from '@/components/base/input/input';
|
import { Input } from '@/components/base/input/input';
|
||||||
@@ -35,6 +35,11 @@ type AppointmentFormProps = {
|
|||||||
// CANCELLED each map to distinct disposition outcomes).
|
// CANCELLED each map to distinct disposition outcomes).
|
||||||
onSaved?: (outcome: 'BOOKED' | 'RESCHEDULED' | 'CANCELLED') => void;
|
onSaved?: (outcome: 'BOOKED' | 'RESCHEDULED' | 'CANCELLED') => void;
|
||||||
existingAppointment?: ExistingAppointment | null;
|
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 };
|
type DoctorRecord = { id: string; name: string; department: string; clinic: string };
|
||||||
@@ -60,6 +65,7 @@ export const AppointmentForm = ({
|
|||||||
patientId,
|
patientId,
|
||||||
onSaved,
|
onSaved,
|
||||||
existingAppointment,
|
existingAppointment,
|
||||||
|
readOnly = false,
|
||||||
}: AppointmentFormProps) => {
|
}: AppointmentFormProps) => {
|
||||||
const isEditMode = !!existingAppointment;
|
const isEditMode = !!existingAppointment;
|
||||||
|
|
||||||
@@ -236,7 +242,19 @@ export const AppointmentForm = ({
|
|||||||
const filteredDoctors = department
|
const filteredDoctors = department
|
||||||
? doctors.filter(d => d.department === department)
|
? doctors.filter(d => d.department === department)
|
||||||
: doctors;
|
: 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 => ({
|
const timeSlotSelectItems = timeSlotItems.map(slot => ({
|
||||||
...slot,
|
...slot,
|
||||||
@@ -471,7 +489,7 @@ export const AppointmentForm = ({
|
|||||||
placeholder="Full name"
|
placeholder="Full name"
|
||||||
value={patientName}
|
value={patientName}
|
||||||
onChange={setPatientName}
|
onChange={setPatientName}
|
||||||
isDisabled={!isNameEditable}
|
isDisabled={readOnly || !isNameEditable}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{!isNameEditable && initialLeadName.length > 0 && (
|
{!isNameEditable && initialLeadName.length > 0 && (
|
||||||
@@ -544,7 +562,7 @@ export const AppointmentForm = ({
|
|||||||
items={departmentItems}
|
items={departmentItems}
|
||||||
selectedKey={department}
|
selectedKey={department}
|
||||||
onSelectionChange={(key) => setDepartment(key as string)}
|
onSelectionChange={(key) => setDepartment(key as string)}
|
||||||
isDisabled={doctors.length === 0}
|
isDisabled={readOnly || doctors.length === 0}
|
||||||
>
|
>
|
||||||
{(item) => <Select.Item id={item.id} label={item.label} />}
|
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||||
</Select>
|
</Select>
|
||||||
@@ -555,7 +573,7 @@ export const AppointmentForm = ({
|
|||||||
items={doctorSelectItems}
|
items={doctorSelectItems}
|
||||||
selectedKey={doctor}
|
selectedKey={doctor}
|
||||||
onSelectionChange={(key) => setDoctor(key as string)}
|
onSelectionChange={(key) => setDoctor(key as string)}
|
||||||
isDisabled={!department}
|
isDisabled={readOnly || !department}
|
||||||
>
|
>
|
||||||
{(item) => <Select.Item id={item.id} label={item.label} />}
|
{(item) => <Select.Item id={item.id} label={item.label} />}
|
||||||
</Select>
|
</Select>
|
||||||
@@ -567,7 +585,7 @@ export const AppointmentForm = ({
|
|||||||
value={date ? parseDate(date) : null}
|
value={date ? parseDate(date) : null}
|
||||||
onChange={(val) => setDate(val ? val.toString() : '')}
|
onChange={(val) => setDate(val ? val.toString() : '')}
|
||||||
granularity="day"
|
granularity="day"
|
||||||
isDisabled={!doctor}
|
isDisabled={readOnly || !doctor}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -585,7 +603,7 @@ export const AppointmentForm = ({
|
|||||||
<button
|
<button
|
||||||
key={slot.id}
|
key={slot.id}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={isBooked}
|
disabled={readOnly || isBooked}
|
||||||
onClick={() => setTimeSlot(slot.id)}
|
onClick={() => setTimeSlot(slot.id)}
|
||||||
className={cx(
|
className={cx(
|
||||||
'rounded-lg py-2 px-1 text-xs font-medium transition duration-100 ease-linear',
|
'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..."
|
placeholder="Describe the reason for visit..."
|
||||||
value={chiefComplaint}
|
value={chiefComplaint}
|
||||||
onChange={setChiefComplaint}
|
onChange={setChiefComplaint}
|
||||||
|
isDisabled={readOnly}
|
||||||
rows={2}
|
rows={2}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -649,7 +668,7 @@ export const AppointmentForm = ({
|
|||||||
{/* Footer — pinned */}
|
{/* Footer — pinned */}
|
||||||
<div className="shrink-0 flex items-center justify-between pt-4 border-t border-secondary">
|
<div className="shrink-0 flex items-center justify-between pt-4 border-t border-secondary">
|
||||||
<div>
|
<div>
|
||||||
{isEditMode && (
|
{isEditMode && !readOnly && (
|
||||||
<Button size="sm" color="primary-destructive" isLoading={isSaving} onClick={handleCancel}>
|
<Button size="sm" color="primary-destructive" isLoading={isSaving} onClick={handleCancel}>
|
||||||
Cancel Appointment
|
Cancel Appointment
|
||||||
</Button>
|
</Button>
|
||||||
@@ -659,9 +678,11 @@ export const AppointmentForm = ({
|
|||||||
<Button size="sm" color="secondary" onClick={() => onOpenChange(false)}>
|
<Button size="sm" color="secondary" onClick={() => onOpenChange(false)}>
|
||||||
Close
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
|
{!readOnly && (
|
||||||
<Button size="sm" color="primary" isLoading={isSaving} showTextWhileLoading onClick={handleSave}>
|
<Button size="sm" color="primary" isLoading={isSaving} showTextWhileLoading onClick={handleSave}>
|
||||||
{isSaving ? 'Saving...' : isEditMode ? 'Update Appointment' : 'Book Appointment'}
|
{isSaving ? 'Saving...' : isEditMode ? 'Update Appointment' : 'Book Appointment'}
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -91,13 +91,6 @@ export const CampaignHero = ({ campaign }: CampaignHeroProps) => {
|
|||||||
View on Platform
|
View on Platform
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
|
||||||
color="primary"
|
|
||||||
size="sm"
|
|
||||||
href={`/leads`}
|
|
||||||
>
|
|
||||||
View Leads
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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,
|
faUsers,
|
||||||
faArrowRightFromBracket,
|
faArrowRightFromBracket,
|
||||||
faTowerBroadcast,
|
faTowerBroadcast,
|
||||||
faChartLine,
|
|
||||||
faFileAudio,
|
faFileAudio,
|
||||||
faPhoneMissed,
|
faPhoneMissed,
|
||||||
} from "@fortawesome/pro-duotone-svg-icons";
|
} 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 type { NavItemType } from "@/components/application/app-navigation/config";
|
||||||
import { Avatar } from "@/components/base/avatar/avatar";
|
import { Avatar } from "@/components/base/avatar/avatar";
|
||||||
import { useAuth } from "@/providers/auth-provider";
|
import { useAuth } from "@/providers/auth-provider";
|
||||||
|
import { useUiFlags } from "@/hooks/use-ui-flags";
|
||||||
import { useAgentState } from "@/hooks/use-agent-state";
|
import { useAgentState } from "@/hooks/use-agent-state";
|
||||||
import { useThemeTokens } from "@/providers/theme-token-provider";
|
import { useThemeTokens } from "@/providers/theme-token-provider";
|
||||||
import { sidebarCollapsedAtom } from "@/state/sidebar-state";
|
import { sidebarCollapsedAtom } from "@/state/sidebar-state";
|
||||||
@@ -49,7 +49,6 @@ const IconUsers = faIcon(faUsers);
|
|||||||
const IconHospitalUser = faIcon(faHospitalUser);
|
const IconHospitalUser = faIcon(faHospitalUser);
|
||||||
const IconCalendarCheck = faIcon(faCalendarCheck);
|
const IconCalendarCheck = faIcon(faCalendarCheck);
|
||||||
const IconTowerBroadcast = faIcon(faTowerBroadcast);
|
const IconTowerBroadcast = faIcon(faTowerBroadcast);
|
||||||
const IconChartLine = faIcon(faChartLine);
|
|
||||||
const IconFileAudio = faIcon(faFileAudio);
|
const IconFileAudio = faIcon(faFileAudio);
|
||||||
const IconPhoneMissed = faIcon(faPhoneMissed);
|
const IconPhoneMissed = faIcon(faPhoneMissed);
|
||||||
|
|
||||||
@@ -62,8 +61,11 @@ const getNavSections = (role: string): NavSection[] => {
|
|||||||
if (role === 'admin') {
|
if (role === 'admin') {
|
||||||
return [
|
return [
|
||||||
{ label: 'Supervisor', items: [
|
{ 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: 'Dashboard', href: '/', icon: IconGrid2 },
|
||||||
{ label: 'Team Performance', href: '/team-performance', icon: IconChartLine },
|
|
||||||
{ label: 'Live Call Monitor', href: '/live-monitor', icon: IconTowerBroadcast },
|
{ label: 'Live Call Monitor', href: '/live-monitor', icon: IconTowerBroadcast },
|
||||||
]},
|
]},
|
||||||
{ label: 'Data & Reports', items: [
|
{ label: 'Data & Reports', items: [
|
||||||
@@ -149,7 +151,16 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
|||||||
navigate('/login');
|
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 = (
|
const content = (
|
||||||
<aside
|
<aside
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/mod
|
|||||||
import { PinInput } from '@/components/base/pin-input/pin-input';
|
import { PinInput } from '@/components/base/pin-input/pin-input';
|
||||||
import { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon';
|
import { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
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 type { FC } from 'react';
|
||||||
import { notify } from '@/lib/toast';
|
import { notify } from '@/lib/toast';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
const ShieldIcon: FC<{ className?: string }> = ({ className }) => (
|
const ShieldIcon: FC<{ className?: string }> = ({ className }) => (
|
||||||
<FontAwesomeIcon icon={faShieldKeyhole} className={className} />
|
<FontAwesomeIcon icon={faShieldKeyhole} className={className} />
|
||||||
@@ -20,9 +21,14 @@ type MaintAction = {
|
|||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
needsPreStep?: boolean;
|
needsPreStep?: boolean;
|
||||||
|
agentPickerEndpoint?: string;
|
||||||
clientSideHandler?: (payload: any) => Promise<{ status: string; message: 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 = {
|
type MaintOtpModalProps = {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
@@ -36,6 +42,55 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
|
|||||||
const [otp, setOtp] = useState('');
|
const [otp, setOtp] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 () => {
|
const handleSubmit = async () => {
|
||||||
if (!action || otp.length < 6) return;
|
if (!action || otp.length < 6) return;
|
||||||
@@ -43,45 +98,50 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
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) {
|
if (action.clientSideHandler) {
|
||||||
// Client-side action — OTP verified by calling a dummy maint endpoint first
|
const { ok, data } = await postMaint('force-ready', {}, otp);
|
||||||
const otpRes = await fetch(`${API_URL}/api/maint/force-ready`, {
|
if (!ok) {
|
||||||
method: 'POST',
|
setError(data.message ?? 'Invalid maintenance code');
|
||||||
headers: { 'Content-Type': 'application/json', 'x-maint-otp': otp },
|
|
||||||
});
|
|
||||||
if (!otpRes.ok) {
|
|
||||||
setError('Invalid maintenance code');
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = await action.clientSideHandler(preStepPayload);
|
const result = await action.clientSideHandler(preStepPayload);
|
||||||
notify.success(action.label, result.message ?? 'Completed');
|
notify.success(action.label, result.message ?? 'Completed');
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
setOtp('');
|
reset();
|
||||||
} else {
|
return;
|
||||||
// Standard sidecar endpoint — include agentId from agent config
|
}
|
||||||
|
|
||||||
|
// 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 agentCfg = localStorage.getItem('helix_agent_config');
|
||||||
const agentId = agentCfg ? JSON.parse(agentCfg).ozonetelAgentId : undefined;
|
const agentId = agentCfg ? JSON.parse(agentCfg).ozonetelAgentId : undefined;
|
||||||
const payload = { ...preStepPayload, ...(agentId ? { agentId } : {}) };
|
const payload = { ...preStepPayload, ...(agentId ? { agentId } : {}) };
|
||||||
|
const { ok, data } = await postMaint(action.endpoint, payload, otp);
|
||||||
const res = await fetch(`${API_URL}/api/maint/${action.endpoint}`, {
|
if (ok) {
|
||||||
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');
|
notify.success(action.label, data.message ?? 'Completed successfully');
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
setOtp('');
|
reset();
|
||||||
} else {
|
} else {
|
||||||
setError(data.message ?? 'Failed');
|
setError(data.message ?? 'Failed');
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
setError('Request failed');
|
setError('Request failed');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -94,19 +154,25 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
|
|||||||
setError(null);
|
setError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
|
||||||
onOpenChange(false);
|
|
||||||
setOtp('');
|
|
||||||
setError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!action) return 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 (
|
return (
|
||||||
<ModalOverlay isOpen={isOpen} onOpenChange={handleClose} isDismissable>
|
<ModalOverlay isOpen={isOpen} onOpenChange={handleClose} isDismissable>
|
||||||
<Modal className="sm:max-w-[400px]">
|
<Modal className="sm:max-w-[440px]">
|
||||||
<Dialog>
|
<Dialog>
|
||||||
{() => (
|
{() => (
|
||||||
<div className="flex w-full flex-col rounded-xl bg-primary shadow-xl ring-1 ring-secondary overflow-hidden">
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Pre-step content (e.g., campaign selection) */}
|
{/* Pre-step content (e.g., campaign selection) */}
|
||||||
{action.needsPreStep && preStepContent && (
|
{action.needsPreStep && preStepContent && !showPicker && (
|
||||||
<div className="px-6 pb-4">
|
<div className="px-6 pb-4">
|
||||||
{preStepContent}
|
{preStepContent}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pin Input — shown when pre-step is ready (or no pre-step needed) */}
|
|
||||||
{showOtp && (
|
{showOtp && (
|
||||||
<div className="flex flex-col items-center gap-2 px-6 pb-5">
|
<div className="flex flex-col items-center gap-2 px-6 pb-5">
|
||||||
<PinInput size="sm">
|
<PinInput size="sm">
|
||||||
@@ -154,6 +219,87 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
|
|||||||
</div>
|
</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 */}
|
{/* Footer */}
|
||||||
<div className="flex items-center gap-3 border-t border-secondary px-6 py-4">
|
<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">
|
<Button size="md" color="secondary" onClick={handleClose} className="flex-1">
|
||||||
@@ -162,9 +308,9 @@ export const MaintOtpModal = ({ isOpen, onOpenChange, action, preStepContent, pr
|
|||||||
<Button
|
<Button
|
||||||
size="md"
|
size="md"
|
||||||
color="primary"
|
color="primary"
|
||||||
isDisabled={otp.length < 6 || loading || (action.needsPreStep && !preStepReady)}
|
isDisabled={confirmDisabled}
|
||||||
isLoading={loading}
|
isLoading={loading}
|
||||||
onClick={handleSubmit}
|
onClick={handleConfirm}
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
>
|
>
|
||||||
Confirm
|
Confirm
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { faCircleInfo, faXmark, faArrowRight } from '@fortawesome/pro-duotone-sv
|
|||||||
import { Button } from '@/components/base/buttons/button';
|
import { Button } from '@/components/base/buttons/button';
|
||||||
import { getSetupState, SETUP_STEP_NAMES, type SetupState } from '@/lib/setup-state';
|
import { getSetupState, SETUP_STEP_NAMES, type SetupState } from '@/lib/setup-state';
|
||||||
import { useAuth } from '@/providers/auth-provider';
|
import { useAuth } from '@/providers/auth-provider';
|
||||||
|
import { useUiFlags } from '@/hooks/use-ui-flags';
|
||||||
|
|
||||||
// Dismissible banner shown across the top of authenticated pages when
|
// Dismissible banner shown across the top of authenticated pages when
|
||||||
// the hospital workspace has incomplete setup steps AND the admin has
|
// 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)
|
// - Not dismissed in the current browser session (resets on reload)
|
||||||
export const ResumeSetupBanner = () => {
|
export const ResumeSetupBanner = () => {
|
||||||
const { isAdmin } = useAuth();
|
const { isAdmin } = useAuth();
|
||||||
|
const { setupManaged } = useUiFlags();
|
||||||
const [state, setState] = useState<SetupState | null>(null);
|
const [state, setState] = useState<SetupState | null>(null);
|
||||||
const [dismissed, setDismissed] = useState(
|
const [dismissed, setDismissed] = useState(
|
||||||
() => sessionStorage.getItem('helix_resume_setup_dismissed') === '1',
|
() => sessionStorage.getItem('helix_resume_setup_dismissed') === '1',
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAdmin || dismissed) return;
|
if (!isAdmin || dismissed || setupManaged) return;
|
||||||
getSetupState()
|
getSetupState()
|
||||||
.then(setState)
|
.then(setState)
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Non-fatal — if setup-state isn't reachable, just
|
// Non-fatal — if setup-state isn't reachable, just
|
||||||
// skip the banner. The wizard still works.
|
// 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;
|
const incompleteCount = SETUP_STEP_NAMES.filter((s) => !state.steps[s].completed).length;
|
||||||
if (incompleteCount === 0) return null;
|
if (incompleteCount === 0) return null;
|
||||||
|
|||||||
@@ -10,27 +10,32 @@ type SectionCardProps = {
|
|||||||
description: string;
|
description: string;
|
||||||
icon: any;
|
icon: any;
|
||||||
iconColor?: string;
|
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;
|
status?: SectionStatus;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Settings hub card. Each card represents one setup-able section (Branding,
|
// Settings hub card. Each card represents one setup-able section (Branding,
|
||||||
// Clinics, Doctors, Team, Telephony, AI, Widget, Rules) and links to its
|
// Clinics, Doctors, Team, Telephony, AI, Widget, Rules) and either links to
|
||||||
// dedicated page. The status badge mirrors the wizard's setup-state so an
|
// its dedicated page or triggers a parent-owned callback.
|
||||||
// admin can see at a glance which sections still need attention.
|
|
||||||
export const SectionCard = ({
|
export const SectionCard = ({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
icon,
|
icon,
|
||||||
iconColor = 'text-brand-primary',
|
iconColor = 'text-brand-primary',
|
||||||
href,
|
href,
|
||||||
|
onClick,
|
||||||
status = 'unknown',
|
status = 'unknown',
|
||||||
}: SectionCardProps) => {
|
}: SectionCardProps) => {
|
||||||
return (
|
const className = cx(
|
||||||
<Link
|
'group block w-full text-left rounded-xl border border-secondary bg-primary p-5 shadow-xs transition hover:border-brand hover:shadow-md',
|
||||||
to={href}
|
);
|
||||||
className="group block 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 justify-between gap-4">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-secondary">
|
<div className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-secondary">
|
||||||
@@ -62,6 +67,19 @@ export const SectionCard = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (onClick) {
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={onClick} className={className}>
|
||||||
|
{body}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Link to={href ?? '#'} className={className}>
|
||||||
|
{body}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ export type MaintAction = {
|
|||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
needsPreStep?: boolean;
|
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 }>;
|
clientSideHandler?: (payload: any) => Promise<{ status: string; message: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -13,11 +17,13 @@ const MAINT_ACTIONS: Record<string, MaintAction> = {
|
|||||||
endpoint: 'force-ready',
|
endpoint: 'force-ready',
|
||||||
label: 'Force Ready',
|
label: 'Force Ready',
|
||||||
description: 'Logout and re-login the agent to force Ready state on Ozonetel.',
|
description: 'Logout and re-login the agent to force Ready state on Ozonetel.',
|
||||||
|
agentPickerEndpoint: 'session-status',
|
||||||
},
|
},
|
||||||
unlockAgent: {
|
unlockAgent: {
|
||||||
endpoint: 'unlock-agent',
|
endpoint: 'unlock-agent',
|
||||||
label: 'Unlock Agent',
|
label: 'Unlock Agent',
|
||||||
description: 'Release the Redis session lock so the agent can log in again.',
|
description: 'Release the Redis session lock so the agent can log in again.',
|
||||||
|
agentPickerEndpoint: 'session-status',
|
||||||
},
|
},
|
||||||
backfill: {
|
backfill: {
|
||||||
endpoint: 'backfill-missed-calls',
|
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>;
|
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 = {
|
export type CSVParseResult = {
|
||||||
headers: string[];
|
headers: string[];
|
||||||
rows: CSVRow[];
|
rows: CSVRow[];
|
||||||
|
|||||||
@@ -1,7 +1,21 @@
|
|||||||
// GraphQL queries for platform entities
|
// 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
|
id name createdAt updatedAt
|
||||||
contactName { firstName lastName }
|
contactName { firstName lastName }
|
||||||
contactPhone { primaryPhoneNumber primaryPhoneCallingCode }
|
contactPhone { primaryPhoneNumber primaryPhoneCallingCode }
|
||||||
@@ -12,9 +26,9 @@ export const LEADS_QUERY = `{ leads(first: 100, orderBy: [{ createdAt: DescNulls
|
|||||||
firstContacted lastContacted contactAttempts convertedAt
|
firstContacted lastContacted contactAttempts convertedAt
|
||||||
patientId campaignId
|
patientId campaignId
|
||||||
aiSummary aiSuggestedAction
|
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
|
id name createdAt updatedAt
|
||||||
campaignName typeCustom status platform
|
campaignName typeCustom status platform
|
||||||
startDate endDate
|
startDate endDate
|
||||||
@@ -22,33 +36,33 @@ export const CAMPAIGNS_QUERY = `{ campaigns(first: 50, orderBy: [{ createdAt: De
|
|||||||
amountSpent { amountMicros currencyCode }
|
amountSpent { amountMicros currencyCode }
|
||||||
impressions clicks targetCount contacted converted leadsGenerated
|
impressions clicks targetCount contacted converted leadsGenerated
|
||||||
externalCampaignId platformUrl { primaryLinkUrl }
|
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
|
id name createdAt updatedAt
|
||||||
adName externalAdId status format
|
adName externalAdId status format
|
||||||
headline adDescription destinationUrl { primaryLinkUrl } previewUrl { primaryLinkUrl }
|
headline adDescription destinationUrl { primaryLinkUrl } previewUrl { primaryLinkUrl }
|
||||||
impressions clicks conversions
|
impressions clicks conversions
|
||||||
spend { amountMicros currencyCode }
|
spend { amountMicros currencyCode }
|
||||||
campaignId
|
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
|
id name createdAt
|
||||||
typeCustom status scheduledAt completedAt
|
typeCustom status scheduledAt completedAt
|
||||||
priority assignedAgent
|
priority assignedAgent
|
||||||
patientId
|
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
|
id name createdAt
|
||||||
activityType summary occurredAt performedBy
|
activityType summary occurredAt performedBy
|
||||||
previousValue newValue
|
previousValue newValue
|
||||||
channel durationSec outcome
|
channel durationSec outcome
|
||||||
leadId
|
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
|
id name createdAt
|
||||||
direction callStatus callerNumber { primaryPhoneNumber } agentName
|
direction callStatus callerNumber { primaryPhoneNumber } agentName
|
||||||
startedAt endedAt durationSec
|
startedAt endedAt durationSec
|
||||||
@@ -56,9 +70,27 @@ export const CALLS_QUERY = `{ calls(first: 100, orderBy: [{ startedAt: DescNulls
|
|||||||
patientId appointmentId leadId
|
patientId appointmentId leadId
|
||||||
agentId agent { id name ozonetelAgentId }
|
agentId agent { id name ozonetelAgentId }
|
||||||
transferredTo transferType
|
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 }
|
id name fullName { firstName lastName }
|
||||||
department specialty qualifications yearsOfExperience
|
department specialty qualifications yearsOfExperience
|
||||||
visitingHours
|
visitingHours
|
||||||
@@ -67,19 +99,3 @@ export const DOCTORS_QUERY = `{ doctors(first: 20) { edges { node {
|
|||||||
active registrationNumber
|
active registrationNumber
|
||||||
clinic { id name clinicName }
|
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
|
|
||||||
} } } }`;
|
|
||||||
|
|||||||
19
src/main.tsx
19
src/main.tsx
@@ -5,16 +5,31 @@ import { AppShell } from "@/components/layout/app-shell";
|
|||||||
import { AuthGuard } from "@/components/layout/auth-guard";
|
import { AuthGuard } from "@/components/layout/auth-guard";
|
||||||
import { useAuth } from "@/providers/auth-provider";
|
import { useAuth } from "@/providers/auth-provider";
|
||||||
import { SetupWizardPage } from "@/pages/setup-wizard";
|
import { SetupWizardPage } from "@/pages/setup-wizard";
|
||||||
|
import { useUiFlags } from "@/hooks/use-ui-flags";
|
||||||
|
|
||||||
const AdminSetupGuard = () => {
|
const AdminSetupGuard = () => {
|
||||||
const { isAdmin } = useAuth();
|
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 RequireAdmin = () => {
|
||||||
const { isAdmin } = useAuth();
|
const { isAdmin } = useAuth();
|
||||||
return isAdmin ? <Outlet /> : <Navigate to="/" replace />;
|
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 { RoleRouter } from "@/components/layout/role-router";
|
||||||
import { NotFound } from "@/pages/not-found";
|
import { NotFound } from "@/pages/not-found";
|
||||||
import { AllLeadsPage } from "@/pages/all-leads";
|
import { AllLeadsPage } from "@/pages/all-leads";
|
||||||
@@ -99,6 +114,7 @@ createRoot(document.getElementById("root")!).render(
|
|||||||
<Route path="/team-dashboard" element={<TeamDashboardPage />} />
|
<Route path="/team-dashboard" element={<TeamDashboardPage />} />
|
||||||
<Route path="/reports" element={<ReportsPage />} />
|
<Route path="/reports" element={<ReportsPage />} />
|
||||||
<Route path="/integrations" element={<IntegrationsPage />} />
|
<Route path="/integrations" element={<IntegrationsPage />} />
|
||||||
|
<Route element={<RequireSelfServeSetup />}>
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/settings/team" element={<TeamSettingsPage />} />
|
<Route path="/settings/team" element={<TeamSettingsPage />} />
|
||||||
<Route path="/settings/clinics" element={<ClinicsPage />} />
|
<Route path="/settings/clinics" element={<ClinicsPage />} />
|
||||||
@@ -107,6 +123,7 @@ createRoot(document.getElementById("root")!).render(
|
|||||||
<Route path="/settings/ai" element={<AiSettingsPage />} />
|
<Route path="/settings/ai" element={<AiSettingsPage />} />
|
||||||
<Route path="/settings/widget" element={<WidgetSettingsPage />} />
|
<Route path="/settings/widget" element={<WidgetSettingsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
</Route>
|
||||||
|
|
||||||
<Route path="/agent/:id" element={<AgentDetailPage />} />
|
<Route path="/agent/:id" element={<AgentDetailPage />} />
|
||||||
<Route path="/patient/:id" element={<Patient360Page />} />
|
<Route path="/patient/:id" element={<Patient360Page />} />
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { useMemo, useState } 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 { 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 Download01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowDownToLine} className={className} />;
|
||||||
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
||||||
import { Button } from '@/components/base/buttons/button';
|
import { Button } from '@/components/base/buttons/button';
|
||||||
@@ -24,6 +23,8 @@ import { useLeads } from '@/hooks/use-leads';
|
|||||||
import { useAuth } from '@/providers/auth-provider';
|
import { useAuth } from '@/providers/auth-provider';
|
||||||
import { useData } from '@/providers/data-provider';
|
import { useData } from '@/providers/data-provider';
|
||||||
import { cx } from '@/utils/cx';
|
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';
|
import type { Lead, LeadSource, LeadStatus } from '@/types/entities';
|
||||||
|
|
||||||
type TabKey = 'new' | 'my-leads' | 'all';
|
type TabKey = 'new' | 'my-leads' | 'all';
|
||||||
@@ -38,7 +39,6 @@ const PAGE_SIZE = 15;
|
|||||||
|
|
||||||
export const AllLeadsPage = () => {
|
export const AllLeadsPage = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const navigate = useNavigate();
|
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const initialSource = searchParams.get('source') as LeadSource | null;
|
const initialSource = searchParams.get('source') as LeadSource | null;
|
||||||
const [tab, setTab] = useState<TabKey>('new');
|
const [tab, setTab] = useState<TabKey>('new');
|
||||||
@@ -166,6 +166,44 @@ export const AllLeadsPage = () => {
|
|||||||
setSelectedIds([]);
|
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) => {
|
const handlePageChange = (page: number) => {
|
||||||
setCurrentPage(page);
|
setCurrentPage(page);
|
||||||
setSelectedIds([]);
|
setSelectedIds([]);
|
||||||
@@ -231,14 +269,6 @@ export const AllLeadsPage = () => {
|
|||||||
{/* Tabs + Controls row */}
|
{/* Tabs + Controls row */}
|
||||||
<div className="flex shrink-0 items-center justify-between px-6 py-3 border-b border-secondary">
|
<div className="flex shrink-0 items-center justify-between px-6 py-3 border-b border-secondary">
|
||||||
<div className="flex items-center gap-3">
|
<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}>
|
<Tabs selectedKey={tab} onSelectionChange={handleTabChange}>
|
||||||
<TabList items={tabItems} type="button-gray" size="sm">
|
<TabList items={tabItems} type="button-gray" size="sm">
|
||||||
{(item) => (
|
{(item) => (
|
||||||
@@ -267,6 +297,7 @@ export const AllLeadsPage = () => {
|
|||||||
size="sm"
|
size="sm"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
iconLeading={Download01}
|
iconLeading={Download01}
|
||||||
|
onClick={handleExportCsv}
|
||||||
>
|
>
|
||||||
Export CSV
|
Export CSV
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -108,9 +108,17 @@ export const CallDeskPage = () => {
|
|||||||
}
|
}
|
||||||
}, [isInCall]);
|
}, [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
|
const callerLead = resolvedCaller
|
||||||
? marketingLeads.find((l) => l.id === resolvedCaller.leadId) ?? {
|
? workLead
|
||||||
|
? { ...workLead, patientId: (workLead as any).patientId ?? resolvedCaller.patientId }
|
||||||
|
: {
|
||||||
id: resolvedCaller.leadId,
|
id: resolvedCaller.leadId,
|
||||||
contactName: { firstName: resolvedCaller.firstName, lastName: resolvedCaller.lastName },
|
contactName: { firstName: resolvedCaller.firstName, lastName: resolvedCaller.lastName },
|
||||||
contactPhone: [{ number: resolvedCaller.phone, callingCode: '+91' }],
|
contactPhone: [{ number: resolvedCaller.phone, callingCode: '+91' }],
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useParams } from 'react-router';
|
import { useParams } from 'react-router';
|
||||||
|
|
||||||
import { Tabs, TabList, Tab, TabPanel } from '@/components/application/tabs/tabs';
|
|
||||||
import { CampaignHero } from '@/components/campaigns/campaign-hero';
|
import { CampaignHero } from '@/components/campaigns/campaign-hero';
|
||||||
import { KpiStrip } from '@/components/campaigns/kpi-strip';
|
import { KpiStrip } from '@/components/campaigns/kpi-strip';
|
||||||
import { AdCard } from '@/components/campaigns/ad-card';
|
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 { SourceBreakdown } from '@/components/campaigns/source-breakdown';
|
||||||
import { BudgetBar } from '@/components/campaigns/budget-bar';
|
import { BudgetBar } from '@/components/campaigns/budget-bar';
|
||||||
import { HealthIndicator } from '@/components/campaigns/health-indicator';
|
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 { useCampaigns } from '@/hooks/use-campaigns';
|
||||||
import { useLeads } from '@/hooks/use-leads';
|
import { useLeads } from '@/hooks/use-leads';
|
||||||
|
import { useData } from '@/providers/data-provider';
|
||||||
import { formatCurrency, formatDateOnly } from '@/lib/format';
|
import { formatCurrency, formatDateOnly } from '@/lib/format';
|
||||||
|
import type { Lead } from '@/types/entities';
|
||||||
const detailTabs = [
|
|
||||||
{ id: 'overview', label: 'Overview' },
|
|
||||||
{ id: 'leads', label: 'Leads' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const CampaignDetailPage = () => {
|
export const CampaignDetailPage = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const [activeTab, setActiveTab] = useState<string>('overview');
|
|
||||||
|
|
||||||
const { campaigns, ads } = useCampaigns();
|
const { campaigns, ads } = useCampaigns();
|
||||||
const { leads } = useLeads();
|
const { leads } = useLeads();
|
||||||
|
const { leadActivities } = useData();
|
||||||
|
|
||||||
const campaign = campaigns.find((c) => c.id === id);
|
const campaign = campaigns.find((c) => c.id === id);
|
||||||
|
|
||||||
const campaignAds = useMemo(() => ads.filter((ad) => ad.campaignId === id), [ads, id]);
|
const campaignAds = useMemo(() => ads.filter((ad) => ad.campaignId === id), [ads, id]);
|
||||||
const campaignLeads = useMemo(() => leads.filter((lead) => lead.campaignId === id), [leads, 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) {
|
if (!campaign) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 items-center justify-center p-8">
|
<div className="flex flex-1 items-center justify-center p-8">
|
||||||
@@ -46,43 +69,52 @@ export const CampaignDetailPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col overflow-y-auto">
|
<div className="flex flex-1 flex-col overflow-y-auto">
|
||||||
{/* Hero header */}
|
|
||||||
<CampaignHero campaign={campaign} />
|
<CampaignHero campaign={campaign} />
|
||||||
|
|
||||||
{/* KPI strip */}
|
|
||||||
<KpiStrip campaign={campaign} />
|
<KpiStrip campaign={campaign} />
|
||||||
|
|
||||||
{/* Tabs */}
|
{/* Main body: leads table on the left, campaign details + funnel + source on the right */}
|
||||||
<div className="px-7 pt-5">
|
<div className="px-7 pt-5 pb-7">
|
||||||
<Tabs selectedKey={activeTab} onSelectionChange={(key) => setActiveTab(String(key))}>
|
<div className="grid grid-cols-1 gap-5 xl:grid-cols-[1fr_340px]">
|
||||||
<TabList
|
<div className="space-y-6">
|
||||||
type="underline"
|
<div>
|
||||||
size="sm"
|
<div className="mb-3 flex items-center justify-between">
|
||||||
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">
|
|
||||||
<h3 className="text-md font-bold text-primary">
|
<h3 className="text-md font-bold text-primary">
|
||||||
Ads ({campaignAds.length})
|
Leads ({campaignLeads.length})
|
||||||
</h3>
|
</h3>
|
||||||
{campaignAds.map((ad) => (
|
</div>
|
||||||
<AdCard key={ad.id} ad={ad} />
|
{campaignLeads.length === 0 ? (
|
||||||
))}
|
<div className="rounded-xl border border-secondary bg-primary p-8 text-center text-sm text-tertiary">
|
||||||
{campaignAds.length === 0 && (
|
No leads from this campaign yet.
|
||||||
<p className="py-8 text-center text-sm text-tertiary">
|
</div>
|
||||||
No ads for this campaign.
|
) : (
|
||||||
</p>
|
<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>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{campaignAds.map((ad) => (
|
||||||
|
<AdCard key={ad.id} ad={ad} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Details + Funnel + Source */}
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Campaign Details card */}
|
|
||||||
<div className="rounded-xl border border-secondary bg-primary p-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>
|
<h4 className="mb-3 text-sm font-bold text-primary">Campaign Details</h4>
|
||||||
<dl className="space-y-2 text-xs">
|
<dl className="space-y-2 text-xs">
|
||||||
@@ -138,34 +170,21 @@ export const CampaignDetailPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Conversion Funnel */}
|
|
||||||
<ConversionFunnel campaign={campaign} leads={campaignLeads} />
|
<ConversionFunnel campaign={campaign} leads={campaignLeads} />
|
||||||
|
|
||||||
{/* Source Breakdown */}
|
|
||||||
<SourceBreakdown leads={campaignLeads} />
|
<SourceBreakdown leads={campaignLeads} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabPanel>
|
</div>
|
||||||
|
|
||||||
<TabPanel id="leads">
|
{activityLead && (
|
||||||
<div className="mt-5 pb-7">
|
<LeadActivitySlideout
|
||||||
<div className="flex flex-col items-center justify-center rounded-xl border border-secondary bg-primary p-12 text-center">
|
isOpen={!!activityLead}
|
||||||
<p className="text-md font-bold text-primary">
|
onOpenChange={(open) => !open && setActivityLead(null)}
|
||||||
{campaignLeads.length} lead{campaignLeads.length !== 1 ? 's' : ''} from this campaign
|
lead={activityLead}
|
||||||
</p>
|
activities={leadActivities}
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabPanel>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { MaintOtpModal } from '@/components/modals/maint-otp-modal';
|
|||||||
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
|
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
|
||||||
import { useThemeTokens } from '@/providers/theme-token-provider';
|
import { useThemeTokens } from '@/providers/theme-token-provider';
|
||||||
import { getSetupState } from '@/lib/setup-state';
|
import { getSetupState } from '@/lib/setup-state';
|
||||||
|
import { getUiFlags } from '@/hooks/use-ui-flags';
|
||||||
|
|
||||||
export const LoginPage = () => {
|
export const LoginPage = () => {
|
||||||
const { loginWithUser } = useAuth();
|
const { loginWithUser } = useAuth();
|
||||||
@@ -118,11 +119,13 @@ export const LoginPage = () => {
|
|||||||
|
|
||||||
// First-run detection: if the workspace's setup is incomplete and
|
// First-run detection: if the workspace's setup is incomplete and
|
||||||
// the wizard hasn't been dismissed, route the admin to /setup so
|
// the wizard hasn't been dismissed, route the admin to /setup so
|
||||||
// they finish onboarding before reaching the dashboard. Failures
|
// they finish onboarding before reaching the dashboard. Skip when
|
||||||
// are non-blocking — we always have a fallback to /.
|
// 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 {
|
try {
|
||||||
const state = await getSetupState();
|
const [state, flags] = await Promise.all([getSetupState(), getUiFlags()]);
|
||||||
if (state.wizardRequired) {
|
if (state.wizardRequired && !flags.setupManaged) {
|
||||||
navigate('/setup');
|
navigate('/setup');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,18 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|||||||
import { faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons';
|
import { faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { AiChatPanel } from '@/components/call-desk/ai-chat-panel';
|
import { AiChatPanel } from '@/components/call-desk/ai-chat-panel';
|
||||||
import { DashboardKpi } from '@/components/dashboard/kpi-cards';
|
import { DashboardKpi } from '@/components/dashboard/kpi-cards';
|
||||||
import { AgentTable } from '@/components/dashboard/agent-table';
|
|
||||||
import { MissedQueue } from '@/components/dashboard/missed-queue';
|
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 { useData } from '@/providers/data-provider';
|
||||||
import { cx } from '@/utils/cx';
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
type DateRange = 'today' | 'week' | 'month';
|
type DateRange = 'today' | 'week' | 'month';
|
||||||
type DashboardTab = 'agents' | 'missed' | 'campaigns';
|
|
||||||
|
|
||||||
const getDateRangeStart = (range: DateRange): Date => {
|
const getDateRangeStart = (range: DateRange): Date => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -23,9 +28,13 @@ const getDateRangeStart = (range: DateRange): Date => {
|
|||||||
export const TeamDashboardPage = () => {
|
export const TeamDashboardPage = () => {
|
||||||
const { calls, leads, campaigns, loading } = useData();
|
const { calls, leads, campaigns, loading } = useData();
|
||||||
const [dateRange, setDateRange] = useState<DateRange>('week');
|
const [dateRange, setDateRange] = useState<DateRange>('week');
|
||||||
const [tab, setTab] = useState<DashboardTab>('agents');
|
|
||||||
const [aiOpen, setAiOpen] = useState(true);
|
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 filteredCalls = useMemo(() => {
|
||||||
const rangeStart = getDateRangeStart(dateRange);
|
const rangeStart = getDateRangeStart(dateRange);
|
||||||
return calls.filter((call) => {
|
return calls.filter((call) => {
|
||||||
@@ -36,11 +45,13 @@ export const TeamDashboardPage = () => {
|
|||||||
|
|
||||||
const dateRangeLabel = dateRange === 'today' ? 'Today' : dateRange === 'week' ? 'This Week' : 'This Month';
|
const dateRangeLabel = dateRange === 'today' ? 'Today' : dateRange === 'week' ? 'This Week' : 'This Month';
|
||||||
|
|
||||||
const tabs = [
|
const convRate = useMemo(() => {
|
||||||
{ id: 'agents' as const, label: 'Agent Performance' },
|
if (filteredCalls.length === 0) return 0;
|
||||||
{ id: 'missed' as const, label: `Missed Queue (${filteredCalls.filter(c => c.callStatus === 'MISSED').length})` },
|
const completed = filteredCalls.filter((c) => c.callStatus === 'COMPLETED').length;
|
||||||
{ id: 'campaigns' as const, label: `Campaigns (${campaigns.length})` },
|
return Math.round((completed / filteredCalls.length) * 100);
|
||||||
];
|
}, [filteredCalls]);
|
||||||
|
|
||||||
|
const missedQueueCount = filteredCalls.filter((c) => c.callStatus === 'MISSED').length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
@@ -76,54 +87,48 @@ export const TeamDashboardPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<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">
|
<div className="flex flex-1 flex-col overflow-y-auto">
|
||||||
{/* KPI cards — always visible */}
|
|
||||||
<div className="px-6 pt-5 pb-3">
|
<div className="px-6 pt-5 pb-3">
|
||||||
<DashboardKpi calls={filteredCalls} leads={leads} />
|
<DashboardKpi calls={filteredCalls} leads={leads} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
<div className="flex-1 space-y-5 px-6 pb-8">
|
||||||
<div className="flex items-center gap-1 border-b border-secondary px-6">
|
{loading && rollupAgents.length === 0 ? (
|
||||||
{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 items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<p className="text-sm text-tertiary">Loading...</p>
|
<p className="text-sm text-tertiary">Loading...</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && tab === 'agents' && (
|
|
||||||
<AgentTable calls={filteredCalls} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!loading && tab === 'missed' && (
|
|
||||||
<MissedQueue calls={filteredCalls} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!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">
|
<RichAgentTable agents={rollupAgents} />
|
||||||
|
|
||||||
|
<TimeBreakdown agents={rollupAgents} />
|
||||||
|
|
||||||
|
<NpsConversion agents={rollupAgents} convRate={convRate} />
|
||||||
|
|
||||||
|
<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>
|
<div>
|
||||||
<span className="text-sm font-semibold text-primary">{c.campaignName}</span>
|
<span className="text-sm font-semibold text-primary">{c.campaignName}</span>
|
||||||
<div className="flex items-center gap-3 mt-1 text-xs text-tertiary">
|
<div className="flex items-center gap-3 mt-1 text-xs text-tertiary">
|
||||||
@@ -139,9 +144,11 @@ export const TeamDashboardPage = () => {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))
|
))}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import type { ReactNode } from 'react';
|
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 { apiClient } from '@/lib/api-client';
|
||||||
import {
|
import {
|
||||||
LEADS_QUERY,
|
leadsQuery,
|
||||||
CAMPAIGNS_QUERY,
|
campaignsQuery,
|
||||||
ADS_QUERY,
|
adsQuery,
|
||||||
FOLLOW_UPS_QUERY,
|
followUpsQuery,
|
||||||
LEAD_ACTIVITIES_QUERY,
|
leadActivitiesQuery,
|
||||||
CALLS_QUERY,
|
callsQuery,
|
||||||
APPOINTMENTS_QUERY,
|
appointmentsQuery,
|
||||||
PATIENTS_QUERY,
|
patientsQuery,
|
||||||
} from '@/lib/queries';
|
} from '@/lib/queries';
|
||||||
import {
|
import {
|
||||||
transformLeads,
|
transformLeads,
|
||||||
@@ -70,6 +70,7 @@ export const DataProvider = ({ children }: DataProviderProps) => {
|
|||||||
const [patients, setPatients] = useState<Patient[]>([]);
|
const [patients, setPatients] = useState<Patient[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const hasLoadedRef = useRef(false);
|
||||||
|
|
||||||
// These don't have platform entities yet — empty for now
|
// These don't have platform entities yet — empty for now
|
||||||
const [templates] = useState<WhatsAppTemplate[]>([]);
|
const [templates] = useState<WhatsAppTemplate[]>([]);
|
||||||
@@ -82,21 +83,48 @@ export const DataProvider = ({ children }: DataProviderProps) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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);
|
setLoading(true);
|
||||||
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const gql = <T,>(query: string) => apiClient.graphql<T>(query, undefined, { silent: true }).catch(() => null);
|
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([
|
const [leadsData, campaignsData, adsData, followUpsData, activitiesData, callsData, appointmentsData, patientsData] = await Promise.all([
|
||||||
gql<any>(LEADS_QUERY),
|
fetchAll('leads', leadsQuery),
|
||||||
gql<any>(CAMPAIGNS_QUERY),
|
fetchAll('campaigns', campaignsQuery),
|
||||||
gql<any>(ADS_QUERY),
|
fetchAll('ads', adsQuery),
|
||||||
gql<any>(FOLLOW_UPS_QUERY),
|
fetchAll('followUps', followUpsQuery),
|
||||||
gql<any>(LEAD_ACTIVITIES_QUERY),
|
fetchAll('leadActivities', leadActivitiesQuery),
|
||||||
gql<any>(CALLS_QUERY),
|
fetchAll('calls', callsQuery),
|
||||||
gql<any>(APPOINTMENTS_QUERY),
|
fetchAll('appointments', appointmentsQuery),
|
||||||
gql<any>(PATIENTS_QUERY),
|
fetchAll('patients', patientsQuery),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (leadsData) setLeads(transformLeads(leadsData));
|
if (leadsData) setLeads(transformLeads(leadsData));
|
||||||
@@ -110,6 +138,7 @@ export const DataProvider = ({ children }: DataProviderProps) => {
|
|||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message ?? 'Failed to load data');
|
setError(err.message ?? 'Failed to load data');
|
||||||
} finally {
|
} finally {
|
||||||
|
hasLoadedRef.current = true;
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
Reference in New Issue
Block a user