5 Commits

Author SHA1 Message Date
85976803a1 fix: unify appointment data source — single DataProvider, immediate refresh
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- appointments-v2: migrated from local query/state to useData().appointments.
  Removed AppointmentRecord type, QUERY, fetchAppointments(), local useState.
  All field references updated to transformed Appointment type (appointmentStatus,
  patientName, patientPhone, clinicName, doctorId).
- active-call-card: calls refresh() after appointment book/reschedule/cancel
  so pills update immediately. Also invalidates sidecar Redis cache.
- One source of truth — all appointment consumers read from DataProvider.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:20:55 +05:30
4ddad7c060 fix: campaign detail — cards above table layout (stacked, not side-by-side)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Campaign Details, Conversion Funnel, Source Breakdown now render as
3-column horizontal cards above the leads table. Table gets full width.

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

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

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

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

View File

@@ -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>
); );

View File

@@ -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} />

View File

@@ -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>
)} )}

View File

@@ -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",

View File

@@ -73,107 +73,77 @@ 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="space-y-6"> <div className="rounded-xl border border-secondary bg-primary p-4">
<div> <h4 className="mb-3 text-sm font-bold text-primary">Campaign Details</h4>
<div className="mb-3 flex items-center justify-between"> <dl className="space-y-1.5 text-xs">
<h3 className="text-md font-bold text-primary"> {[
Leads ({campaignLeads.length}) ['Type', campaign.campaignType?.replace(/_/g, ' ') ?? '--'],
</h3> ['Platform', campaign.platform ?? '--'],
</div> ['Start', formatDateShort(campaign.startDate)],
{campaignLeads.length === 0 ? ( ['End', formatDateShort(campaign.endDate)],
<div className="rounded-xl border border-secondary bg-primary p-8 text-center text-sm text-tertiary"> ['Budget', campaign.budget ? formatCurrency(campaign.budget.amountMicros, campaign.budget.currencyCode) : '--'],
No leads from this campaign yet. ['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> </div>
) : ( ))}
<LeadTable </dl>
leads={sortedLeads} <div className="mt-3 space-y-2">
selectedIds={selectedIds} <BudgetBar spent={campaign.amountSpent} budget={campaign.budget} />
onSelectionChange={setSelectedIds} <HealthIndicator campaign={campaign} leads={campaignLeads} />
sortField={sortField}
sortDirection={sortDirection}
onSort={handleSort}
onViewActivity={(lead) => setActivityLead(lead)}
/>
)}
</div> </div>
</div>
{campaignAds.length > 0 && ( <ConversionFunnel campaign={campaign} leads={campaignLeads} />
<div> <SourceBreakdown leads={campaignLeads} />
<h3 className="mb-3 text-md font-bold text-primary"> </div>
Ads ({campaignAds.length}) </div>
</h3>
<div className="space-y-3"> {/* Leads table — full width */}
{campaignAds.map((ad) => ( <div className="px-7 pb-7">
<AdCard key={ad.id} ad={ad} /> <div className="space-y-6">
))} <div>
</div> <div className="mb-3 flex items-center justify-between">
<h3 className="text-md font-bold text-primary">
Leads ({campaignLeads.length})
</h3>
</div>
{campaignLeads.length === 0 ? (
<div className="rounded-xl border border-secondary bg-primary p-8 text-center text-sm text-tertiary">
No leads from this campaign yet.
</div> </div>
) : (
<LeadTable
leads={sortedLeads}
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
sortField={sortField}
sortDirection={sortDirection}
onSort={handleSort}
onViewActivity={(lead) => setActivityLead(lead)}
visibleColumns={new Set(['phone', 'name', 'source', 'status', 'lastContactedAt', 'createdAt'])}
/>
)} )}
</div> </div>
<div className="space-y-4"> {campaignAds.length > 0 && (
<div className="rounded-xl border border-secondary bg-primary p-4"> <div>
<h4 className="mb-3 text-sm font-bold text-primary">Campaign Details</h4> <h3 className="mb-3 text-md font-bold text-primary">
<dl className="space-y-2 text-xs"> Ads ({campaignAds.length})
<div className="flex justify-between"> </h3>
<dt className="text-quaternary">Type</dt> <div className="space-y-3">
<dd className="font-medium text-secondary"> {campaignAds.map((ad) => (
{campaign.campaignType?.replace(/_/g, ' ') ?? '--'} <AdCard key={ad.id} ad={ad} />
</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>
</div> </div>
)}
<ConversionFunnel campaign={campaign} leads={campaignLeads} />
<SourceBreakdown leads={campaignLeads} />
</div>
</div> </div>
</div> </div>

View File

@@ -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>
); );