mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
Compare commits
7 Commits
v0.13-ai-c
...
85976803a1
| Author | SHA1 | Date | |
|---|---|---|---|
| 85976803a1 | |||
| 4ddad7c060 | |||
| 911ea4cd6c | |||
| 9cc71dbd95 | |||
| 0bc8271845 | |||
| eee7c82b8d | |||
| 810eb75ccb |
@@ -41,7 +41,7 @@ const formatDuration = (seconds: number): string => {
|
|||||||
|
|
||||||
export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete }: ActiveCallCardProps) => {
|
export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete }: ActiveCallCardProps) => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { callState, callDuration, callUcid, isMuted, isOnHold, answer, reject, hangup, toggleMute, toggleHold } = useSip();
|
const { callState, callDuration, callUcid, isMuted, isOnHold, answer, hangup, toggleMute, toggleHold } = useSip();
|
||||||
const setCallState = useSetAtom(sipCallStateAtom);
|
const setCallState = useSetAtom(sipCallStateAtom);
|
||||||
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
|
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
|
||||||
const setCallUcid = useSetAtom(sipCallUcidAtom);
|
const setCallUcid = useSetAtom(sipCallUcidAtom);
|
||||||
@@ -71,7 +71,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
// Upcoming appointments for this caller (if returning patient) — drives
|
// Upcoming appointments for this caller (if returning patient) — drives
|
||||||
// the pill row above AppointmentForm so the agent can edit existing
|
// the pill row above AppointmentForm so the agent can edit existing
|
||||||
// bookings in addition to creating new ones.
|
// bookings in addition to creating new ones.
|
||||||
const { appointments } = useData();
|
const { appointments, refresh } = useData();
|
||||||
const leadAppointments = useMemo(() => {
|
const leadAppointments = useMemo(() => {
|
||||||
const patientId = (lead as any)?.patientId;
|
const patientId = (lead as any)?.patientId;
|
||||||
if (!patientId) return [];
|
if (!patientId) return [];
|
||||||
@@ -180,6 +180,11 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
|
|
||||||
const handleAppointmentSaved = (outcome: 'BOOKED' | 'RESCHEDULED' | 'CANCELLED') => {
|
const handleAppointmentSaved = (outcome: 'BOOKED' | 'RESCHEDULED' | 'CANCELLED') => {
|
||||||
setAppointmentOpen(false);
|
setAppointmentOpen(false);
|
||||||
|
refresh();
|
||||||
|
// Invalidate sidecar's caller context cache so AI gets fresh appointment data
|
||||||
|
if (lead?.id) {
|
||||||
|
apiClient.post('/api/caller/invalidate-context', { leadId: lead.id }, { silent: true }).catch(() => {});
|
||||||
|
}
|
||||||
if (outcome === 'RESCHEDULED') {
|
if (outcome === 'RESCHEDULED') {
|
||||||
addActions('RESCHEDULE');
|
addActions('RESCHEDULE');
|
||||||
notify.success('Appointment Rescheduled');
|
notify.success('Appointment Rescheduled');
|
||||||
@@ -248,7 +253,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex gap-2">
|
<div className="mt-3 flex gap-2">
|
||||||
<Button size="sm" color="primary" onClick={answer}>Answer</Button>
|
<Button size="sm" color="primary" onClick={answer}>Answer</Button>
|
||||||
<Button size="sm" color="tertiary-destructive" onClick={reject}>Decline</Button>
|
{/* Decline hidden per product — reject returns call to Ozonetel queue */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { TableBody as AriaTableBody } from 'react-aria-components';
|
import { TableBody as AriaTableBody } from 'react-aria-components';
|
||||||
import type { SortDescriptor, Selection } from 'react-aria-components';
|
import type { SortDescriptor, Selection } from 'react-aria-components';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import { faEye } from '@fortawesome/pro-duotone-svg-icons';
|
|
||||||
import { Badge } from '@/components/base/badges/badges';
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
import { Table } from '@/components/application/table/table';
|
import { Table } from '@/components/application/table/table';
|
||||||
import { LeadStatusBadge } from '@/components/shared/status-badge';
|
import { LeadStatusBadge } from '@/components/shared/status-badge';
|
||||||
@@ -94,7 +92,6 @@ export const LeadTable = ({
|
|||||||
}, [leads, expandedDupId]);
|
}, [leads, expandedDupId]);
|
||||||
|
|
||||||
const allColumns = [
|
const allColumns = [
|
||||||
{ id: 'view', label: '', allowsSorting: false, defaultWidth: 40 },
|
|
||||||
{ id: 'phone', label: 'Phone', allowsSorting: true, defaultWidth: 150 },
|
{ id: 'phone', label: 'Phone', allowsSorting: true, defaultWidth: 150 },
|
||||||
{ id: 'name', label: 'Name', allowsSorting: true, defaultWidth: 160 },
|
{ id: 'name', label: 'Name', allowsSorting: true, defaultWidth: 160 },
|
||||||
{ id: 'email', label: 'Email', allowsSorting: false, defaultWidth: 180 },
|
{ id: 'email', label: 'Email', allowsSorting: false, defaultWidth: 180 },
|
||||||
@@ -110,7 +107,7 @@ export const LeadTable = ({
|
|||||||
];
|
];
|
||||||
|
|
||||||
const columns = visibleColumns
|
const columns = visibleColumns
|
||||||
? allColumns.filter(c => visibleColumns.has(c.id) || c.id === 'view')
|
? allColumns.filter(c => visibleColumns.has(c.id))
|
||||||
: allColumns;
|
: allColumns;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -156,7 +153,6 @@ export const LeadTable = ({
|
|||||||
id={row.id}
|
id={row.id}
|
||||||
className="bg-warning-primary"
|
className="bg-warning-primary"
|
||||||
>
|
>
|
||||||
<Table.Cell />
|
|
||||||
<Table.Cell className="pl-10">
|
<Table.Cell className="pl-10">
|
||||||
<span className="text-xs text-tertiary">{phone}</span>
|
<span className="text-xs text-tertiary">{phone}</span>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
@@ -207,20 +203,12 @@ export const LeadTable = ({
|
|||||||
key={row.id}
|
key={row.id}
|
||||||
id={row.id}
|
id={row.id}
|
||||||
className={cx(
|
className={cx(
|
||||||
'group/row',
|
'group/row cursor-pointer',
|
||||||
isSpamRow && !isSelected && 'bg-warning-primary',
|
isSpamRow && !isSelected && 'bg-warning-primary',
|
||||||
isSelected && 'bg-brand-primary',
|
isSelected && 'bg-brand-primary',
|
||||||
)}
|
)}
|
||||||
|
onAction={() => onViewActivity?.(lead)}
|
||||||
>
|
>
|
||||||
<Table.Cell>
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); onViewActivity?.(lead); }}
|
|
||||||
className="flex size-7 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
|
||||||
title="View details"
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faEye} className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
</Table.Cell>
|
|
||||||
{isCol('phone') && <Table.Cell>
|
{isCol('phone') && <Table.Cell>
|
||||||
{phoneRaw ? (
|
{phoneRaw ? (
|
||||||
<PhoneActionCell phoneNumber={phoneRaw} displayNumber={phone} />
|
<PhoneActionCell phoneNumber={phoneRaw} displayNumber={phone} />
|
||||||
|
|||||||
@@ -146,9 +146,7 @@ export const AllLeadsPage = () => {
|
|||||||
result = result.filter((l) => l.assignedAgent === user.name);
|
result = result.filter((l) => l.assignedAgent === user.name);
|
||||||
}
|
}
|
||||||
if (campaignFilter) {
|
if (campaignFilter) {
|
||||||
result = campaignFilter === '__none__'
|
result = result.filter((l) => l.campaignId === campaignFilter);
|
||||||
? result.filter((l) => !l.campaignId)
|
|
||||||
: result.filter((l) => l.campaignId === campaignFilter);
|
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}, [sortedLeads, myLeadsOnly, user.name, campaignFilter]);
|
}, [sortedLeads, myLeadsOnly, user.name, campaignFilter]);
|
||||||
@@ -320,17 +318,6 @@ export const AllLeadsPage = () => {
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<button
|
|
||||||
onClick={() => { setCampaignFilter(campaignFilter === '__none__' ? null : '__none__'); setCurrentPage(1); }}
|
|
||||||
className={cx(
|
|
||||||
'shrink-0 rounded-full px-3 py-1 text-xs font-medium transition duration-100 ease-linear',
|
|
||||||
campaignFilter === '__none__'
|
|
||||||
? 'bg-brand-solid text-white'
|
|
||||||
: 'bg-secondary text-tertiary hover:text-secondary hover:bg-secondary_hover',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
No Campaign ({filteredLeads.filter(l => !l.campaignId).length})
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// Appointments v2 — lean table + detail side panel + reschedule + reminder
|
// Appointments v2 — lean table + detail side panel + reschedule
|
||||||
|
// Uses DataProvider as single source of truth for appointment data.
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import {
|
import {
|
||||||
faMagnifyingGlass, faPenToSquare, faEye, faBell, faXmark,
|
faMagnifyingGlass, faPenToSquare, faXmark,
|
||||||
faCalendarCheck, faUserDoctor, faBuilding, faStethoscope, faNotesMedical,
|
faCalendarCheck, faUserDoctor, faBuilding, faStethoscope, faNotesMedical,
|
||||||
} from '@fortawesome/pro-duotone-svg-icons';
|
} from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { faIcon } from '@/lib/icon-wrapper';
|
import { faIcon } from '@/lib/icon-wrapper';
|
||||||
@@ -12,7 +13,6 @@ import { Badge } from '@/components/base/badges/badges';
|
|||||||
import { Input } from '@/components/base/input/input';
|
import { Input } from '@/components/base/input/input';
|
||||||
import { Table } from '@/components/application/table/table';
|
import { Table } from '@/components/application/table/table';
|
||||||
import { PaginationCardDefault } from '@/components/application/pagination/pagination';
|
import { PaginationCardDefault } from '@/components/application/pagination/pagination';
|
||||||
// TopBar replaced by inline header
|
|
||||||
import { Button } from '@/components/base/buttons/button';
|
import { Button } from '@/components/base/buttons/button';
|
||||||
import { ModalOverlay, Modal, Dialog } from '@/components/application/modals/modal';
|
import { ModalOverlay, Modal, Dialog } from '@/components/application/modals/modal';
|
||||||
import { Select } from '@/components/base/select/select';
|
import { Select } from '@/components/base/select/select';
|
||||||
@@ -21,33 +21,11 @@ import { parseDate, today, getLocalTimeZone } from '@internationalized/date';
|
|||||||
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
||||||
import { PageHeader } from '@/components/layout/page-header';
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
import { formatPhone, formatDateOnly, formatTimeOnly } from '@/lib/format';
|
import { formatPhone, formatDateOnly, formatTimeOnly } from '@/lib/format';
|
||||||
|
import { useData } from '@/providers/data-provider';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { notify } from '@/lib/toast';
|
import { notify } from '@/lib/toast';
|
||||||
import { cx } from '@/utils/cx';
|
import { cx } from '@/utils/cx';
|
||||||
|
import type { Appointment } from '@/types/entities';
|
||||||
type AppointmentRecord = {
|
|
||||||
id: string;
|
|
||||||
scheduledAt: string | null;
|
|
||||||
durationMin: number | null;
|
|
||||||
appointmentType: string | null;
|
|
||||||
status: string | null;
|
|
||||||
doctorName: string | null;
|
|
||||||
department: string | null;
|
|
||||||
reasonForVisit: string | null;
|
|
||||||
patient: {
|
|
||||||
id: string;
|
|
||||||
fullName: { firstName: string; lastName: string } | null;
|
|
||||||
phones: { primaryPhoneNumber: string } | null;
|
|
||||||
} | null;
|
|
||||||
clinic: {
|
|
||||||
id?: string;
|
|
||||||
clinicName: string;
|
|
||||||
} | null;
|
|
||||||
doctor: {
|
|
||||||
id: string;
|
|
||||||
fullName?: { firstName: string; lastName: string } | null;
|
|
||||||
} | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type StatusTab = 'all' | 'SCHEDULED' | 'COMPLETED' | 'CANCELLED' | 'RESCHEDULED';
|
type StatusTab = 'all' | 'SCHEDULED' | 'COMPLETED' | 'CANCELLED' | 'RESCHEDULED';
|
||||||
|
|
||||||
@@ -69,43 +47,14 @@ const STATUS_LABELS: Record<string, string> = {
|
|||||||
RESCHEDULED: 'Rescheduled',
|
RESCHEDULED: 'Rescheduled',
|
||||||
};
|
};
|
||||||
|
|
||||||
const QUERY = `{ appointments(first: 200, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
const getPatientName = (appt: Appointment): string =>
|
||||||
id scheduledAt durationMin appointmentType status
|
appt.patientName || 'Unknown';
|
||||||
doctorName department reasonForVisit
|
|
||||||
patient { id fullName { firstName lastName } phones { primaryPhoneNumber } }
|
|
||||||
clinic { id clinicName }
|
|
||||||
doctor { id fullName { firstName lastName } }
|
|
||||||
} } } }`;
|
|
||||||
|
|
||||||
const formatDateTime = (iso: string): string =>
|
const getPhone = (appt: Appointment): string =>
|
||||||
`${formatDateOnly(iso)}, ${formatTimeOnly(iso)}`;
|
appt.patientPhone ?? '';
|
||||||
|
|
||||||
const getPatientName = (appt: AppointmentRecord): string => {
|
const canEdit = (appt: Appointment): boolean =>
|
||||||
if (!appt.patient?.fullName) return 'Unknown';
|
appt.appointmentStatus !== 'COMPLETED' && appt.appointmentStatus !== 'CANCELLED' && appt.appointmentStatus !== 'NO_SHOW';
|
||||||
return `${appt.patient.fullName.firstName} ${appt.patient.fullName.lastName}`.trim() || 'Unknown';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getPhone = (appt: AppointmentRecord): string =>
|
|
||||||
appt.patient?.phones?.primaryPhoneNumber ?? '';
|
|
||||||
|
|
||||||
const isUpcoming = (appt: AppointmentRecord): boolean => {
|
|
||||||
if (appt.status !== 'SCHEDULED' && appt.status !== 'CONFIRMED') return false;
|
|
||||||
if (!appt.scheduledAt) return false;
|
|
||||||
return new Date(appt.scheduledAt).getTime() >= Date.now();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Can edit/reschedule: anything that isn't completed or cancelled
|
|
||||||
const canEdit = (appt: AppointmentRecord): boolean => {
|
|
||||||
return appt.status !== 'COMPLETED' && appt.status !== 'CANCELLED' && appt.status !== 'NO_SHOW';
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildReminderMessage = (appt: AppointmentRecord): string => {
|
|
||||||
const name = getPatientName(appt);
|
|
||||||
const doctor = appt.doctorName ?? 'your doctor';
|
|
||||||
const date = appt.scheduledAt ? formatDateTime(appt.scheduledAt) : 'your scheduled time';
|
|
||||||
const branch = appt.clinic?.clinicName ?? 'our clinic';
|
|
||||||
return `Hi ${name}, this is a reminder for your appointment with ${doctor} on ${date} at ${branch}. Please confirm or call us to reschedule.`;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Detail Panel ─────────────────────────────────────────────────
|
// ── Detail Panel ─────────────────────────────────────────────────
|
||||||
const DetailRow = ({ icon, label, value }: { icon: any; label: string; value: string }) => (
|
const DetailRow = ({ icon, label, value }: { icon: any; label: string; value: string }) => (
|
||||||
@@ -123,7 +72,7 @@ const AppointmentDetailPanel = ({
|
|||||||
onClose,
|
onClose,
|
||||||
onReschedule,
|
onReschedule,
|
||||||
}: {
|
}: {
|
||||||
appointment: AppointmentRecord;
|
appointment: Appointment;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onReschedule: () => void;
|
onReschedule: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
@@ -155,12 +104,11 @@ const AppointmentDetailPanel = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-1">
|
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-1">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<Badge size="md" color={STATUS_COLORS[appointment.status ?? ''] ?? 'gray'} type="pill-color">
|
<Badge size="md" color={STATUS_COLORS[appointment.appointmentStatus ?? ''] ?? 'gray'} type="pill-color">
|
||||||
{STATUS_LABELS[appointment.status ?? ''] ?? appointment.status ?? '—'}
|
{STATUS_LABELS[appointment.appointmentStatus ?? ''] ?? appointment.appointmentStatus ?? '—'}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Date & Time — 2 lines */}
|
|
||||||
<div className="flex items-start gap-3 py-2.5">
|
<div className="flex items-start gap-3 py-2.5">
|
||||||
<FontAwesomeIcon icon={faCalendarCheck} className="size-4 text-fg-quaternary mt-0.5 shrink-0" />
|
<FontAwesomeIcon icon={faCalendarCheck} className="size-4 text-fg-quaternary mt-0.5 shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
@@ -176,7 +124,7 @@ const AppointmentDetailPanel = ({
|
|||||||
|
|
||||||
<DetailRow icon={faUserDoctor} label="Doctor" value={appointment.doctorName ?? '—'} />
|
<DetailRow icon={faUserDoctor} label="Doctor" value={appointment.doctorName ?? '—'} />
|
||||||
<DetailRow icon={faStethoscope} label="Department" value={appointment.department ?? '—'} />
|
<DetailRow icon={faStethoscope} label="Department" value={appointment.department ?? '—'} />
|
||||||
<DetailRow icon={faBuilding} label="Branch / Clinic" value={appointment.clinic?.clinicName ?? '—'} />
|
<DetailRow icon={faBuilding} label="Branch / Clinic" value={appointment.clinicName ?? '—'} />
|
||||||
<DetailRow icon={faNotesMedical} label="Chief Complaint" value={appointment.reasonForVisit ?? '—'} />
|
<DetailRow icon={faNotesMedical} label="Chief Complaint" value={appointment.reasonForVisit ?? '—'} />
|
||||||
|
|
||||||
<div className="border-t border-secondary pt-3 mt-3">
|
<div className="border-t border-secondary pt-3 mt-3">
|
||||||
@@ -190,7 +138,6 @@ const AppointmentDetailPanel = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Reschedule confirm modal — same pattern as call desk */}
|
|
||||||
<ModalOverlay
|
<ModalOverlay
|
||||||
isOpen={reschedulePromptOpen}
|
isOpen={reschedulePromptOpen}
|
||||||
onOpenChange={(open) => { if (!open) setReschedulePromptOpen(false); }}
|
onOpenChange={(open) => { if (!open) setReschedulePromptOpen(false); }}
|
||||||
@@ -203,7 +150,6 @@ const AppointmentDetailPanel = ({
|
|||||||
<h2 className="text-lg font-semibold text-primary">Reschedule this appointment?</h2>
|
<h2 className="text-lg font-semibold text-primary">Reschedule this appointment?</h2>
|
||||||
<p className="text-sm text-tertiary">
|
<p className="text-sm text-tertiary">
|
||||||
Choose "Yes, reschedule" to change the date, time, or doctor.
|
Choose "Yes, reschedule" to change the date, time, or doctor.
|
||||||
Choose "No, just view" to see the details without changing anything.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2 justify-end">
|
<div className="flex items-center gap-2 justify-end">
|
||||||
<Button size="sm" color="secondary" onClick={() => setReschedulePromptOpen(false)}>
|
<Button size="sm" color="secondary" onClick={() => setReschedulePromptOpen(false)}>
|
||||||
@@ -223,10 +169,6 @@ const AppointmentDetailPanel = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── Reschedule Panel ─────────────────────────────────────────────
|
// ── Reschedule Panel ─────────────────────────────────────────────
|
||||||
// Dedicated form for rescheduling from the Appointments page.
|
|
||||||
// No patient creation, no lead updates, no modal — just update the
|
|
||||||
// existing appointment's doctor, date, time, and chief complaint.
|
|
||||||
|
|
||||||
type Doctor = { id: string; name: string; department: string };
|
type Doctor = { id: string; name: string; department: string };
|
||||||
|
|
||||||
const DOCTORS_QUERY = `{ doctors(first: 50) { edges { node {
|
const DOCTORS_QUERY = `{ doctors(first: 50) { edges { node {
|
||||||
@@ -238,13 +180,13 @@ const ReschedulePanel = ({
|
|||||||
onClose,
|
onClose,
|
||||||
onSaved,
|
onSaved,
|
||||||
}: {
|
}: {
|
||||||
appointment: AppointmentRecord;
|
appointment: Appointment;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||||
const [department, setDepartment] = useState(appointment.department ?? '');
|
const [department, setDepartment] = useState(appointment.department ?? '');
|
||||||
const [doctor, setDoctor] = useState(appointment.doctor?.id ?? '');
|
const [doctor, setDoctor] = useState(appointment.doctorId ?? '');
|
||||||
const [date, setDate] = useState(() => appointment.scheduledAt?.split('T')[0] ?? '');
|
const [date, setDate] = useState(() => appointment.scheduledAt?.split('T')[0] ?? '');
|
||||||
const [timeSlot, setTimeSlot] = useState(() => {
|
const [timeSlot, setTimeSlot] = useState(() => {
|
||||||
if (!appointment.scheduledAt) return '';
|
if (!appointment.scheduledAt) return '';
|
||||||
@@ -257,7 +199,6 @@ const ReschedulePanel = ({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [cancelConfirm, setCancelConfirm] = useState(false);
|
const [cancelConfirm, setCancelConfirm] = useState(false);
|
||||||
|
|
||||||
// Fetch doctors once
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiClient.graphql<any>(DOCTORS_QUERY, undefined, { silent: true })
|
apiClient.graphql<any>(DOCTORS_QUERY, undefined, { silent: true })
|
||||||
.then(data => {
|
.then(data => {
|
||||||
@@ -273,11 +214,9 @@ const ReschedulePanel = ({
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Departments derived from doctors
|
|
||||||
const departments = useMemo(() => [...new Set(doctors.map(d => d.department).filter(Boolean))], [doctors]);
|
const departments = useMemo(() => [...new Set(doctors.map(d => d.department).filter(Boolean))], [doctors]);
|
||||||
const filteredDoctors = useMemo(() => department ? doctors.filter(d => d.department === department) : doctors, [doctors, department]);
|
const filteredDoctors = useMemo(() => department ? doctors.filter(d => d.department === department) : doctors, [doctors, department]);
|
||||||
|
|
||||||
// Fetch slots when doctor + date change
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!doctor || !date) { setSlots([]); return; }
|
if (!doctor || !date) { setSlots([]); return; }
|
||||||
apiClient.get<Array<{ time: string; label: string }>>(`/api/masterdata/slots?doctorId=${doctor}&date=${date}`, { silent: true })
|
apiClient.get<Array<{ time: string; label: string }>>(`/api/masterdata/slots?doctorId=${doctor}&date=${date}`, { silent: true })
|
||||||
@@ -346,7 +285,6 @@ const ReschedulePanel = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-4">
|
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-4">
|
||||||
{/* Department */}
|
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs font-medium text-secondary">Department</span>
|
<span className="text-xs font-medium text-secondary">Department</span>
|
||||||
<Select
|
<Select
|
||||||
@@ -360,7 +298,6 @@ const ReschedulePanel = ({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Doctor */}
|
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs font-medium text-secondary">Doctor <span className="text-error-primary">*</span></span>
|
<span className="text-xs font-medium text-secondary">Doctor <span className="text-error-primary">*</span></span>
|
||||||
<Select
|
<Select
|
||||||
@@ -374,7 +311,6 @@ const ReschedulePanel = ({
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Date */}
|
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs font-medium text-secondary">Date <span className="text-error-primary">*</span></span>
|
<span className="text-xs font-medium text-secondary">Date <span className="text-error-primary">*</span></span>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
@@ -387,7 +323,6 @@ const ReschedulePanel = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Time slots */}
|
|
||||||
{doctor && date && slots.length > 0 && (
|
{doctor && date && slots.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs font-medium text-secondary">Time Slot <span className="text-error-primary">*</span></span>
|
<span className="text-xs font-medium text-secondary">Time Slot <span className="text-error-primary">*</span></span>
|
||||||
@@ -413,7 +348,6 @@ const ReschedulePanel = ({
|
|||||||
<p className="text-xs text-tertiary">No available slots for this date</p>
|
<p className="text-xs text-tertiary">No available slots for this date</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Chief Complaint */}
|
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs font-medium text-secondary">Chief Complaint</span>
|
<span className="text-xs font-medium text-secondary">Chief Complaint</span>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -428,7 +362,6 @@ const ReschedulePanel = ({
|
|||||||
{error && <p className="text-sm text-error-primary">{error}</p>}
|
{error && <p className="text-sm text-error-primary">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer buttons */}
|
|
||||||
<div className="flex items-center justify-between gap-2 border-t border-secondary px-5 py-3">
|
<div className="flex items-center justify-between gap-2 border-t border-secondary px-5 py-3">
|
||||||
<Button size="sm" color="primary-destructive" onClick={() => setCancelConfirm(true)} isDisabled={saving}>
|
<Button size="sm" color="primary-destructive" onClick={() => setCancelConfirm(true)} isDisabled={saving}>
|
||||||
Cancel Appointment
|
Cancel Appointment
|
||||||
@@ -438,7 +371,6 @@ const ReschedulePanel = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cancel confirm modal */}
|
|
||||||
<ModalOverlay
|
<ModalOverlay
|
||||||
isOpen={cancelConfirm}
|
isOpen={cancelConfirm}
|
||||||
onOpenChange={(open) => { if (!open) setCancelConfirm(false); }}
|
onOpenChange={(open) => { if (!open) setCancelConfirm(false); }}
|
||||||
@@ -471,37 +403,31 @@ const ReschedulePanel = ({
|
|||||||
|
|
||||||
// ── Page ─────────────────────────────────────────────────────────
|
// ── Page ─────────────────────────────────────────────────────────
|
||||||
export const AppointmentsPageV2 = () => {
|
export const AppointmentsPageV2 = () => {
|
||||||
const [appointments, setAppointments] = useState<AppointmentRecord[]>([]);
|
const { appointments, loading, refresh } = useData();
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [tab, setTab] = useState<StatusTab>('all');
|
const [tab, setTab] = useState<StatusTab>('all');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [selectedAppt, setSelectedAppt] = useState<AppointmentRecord | null>(null);
|
const [selectedAppt, setSelectedAppt] = useState<Appointment | null>(null);
|
||||||
const [panelOpen, setPanelOpen] = useState(false);
|
const [panelOpen, setPanelOpen] = useState(false);
|
||||||
const [rescheduleOpen, setRescheduleOpen] = useState(false);
|
const [rescheduleOpen, setRescheduleOpen] = useState(false);
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
const fetchAppointments = () => {
|
|
||||||
apiClient.graphql<{ appointments: { edges: Array<{ node: AppointmentRecord }> } }>(QUERY, undefined, { silent: true })
|
|
||||||
.then(data => setAppointments(data.appointments.edges.map(e => e.node)))
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => { fetchAppointments(); }, []);
|
|
||||||
|
|
||||||
const statusCounts = useMemo(() => {
|
const statusCounts = useMemo(() => {
|
||||||
const counts: Record<string, number> = {};
|
const counts: Record<string, number> = {};
|
||||||
for (const a of appointments) {
|
for (const a of appointments) {
|
||||||
const s = a.status ?? 'UNKNOWN';
|
const s = a.appointmentStatus ?? 'UNKNOWN';
|
||||||
counts[s] = (counts[s] ?? 0) + 1;
|
counts[s] = (counts[s] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
return counts;
|
return counts;
|
||||||
}, [appointments]);
|
}, [appointments]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
let rows = appointments;
|
let rows = [...appointments].sort((a, b) => {
|
||||||
if (tab !== 'all') rows = rows.filter(a => a.status === tab);
|
const da = a.scheduledAt ? new Date(a.scheduledAt).getTime() : 0;
|
||||||
|
const db = b.scheduledAt ? new Date(b.scheduledAt).getTime() : 0;
|
||||||
|
return db - da;
|
||||||
|
});
|
||||||
|
if (tab !== 'all') rows = rows.filter(a => a.appointmentStatus === tab);
|
||||||
if (search.trim()) {
|
if (search.trim()) {
|
||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
rows = rows.filter(a => {
|
rows = rows.filter(a => {
|
||||||
@@ -527,25 +453,17 @@ export const AppointmentsPageV2 = () => {
|
|||||||
{ id: 'RESCHEDULED' as const, label: 'Rescheduled', badge: statusCounts.RESCHEDULED ? String(statusCounts.RESCHEDULED) : undefined },
|
{ id: 'RESCHEDULED' as const, label: 'Rescheduled', badge: statusCounts.RESCHEDULED ? String(statusCounts.RESCHEDULED) : undefined },
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleEditClick = (appt: AppointmentRecord) => {
|
const handleEditClick = (appt: Appointment) => {
|
||||||
setSelectedAppt(appt);
|
setSelectedAppt(appt);
|
||||||
setPanelOpen(true);
|
setPanelOpen(true);
|
||||||
setRescheduleOpen(false);
|
setRescheduleOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSendReminder = (appt: AppointmentRecord) => {
|
|
||||||
const phone = getPhone(appt);
|
|
||||||
if (!phone) return;
|
|
||||||
const msg = encodeURIComponent(buildReminderMessage(appt));
|
|
||||||
window.open(`https://wa.me/91${phone}?text=${msg}`, '_blank');
|
|
||||||
notify.success('Reminder', `WhatsApp opened for ${getPatientName(appt)}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRescheduleSaved = () => {
|
const handleRescheduleSaved = () => {
|
||||||
setRescheduleOpen(false);
|
setRescheduleOpen(false);
|
||||||
setPanelOpen(false);
|
setPanelOpen(false);
|
||||||
setSelectedAppt(null);
|
setSelectedAppt(null);
|
||||||
fetchAppointments();
|
refresh();
|
||||||
notify.success('Appointment Rescheduled');
|
notify.success('Appointment Rescheduled');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -554,7 +472,7 @@ export const AppointmentsPageV2 = () => {
|
|||||||
<PageHeader
|
<PageHeader
|
||||||
title="Appointments"
|
title="Appointments"
|
||||||
badge={filtered.length}
|
badge={filtered.length}
|
||||||
infoText="All scheduled, completed, cancelled, and rescheduled appointments. Click the eye icon to view details or reschedule."
|
infoText="All scheduled, completed, cancelled, and rescheduled appointments. Click a row to view details or reschedule."
|
||||||
controls={
|
controls={
|
||||||
<div className="w-56">
|
<div className="w-56">
|
||||||
<Input
|
<Input
|
||||||
@@ -589,7 +507,6 @@ export const AppointmentsPageV2 = () => {
|
|||||||
|
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col overflow-hidden px-4 pt-3">
|
<div className="flex flex-1 flex-col overflow-hidden px-4 pt-3">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
@@ -602,47 +519,31 @@ export const AppointmentsPageV2 = () => {
|
|||||||
) : (
|
) : (
|
||||||
<Table size="sm">
|
<Table size="sm">
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Head label="" className="w-8" isRowHeader />
|
<Table.Head label="PATIENT" className="min-w-[180px]" isRowHeader />
|
||||||
<Table.Head label="PATIENT" className="min-w-[180px]" />
|
|
||||||
<Table.Head label="DATE & TIME" className="w-28" />
|
<Table.Head label="DATE & TIME" className="w-28" />
|
||||||
<Table.Head label="DOCTOR" className="min-w-[160px]" />
|
<Table.Head label="DOCTOR" className="min-w-[160px]" />
|
||||||
<Table.Head label="STATUS" className="w-24" />
|
<Table.Head label="STATUS" className="w-24" />
|
||||||
<Table.Head label="REMIND" className="w-20" />
|
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body items={pagedRows}>
|
<Table.Body items={pagedRows}>
|
||||||
{(appt) => {
|
{(appt) => {
|
||||||
const name = getPatientName(appt);
|
const name = getPatientName(appt);
|
||||||
const phone = getPhone(appt);
|
const phone = getPhone(appt);
|
||||||
const statusLabel = STATUS_LABELS[appt.status ?? ''] ?? appt.status ?? '—';
|
const statusLabel = STATUS_LABELS[appt.appointmentStatus ?? ''] ?? appt.appointmentStatus ?? '—';
|
||||||
const statusColor = STATUS_COLORS[appt.status ?? ''] ?? 'gray';
|
const statusColor = STATUS_COLORS[appt.appointmentStatus ?? ''] ?? 'gray';
|
||||||
const upcoming = isUpcoming(appt);
|
|
||||||
const isSelected = selectedAppt?.id === appt.id;
|
const isSelected = selectedAppt?.id === appt.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table.Row
|
<Table.Row
|
||||||
id={appt.id}
|
id={appt.id}
|
||||||
className={cx('group/row', isSelected && 'bg-brand-primary')}
|
className={cx('group/row cursor-pointer', isSelected && 'bg-brand-primary')}
|
||||||
|
onAction={() => handleEditClick(appt)}
|
||||||
>
|
>
|
||||||
{/* Eye icon — first column */}
|
|
||||||
<Table.Cell>
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); handleEditClick(appt); }}
|
|
||||||
className="flex size-7 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
|
||||||
title="View details"
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faEye} className="size-3.5" />
|
|
||||||
</button>
|
|
||||||
</Table.Cell>
|
|
||||||
|
|
||||||
{/* Patient: name + phone on 2 lines */}
|
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-medium text-primary truncate">{name}</p>
|
<p className="text-sm font-medium text-primary truncate">{name}</p>
|
||||||
{phone && <PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />}
|
{phone && <PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />}
|
||||||
</div>
|
</div>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
|
||||||
{/* Date & Time: date + time on 2 lines */}
|
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
{appt.scheduledAt ? (
|
{appt.scheduledAt ? (
|
||||||
<div>
|
<div>
|
||||||
@@ -651,38 +552,17 @@ export const AppointmentsPageV2 = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : <span className="text-sm text-quaternary">—</span>}
|
) : <span className="text-sm text-quaternary">—</span>}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
|
||||||
{/* Doctor: name + department on 2 lines */}
|
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm text-primary truncate">{appt.doctorName ?? '—'}</p>
|
<p className="text-sm text-primary truncate">{appt.doctorName ?? '—'}</p>
|
||||||
{appt.department && <p className="text-xs text-tertiary truncate">{appt.department}</p>}
|
{appt.department && <p className="text-xs text-tertiary truncate">{appt.department}</p>}
|
||||||
</div>
|
</div>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
|
||||||
{/* Status */}
|
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Badge size="sm" color={statusColor} type="pill-color">
|
<Badge size="sm" color={statusColor} type="pill-color">
|
||||||
{statusLabel}
|
{statusLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
|
||||||
{/* Reminder */}
|
|
||||||
<Table.Cell>
|
|
||||||
{upcoming ? (
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); handleSendReminder(appt); }}
|
|
||||||
className="inline-flex items-center gap-1 rounded-lg px-2 py-1 text-xs font-medium text-brand-secondary hover:bg-brand-primary transition duration-100 ease-linear"
|
|
||||||
title="Send WhatsApp reminder"
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faBell} className="size-3" />
|
|
||||||
Send
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-quaternary">—</span>
|
|
||||||
)}
|
|
||||||
</Table.Cell>
|
|
||||||
|
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
@@ -699,7 +579,6 @@ export const AppointmentsPageV2 = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Detail side panel */}
|
|
||||||
<div className={cx(
|
<div className={cx(
|
||||||
"shrink-0 border-l border-secondary bg-primary flex flex-col overflow-hidden transition-all duration-200 ease-linear",
|
"shrink-0 border-l border-secondary bg-primary flex flex-col overflow-hidden transition-all duration-200 ease-linear",
|
||||||
panelOpen && selectedAppt ? "w-[380px]" : "w-0 border-l-0",
|
panelOpen && selectedAppt ? "w-[380px]" : "w-0 border-l-0",
|
||||||
|
|||||||
@@ -73,9 +73,40 @@ export const CampaignDetailPage = () => {
|
|||||||
|
|
||||||
<KpiStrip campaign={campaign} />
|
<KpiStrip campaign={campaign} />
|
||||||
|
|
||||||
{/* Main body: leads table on the left, campaign details + funnel + source on the right */}
|
{/* Campaign details + funnel + source — horizontal cards above table */}
|
||||||
<div className="px-7 pt-5 pb-7">
|
<div className="px-7 pt-5">
|
||||||
<div className="grid grid-cols-1 gap-5 xl:grid-cols-[1fr_340px]">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3 mb-6">
|
||||||
|
<div className="rounded-xl border border-secondary bg-primary p-4">
|
||||||
|
<h4 className="mb-3 text-sm font-bold text-primary">Campaign Details</h4>
|
||||||
|
<dl className="space-y-1.5 text-xs">
|
||||||
|
{[
|
||||||
|
['Type', campaign.campaignType?.replace(/_/g, ' ') ?? '--'],
|
||||||
|
['Platform', campaign.platform ?? '--'],
|
||||||
|
['Start', formatDateShort(campaign.startDate)],
|
||||||
|
['End', formatDateShort(campaign.endDate)],
|
||||||
|
['Budget', campaign.budget ? formatCurrency(campaign.budget.amountMicros, campaign.budget.currencyCode) : '--'],
|
||||||
|
['Impressions', campaign.impressionCount?.toLocaleString('en-IN') ?? '--'],
|
||||||
|
['Clicks', campaign.clickCount?.toLocaleString('en-IN') ?? '--'],
|
||||||
|
].map(([label, value]) => (
|
||||||
|
<div key={label} className="flex justify-between">
|
||||||
|
<dt className="text-quaternary">{label}</dt>
|
||||||
|
<dd className="font-medium text-secondary">{value}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
<BudgetBar spent={campaign.amountSpent} budget={campaign.budget} />
|
||||||
|
<HealthIndicator campaign={campaign} leads={campaignLeads} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConversionFunnel campaign={campaign} leads={campaignLeads} />
|
||||||
|
<SourceBreakdown leads={campaignLeads} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Leads table — full width */}
|
||||||
|
<div className="px-7 pb-7">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
@@ -96,6 +127,7 @@ export const CampaignDetailPage = () => {
|
|||||||
sortDirection={sortDirection}
|
sortDirection={sortDirection}
|
||||||
onSort={handleSort}
|
onSort={handleSort}
|
||||||
onViewActivity={(lead) => setActivityLead(lead)}
|
onViewActivity={(lead) => setActivityLead(lead)}
|
||||||
|
visibleColumns={new Set(['phone', 'name', 'source', 'status', 'lastContactedAt', 'createdAt'])}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -113,68 +145,6 @@ export const CampaignDetailPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="rounded-xl border border-secondary bg-primary p-4">
|
|
||||||
<h4 className="mb-3 text-sm font-bold text-primary">Campaign Details</h4>
|
|
||||||
<dl className="space-y-2 text-xs">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<dt className="text-quaternary">Type</dt>
|
|
||||||
<dd className="font-medium text-secondary">
|
|
||||||
{campaign.campaignType?.replace(/_/g, ' ') ?? '--'}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<dt className="text-quaternary">Platform</dt>
|
|
||||||
<dd className="font-medium text-secondary">
|
|
||||||
{campaign.platform ?? '--'}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<dt className="text-quaternary">Start Date</dt>
|
|
||||||
<dd className="font-medium text-secondary">
|
|
||||||
{formatDateShort(campaign.startDate)}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<dt className="text-quaternary">End Date</dt>
|
|
||||||
<dd className="font-medium text-secondary">
|
|
||||||
{formatDateShort(campaign.endDate)}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<dt className="text-quaternary">Budget</dt>
|
|
||||||
<dd className="font-medium text-secondary">
|
|
||||||
{campaign.budget
|
|
||||||
? formatCurrency(campaign.budget.amountMicros, campaign.budget.currencyCode)
|
|
||||||
: '--'}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<dt className="text-quaternary">Impressions</dt>
|
|
||||||
<dd className="font-medium text-secondary">
|
|
||||||
{campaign.impressionCount?.toLocaleString('en-IN') ?? '--'}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<dt className="text-quaternary">Clicks</dt>
|
|
||||||
<dd className="font-medium text-secondary">
|
|
||||||
{campaign.clickCount?.toLocaleString('en-IN') ?? '--'}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<div className="mt-4 space-y-3">
|
|
||||||
<BudgetBar spent={campaign.amountSpent} budget={campaign.budget} />
|
|
||||||
<HealthIndicator campaign={campaign} leads={campaignLeads} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ConversionFunnel campaign={campaign} leads={campaignLeads} />
|
|
||||||
|
|
||||||
<SourceBreakdown leads={campaignLeads} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{activityLead && (
|
{activityLead && (
|
||||||
|
|||||||
@@ -133,8 +133,6 @@ export const PatientsPage = () => {
|
|||||||
<Table.Head label="PATIENT" isRowHeader />
|
<Table.Head label="PATIENT" isRowHeader />
|
||||||
<Table.Head label="PHONE" />
|
<Table.Head label="PHONE" />
|
||||||
<Table.Head label="EMAIL" />
|
<Table.Head label="EMAIL" />
|
||||||
<Table.Head label="GENDER" />
|
|
||||||
<Table.Head label="AGE" />
|
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body items={pagedPatients} dependencies={[selectedPatient?.id]}>
|
<Table.Body items={pagedPatients} dependencies={[selectedPatient?.id]}>
|
||||||
{(patient) => {
|
{(patient) => {
|
||||||
@@ -197,19 +195,6 @@ export const PatientsPage = () => {
|
|||||||
)}
|
)}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
|
||||||
{/* Gender */}
|
|
||||||
<Table.Cell>
|
|
||||||
<span className="text-sm text-secondary">
|
|
||||||
{patient.gender ? patient.gender.charAt(0) + patient.gender.slice(1).toLowerCase() : '—'}
|
|
||||||
</span>
|
|
||||||
</Table.Cell>
|
|
||||||
|
|
||||||
{/* Age */}
|
|
||||||
<Table.Cell>
|
|
||||||
<span className="text-sm text-secondary">
|
|
||||||
{age !== null ? `${age} yrs` : '—'}
|
|
||||||
</span>
|
|
||||||
</Table.Cell>
|
|
||||||
|
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user