mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
feat: appointments v2 + patients redesign + call history agent filter + datepicker placement
Appointments v2: - Lean 6-column table (eye icon, patient 2-line, date+time 2-line, doctor+dept 2-line, status badge, reminder button) - Detail side panel on eye click (read-only: all fields + patient phone via PhoneActionCell) - Reschedule flow: pencil in panel → modal confirm → dedicated ReschedulePanel with department/doctor/date/slot/complaint fields - Cancel flow: modal confirm before cancelling - WhatsApp reminder button for upcoming booked appointments - DatePicker popoverPlacement prop for narrow panels (opens upward) Patients page redesign: - Phone column uses PhoneActionCell (clickable to dial) - Email split into own column - Actions column replaced by hamburger menu (SMS + WhatsApp) - View (eye) button removed — row click opens profile panel Call History agent filter: - Missed calls excluded from agent's personal history - Chain name parsing for agent matching - "Missed" filter option hidden for agents - Subtitle: "134 completed" (no "0 missed") DatePicker: - New popoverPlacement prop forwarded to AriaPopover - Default "bottom start", use "top start" in constrained panels Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,9 +19,12 @@ interface DatePickerProps extends AriaDatePickerProps<DateValue> {
|
|||||||
onApply?: () => void;
|
onApply?: () => void;
|
||||||
/** The function to call when the cancel button is clicked. */
|
/** The function to call when the cancel button is clicked. */
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
|
/** Override popover placement — use "top start" in narrow panels
|
||||||
|
* where "bottom start" would overflow the viewport. */
|
||||||
|
popoverPlacement?: 'bottom start' | 'top start' | 'top end' | 'bottom end';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DatePicker = ({ value: valueProp, defaultValue, onChange, onApply, onCancel, ...props }: DatePickerProps) => {
|
export const DatePicker = ({ value: valueProp, defaultValue, onChange, onApply, onCancel, popoverPlacement, ...props }: DatePickerProps) => {
|
||||||
const formatter = useDateFormatter({
|
const formatter = useDateFormatter({
|
||||||
month: "short",
|
month: "short",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
@@ -40,7 +43,7 @@ export const DatePicker = ({ value: valueProp, defaultValue, onChange, onApply,
|
|||||||
</AriaGroup>
|
</AriaGroup>
|
||||||
<AriaPopover
|
<AriaPopover
|
||||||
offset={8}
|
offset={8}
|
||||||
placement="bottom start"
|
placement={popoverPlacement ?? "bottom start"}
|
||||||
shouldFlip
|
shouldFlip
|
||||||
className={({ isEntering, isExiting }) =>
|
className={({ isEntering, isExiting }) =>
|
||||||
cx(
|
cx(
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ import { IntegrationsPage } from "@/pages/integrations";
|
|||||||
import { AgentDetailPage } from "@/pages/agent-detail";
|
import { AgentDetailPage } from "@/pages/agent-detail";
|
||||||
import { SettingsPage } from "@/pages/settings";
|
import { SettingsPage } from "@/pages/settings";
|
||||||
import { MyPerformancePage } from "@/pages/my-performance";
|
import { MyPerformancePage } from "@/pages/my-performance";
|
||||||
import { AppointmentsPage } from "@/pages/appointments";
|
// v2 appointments — testing locally via Tauri before replacing v1
|
||||||
|
import { AppointmentsPageV2 as AppointmentsPage } from "@/pages/appointments-v2";
|
||||||
import { TeamPerformancePage } from "@/pages/team-performance";
|
import { TeamPerformancePage } from "@/pages/team-performance";
|
||||||
import { LiveMonitorPage } from "@/pages/live-monitor";
|
import { LiveMonitorPage } from "@/pages/live-monitor";
|
||||||
import { CallRecordingsPage } from "@/pages/call-recordings";
|
import { CallRecordingsPage } from "@/pages/call-recordings";
|
||||||
|
|||||||
714
src/pages/appointments-v2.tsx
Normal file
714
src/pages/appointments-v2.tsx
Normal file
@@ -0,0 +1,714 @@
|
|||||||
|
// Appointments v2 — lean table + detail side panel + reschedule + reminder
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import {
|
||||||
|
faMagnifyingGlass, faPenToSquare, faEye, faBell, faXmark,
|
||||||
|
faCalendarCheck, faUserDoctor, faBuilding, faStethoscope, faNotesMedical,
|
||||||
|
} from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { faIcon } from '@/lib/icon-wrapper';
|
||||||
|
|
||||||
|
const SearchLg = faIcon(faMagnifyingGlass);
|
||||||
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
|
import { Input } from '@/components/base/input/input';
|
||||||
|
import { Table } from '@/components/application/table/table';
|
||||||
|
import { PaginationCardDefault } from '@/components/application/pagination/pagination';
|
||||||
|
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
|
||||||
|
// TopBar replaced by inline header
|
||||||
|
import { Button } from '@/components/base/buttons/button';
|
||||||
|
import { ModalOverlay, Modal, Dialog } from '@/components/application/modals/modal';
|
||||||
|
import { Select } from '@/components/base/select/select';
|
||||||
|
import { DatePicker } from '@/components/application/date-picker/date-picker';
|
||||||
|
import { parseDate, today, getLocalTimeZone } from '@internationalized/date';
|
||||||
|
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
||||||
|
import { formatPhone, formatDateOnly, formatTimeOnly } from '@/lib/format';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import { notify } from '@/lib/toast';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, 'brand' | 'success' | 'error' | 'warning' | 'gray'> = {
|
||||||
|
SCHEDULED: 'brand',
|
||||||
|
CONFIRMED: 'brand',
|
||||||
|
COMPLETED: 'success',
|
||||||
|
CANCELLED: 'error',
|
||||||
|
NO_SHOW: 'warning',
|
||||||
|
RESCHEDULED: 'warning',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
SCHEDULED: 'Booked',
|
||||||
|
CONFIRMED: 'Confirmed',
|
||||||
|
COMPLETED: 'Completed',
|
||||||
|
CANCELLED: 'Cancelled',
|
||||||
|
NO_SHOW: 'No Show',
|
||||||
|
RESCHEDULED: 'Rescheduled',
|
||||||
|
};
|
||||||
|
|
||||||
|
const QUERY = `{ appointments(first: 200, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||||
|
id scheduledAt durationMin appointmentType status
|
||||||
|
doctorName department reasonForVisit
|
||||||
|
patient { id fullName { firstName lastName } phones { primaryPhoneNumber } }
|
||||||
|
clinic { id clinicName }
|
||||||
|
doctor { id fullName { firstName lastName } }
|
||||||
|
} } } }`;
|
||||||
|
|
||||||
|
const formatDateTime = (iso: string): string =>
|
||||||
|
`${formatDateOnly(iso)}, ${formatTimeOnly(iso)}`;
|
||||||
|
|
||||||
|
const getPatientName = (appt: AppointmentRecord): string => {
|
||||||
|
if (!appt.patient?.fullName) return 'Unknown';
|
||||||
|
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 ─────────────────────────────────────────────────
|
||||||
|
const DetailRow = ({ icon, label, value }: { icon: any; label: string; value: string }) => (
|
||||||
|
<div className="flex items-start gap-3 py-2.5">
|
||||||
|
<FontAwesomeIcon icon={icon} className="size-4 text-fg-quaternary mt-0.5 shrink-0" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-wider text-quaternary">{label}</p>
|
||||||
|
<p className="text-sm text-primary mt-0.5">{value || '—'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AppointmentDetailPanel = ({
|
||||||
|
appointment,
|
||||||
|
onClose,
|
||||||
|
onReschedule,
|
||||||
|
}: {
|
||||||
|
appointment: AppointmentRecord;
|
||||||
|
onClose: () => void;
|
||||||
|
onReschedule: () => void;
|
||||||
|
}) => {
|
||||||
|
const editable = canEdit(appointment);
|
||||||
|
const phone = getPhone(appointment);
|
||||||
|
const [reschedulePromptOpen, setReschedulePromptOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex items-center justify-between border-b border-secondary px-5 py-3">
|
||||||
|
<h3 className="text-sm font-bold text-primary">Appointment Details</h3>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{editable && (
|
||||||
|
<button
|
||||||
|
onClick={() => setReschedulePromptOpen(true)}
|
||||||
|
title="Reschedule appointment"
|
||||||
|
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-brand-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faPenToSquare} className="size-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faXmark} className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-1">
|
||||||
|
<div className="mb-4">
|
||||||
|
<Badge size="md" color={STATUS_COLORS[appointment.status ?? ''] ?? 'gray'} type="pill-color">
|
||||||
|
{STATUS_LABELS[appointment.status ?? ''] ?? appointment.status ?? '—'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date & Time — 2 lines */}
|
||||||
|
<div className="flex items-start gap-3 py-2.5">
|
||||||
|
<FontAwesomeIcon icon={faCalendarCheck} className="size-4 text-fg-quaternary mt-0.5 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-wider text-quaternary">Date & Time</p>
|
||||||
|
{appointment.scheduledAt ? (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-primary mt-0.5">{formatDateOnly(appointment.scheduledAt)}</p>
|
||||||
|
<p className="text-xs text-tertiary">{formatTimeOnly(appointment.scheduledAt)}</p>
|
||||||
|
</>
|
||||||
|
) : <p className="text-sm text-quaternary mt-0.5">—</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DetailRow icon={faUserDoctor} label="Doctor" value={appointment.doctorName ?? '—'} />
|
||||||
|
<DetailRow icon={faStethoscope} label="Department" value={appointment.department ?? '—'} />
|
||||||
|
<DetailRow icon={faBuilding} label="Branch / Clinic" value={appointment.clinic?.clinicName ?? '—'} />
|
||||||
|
<DetailRow icon={faNotesMedical} label="Chief Complaint" value={appointment.reasonForVisit ?? '—'} />
|
||||||
|
|
||||||
|
<div className="border-t border-secondary pt-3 mt-3">
|
||||||
|
<p className="text-[11px] font-medium uppercase tracking-wider text-quaternary mb-1">Patient</p>
|
||||||
|
<p className="text-sm font-medium text-primary">{getPatientName(appointment)}</p>
|
||||||
|
{phone && (
|
||||||
|
<div className="mt-1">
|
||||||
|
<PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reschedule confirm modal — same pattern as call desk */}
|
||||||
|
<ModalOverlay
|
||||||
|
isOpen={reschedulePromptOpen}
|
||||||
|
onOpenChange={(open) => { if (!open) setReschedulePromptOpen(false); }}
|
||||||
|
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={() => setReschedulePromptOpen(false)}>
|
||||||
|
No, just view
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" color="primary" onClick={() => { setReschedulePromptOpen(false); onReschedule(); }}>
|
||||||
|
Yes, reschedule
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
</Modal>
|
||||||
|
</ModalOverlay>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── 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 };
|
||||||
|
|
||||||
|
const DOCTORS_QUERY = `{ doctors(first: 50) { edges { node {
|
||||||
|
id name fullName { firstName lastName } department
|
||||||
|
} } } }`;
|
||||||
|
|
||||||
|
const ReschedulePanel = ({
|
||||||
|
appointment,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
appointment: AppointmentRecord;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: () => void;
|
||||||
|
}) => {
|
||||||
|
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||||
|
const [department, setDepartment] = useState(appointment.department ?? '');
|
||||||
|
const [doctor, setDoctor] = useState(appointment.doctor?.id ?? '');
|
||||||
|
const [date, setDate] = useState(() => appointment.scheduledAt?.split('T')[0] ?? '');
|
||||||
|
const [timeSlot, setTimeSlot] = useState(() => {
|
||||||
|
if (!appointment.scheduledAt) return '';
|
||||||
|
const dt = new Date(appointment.scheduledAt);
|
||||||
|
return `${String(dt.getHours()).padStart(2, '0')}:${String(dt.getMinutes()).padStart(2, '0')}`;
|
||||||
|
});
|
||||||
|
const [slots, setSlots] = useState<Array<{ id: string; label: string }>>([]);
|
||||||
|
const [reason, setReason] = useState(appointment.reasonForVisit ?? '');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [cancelConfirm, setCancelConfirm] = useState(false);
|
||||||
|
|
||||||
|
// Fetch doctors once
|
||||||
|
useEffect(() => {
|
||||||
|
apiClient.graphql<any>(DOCTORS_QUERY, undefined, { silent: true })
|
||||||
|
.then(data => {
|
||||||
|
const docs = data.doctors.edges.map((e: any) => {
|
||||||
|
const n = e.node;
|
||||||
|
const name = n.fullName
|
||||||
|
? `Dr. ${n.fullName.firstName} ${n.fullName.lastName}`.trim()
|
||||||
|
: n.name;
|
||||||
|
return { id: n.id, name, department: n.department ?? '' };
|
||||||
|
});
|
||||||
|
setDoctors(docs);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Departments derived from 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]);
|
||||||
|
|
||||||
|
// Fetch slots when doctor + date change
|
||||||
|
useEffect(() => {
|
||||||
|
if (!doctor || !date) { setSlots([]); return; }
|
||||||
|
apiClient.get<Array<{ time: string; label: string }>>(`/api/masterdata/slots?doctorId=${doctor}&date=${date}`, { silent: true })
|
||||||
|
.then(s => setSlots(s.map(sl => ({ id: sl.time, label: sl.label }))))
|
||||||
|
.catch(() => setSlots([]));
|
||||||
|
}, [doctor, date]);
|
||||||
|
|
||||||
|
const handleUpdate = async () => {
|
||||||
|
if (!doctor || !date || !timeSlot) {
|
||||||
|
setError('Please select doctor, date, and time slot');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const scheduledAt = new Date(`${date}T${timeSlot}:00`).toISOString();
|
||||||
|
const selectedDoc = doctors.find(d => d.id === doctor);
|
||||||
|
await apiClient.graphql(
|
||||||
|
`mutation($id: UUID!, $data: AppointmentUpdateInput!) { updateAppointment(id: $id, data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
id: appointment.id,
|
||||||
|
data: {
|
||||||
|
scheduledAt,
|
||||||
|
doctorName: selectedDoc?.name ?? appointment.doctorName,
|
||||||
|
department: department || appointment.department,
|
||||||
|
reasonForVisit: reason || null,
|
||||||
|
status: 'RESCHEDULED',
|
||||||
|
doctorId: doctor,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
onSaved();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message ?? 'Failed to update appointment');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await apiClient.graphql(
|
||||||
|
`mutation($id: UUID!, $data: AppointmentUpdateInput!) { updateAppointment(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: appointment.id, data: { status: 'CANCELLED' } },
|
||||||
|
);
|
||||||
|
notify.success('Appointment Cancelled');
|
||||||
|
onSaved();
|
||||||
|
} catch {
|
||||||
|
setError('Failed to cancel appointment');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex items-center justify-between border-b border-secondary px-5 py-3">
|
||||||
|
<h3 className="text-sm font-bold text-primary">Reschedule Appointment</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faXmark} className="size-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-4">
|
||||||
|
{/* Department */}
|
||||||
|
<div>
|
||||||
|
<span className="text-xs font-medium text-secondary">Department</span>
|
||||||
|
<Select
|
||||||
|
size="sm"
|
||||||
|
placeholder="Select department"
|
||||||
|
selectedKey={department}
|
||||||
|
onSelectionChange={(key) => { setDepartment(String(key)); setDoctor(''); }}
|
||||||
|
items={departments.map(d => ({ id: d, label: d.replace(/_/g, ' ') }))}
|
||||||
|
>
|
||||||
|
{(item) => <Select.Item id={item.id}>{item.label}</Select.Item>}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Doctor */}
|
||||||
|
<div>
|
||||||
|
<span className="text-xs font-medium text-secondary">Doctor <span className="text-error-primary">*</span></span>
|
||||||
|
<Select
|
||||||
|
size="sm"
|
||||||
|
placeholder="Select doctor"
|
||||||
|
selectedKey={doctor}
|
||||||
|
onSelectionChange={(key) => setDoctor(String(key))}
|
||||||
|
items={filteredDoctors.map(d => ({ id: d.id, label: d.name }))}
|
||||||
|
>
|
||||||
|
{(item) => <Select.Item id={item.id}>{item.label}</Select.Item>}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date */}
|
||||||
|
<div>
|
||||||
|
<span className="text-xs font-medium text-secondary">Date <span className="text-error-primary">*</span></span>
|
||||||
|
<DatePicker
|
||||||
|
value={date ? parseDate(date) : null}
|
||||||
|
onChange={(val) => setDate(val ? val.toString() : '')}
|
||||||
|
granularity="day"
|
||||||
|
minValue={today(getLocalTimeZone())}
|
||||||
|
isDisabled={!doctor}
|
||||||
|
popoverPlacement="top start"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time slots */}
|
||||||
|
{doctor && date && slots.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<span className="text-xs font-medium text-secondary">Time Slot <span className="text-error-primary">*</span></span>
|
||||||
|
<div className="mt-1 grid grid-cols-3 gap-1.5">
|
||||||
|
{slots.map(s => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => setTimeSlot(s.id)}
|
||||||
|
className={cx(
|
||||||
|
'rounded-lg border px-2 py-1.5 text-xs font-medium transition duration-100 ease-linear',
|
||||||
|
timeSlot === s.id
|
||||||
|
? 'border-brand bg-brand-primary text-brand-secondary'
|
||||||
|
: 'border-secondary text-secondary hover:border-brand hover:text-brand-secondary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{doctor && date && slots.length === 0 && (
|
||||||
|
<p className="text-xs text-tertiary">No available slots for this date</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Chief Complaint */}
|
||||||
|
<div>
|
||||||
|
<span className="text-xs font-medium text-secondary">Chief Complaint</span>
|
||||||
|
<textarea
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
className="mt-1 w-full rounded-lg border border-primary bg-primary px-3 py-2 text-sm text-primary outline-none focus:border-brand-primary resize-y"
|
||||||
|
placeholder="Reason for visit..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="text-sm text-error-primary">{error}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer buttons */}
|
||||||
|
<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}>
|
||||||
|
Cancel Appointment
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" color="primary" onClick={handleUpdate} isLoading={saving} isDisabled={!doctor || !date || !timeSlot}>
|
||||||
|
Update Appointment
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cancel confirm modal */}
|
||||||
|
<ModalOverlay
|
||||||
|
isOpen={cancelConfirm}
|
||||||
|
onOpenChange={(open) => { if (!open) setCancelConfirm(false); }}
|
||||||
|
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">Cancel this appointment?</h2>
|
||||||
|
<p className="text-sm text-tertiary">
|
||||||
|
This will mark the appointment as cancelled. The patient will need to book a new appointment.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 justify-end">
|
||||||
|
<Button size="sm" color="secondary" onClick={() => setCancelConfirm(false)}>
|
||||||
|
No, keep it
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" color="primary-destructive" onClick={() => { setCancelConfirm(false); handleCancel(); }} isLoading={saving}>
|
||||||
|
Yes, cancel appointment
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
</Modal>
|
||||||
|
</ModalOverlay>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Page ─────────────────────────────────────────────────────────
|
||||||
|
export const AppointmentsPageV2 = () => {
|
||||||
|
const [appointments, setAppointments] = useState<AppointmentRecord[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [tab, setTab] = useState<StatusTab>('all');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [selectedAppt, setSelectedAppt] = useState<AppointmentRecord | null>(null);
|
||||||
|
const [panelOpen, setPanelOpen] = useState(false);
|
||||||
|
const [rescheduleOpen, setRescheduleOpen] = useState(false);
|
||||||
|
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 counts: Record<string, number> = {};
|
||||||
|
for (const a of appointments) {
|
||||||
|
const s = a.status ?? 'UNKNOWN';
|
||||||
|
counts[s] = (counts[s] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
}, [appointments]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
let rows = appointments;
|
||||||
|
if (tab !== 'all') rows = rows.filter(a => a.status === tab);
|
||||||
|
if (search.trim()) {
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
rows = rows.filter(a => {
|
||||||
|
const name = getPatientName(a).toLowerCase();
|
||||||
|
const phone = getPhone(a);
|
||||||
|
const doctor = (a.doctorName ?? '').toLowerCase();
|
||||||
|
return name.includes(q) || phone.includes(q) || doctor.includes(q);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}, [appointments, tab, search]);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||||
|
const pagedRows = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||||
|
|
||||||
|
useEffect(() => { setPage(1); }, [tab, search]);
|
||||||
|
|
||||||
|
const tabItems = [
|
||||||
|
{ id: 'all' as const, label: 'All', badge: appointments.length > 0 ? String(appointments.length) : undefined },
|
||||||
|
{ id: 'SCHEDULED' as const, label: 'Booked', badge: statusCounts.SCHEDULED ? String(statusCounts.SCHEDULED) : undefined },
|
||||||
|
{ id: 'COMPLETED' as const, label: 'Completed', badge: statusCounts.COMPLETED ? String(statusCounts.COMPLETED) : undefined },
|
||||||
|
{ id: 'CANCELLED' as const, label: 'Cancelled', badge: statusCounts.CANCELLED ? String(statusCounts.CANCELLED) : undefined },
|
||||||
|
{ id: 'RESCHEDULED' as const, label: 'Rescheduled', badge: statusCounts.RESCHEDULED ? String(statusCounts.RESCHEDULED) : undefined },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleEditClick = (appt: AppointmentRecord) => {
|
||||||
|
setSelectedAppt(appt);
|
||||||
|
setPanelOpen(true);
|
||||||
|
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 = () => {
|
||||||
|
setRescheduleOpen(false);
|
||||||
|
setPanelOpen(false);
|
||||||
|
setSelectedAppt(null);
|
||||||
|
fetchAppointments();
|
||||||
|
notify.success('Appointment Rescheduled');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Header with search inline */}
|
||||||
|
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-6 py-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-bold text-primary">Appointments</h1>
|
||||||
|
</div>
|
||||||
|
<div className="w-56">
|
||||||
|
<Input
|
||||||
|
placeholder="Search patient, doctor..."
|
||||||
|
icon={SearchLg}
|
||||||
|
size="sm"
|
||||||
|
value={search}
|
||||||
|
onChange={setSearch}
|
||||||
|
aria-label="Search appointments"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="flex shrink-0 items-end px-6 pt-2 pb-0.5">
|
||||||
|
<Tabs selectedKey={tab} onSelectionChange={(key) => setTab(key as StatusTab)}>
|
||||||
|
<TabList items={tabItems} type="underline" size="sm">
|
||||||
|
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
|
||||||
|
</TabList>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden px-4 pt-3">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<p className="text-sm text-tertiary">Loading appointments...</p>
|
||||||
|
</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<p className="text-sm text-quaternary">{search ? 'No matching appointments' : 'No appointments found'}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table size="sm">
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Head label="" className="w-8" isRowHeader />
|
||||||
|
<Table.Head label="PATIENT" className="min-w-[180px]" />
|
||||||
|
<Table.Head label="DATE & TIME" className="w-28" />
|
||||||
|
<Table.Head label="DOCTOR" className="min-w-[160px]" />
|
||||||
|
<Table.Head label="STATUS" className="w-24" />
|
||||||
|
<Table.Head label="REMIND" className="w-20" />
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body items={pagedRows}>
|
||||||
|
{(appt) => {
|
||||||
|
const name = getPatientName(appt);
|
||||||
|
const phone = getPhone(appt);
|
||||||
|
const statusLabel = STATUS_LABELS[appt.status ?? ''] ?? appt.status ?? '—';
|
||||||
|
const statusColor = STATUS_COLORS[appt.status ?? ''] ?? 'gray';
|
||||||
|
const upcoming = isUpcoming(appt);
|
||||||
|
const isSelected = selectedAppt?.id === appt.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table.Row
|
||||||
|
id={appt.id}
|
||||||
|
className={cx(isSelected && 'bg-brand-primary')}
|
||||||
|
>
|
||||||
|
{/* 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>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-primary truncate">{name}</p>
|
||||||
|
{phone && <p className="text-xs text-tertiary">{formatPhone({ number: phone, callingCode: '+91' })}</p>}
|
||||||
|
</div>
|
||||||
|
</Table.Cell>
|
||||||
|
|
||||||
|
{/* Date & Time: date + time on 2 lines */}
|
||||||
|
<Table.Cell>
|
||||||
|
{appt.scheduledAt ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-primary">{formatDateOnly(appt.scheduledAt)}</p>
|
||||||
|
<p className="text-xs text-tertiary">{formatTimeOnly(appt.scheduledAt)}</p>
|
||||||
|
</div>
|
||||||
|
) : <span className="text-sm text-quaternary">—</span>}
|
||||||
|
</Table.Cell>
|
||||||
|
|
||||||
|
{/* Doctor: name + department on 2 lines */}
|
||||||
|
<Table.Cell>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm text-primary truncate">{appt.doctorName ?? '—'}</p>
|
||||||
|
{appt.department && <p className="text-xs text-tertiary truncate">{appt.department}</p>}
|
||||||
|
</div>
|
||||||
|
</Table.Cell>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<Table.Cell>
|
||||||
|
<Badge size="sm" color={statusColor} type="pill-color">
|
||||||
|
{statusLabel}
|
||||||
|
</Badge>
|
||||||
|
</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.Body>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
<div className="shrink-0">
|
||||||
|
<PaginationCardDefault
|
||||||
|
page={page}
|
||||||
|
total={totalPages}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detail side panel */}
|
||||||
|
<div className={cx(
|
||||||
|
"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 && !rescheduleOpen && (
|
||||||
|
<AppointmentDetailPanel
|
||||||
|
appointment={selectedAppt}
|
||||||
|
onClose={() => { setPanelOpen(false); setSelectedAppt(null); }}
|
||||||
|
onReschedule={() => setRescheduleOpen(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{panelOpen && selectedAppt && rescheduleOpen && (
|
||||||
|
<ReschedulePanel
|
||||||
|
appointment={selectedAppt}
|
||||||
|
onClose={() => setRescheduleOpen(false)}
|
||||||
|
onSaved={handleRescheduleSaved}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -26,13 +26,19 @@ import type { Call, CallDirection, CallDisposition } from '@/types/entities';
|
|||||||
|
|
||||||
type FilterKey = 'all' | 'inbound' | 'outbound' | 'missed';
|
type FilterKey = 'all' | 'inbound' | 'outbound' | 'missed';
|
||||||
|
|
||||||
const filterItems = [
|
const allFilterItems = [
|
||||||
{ id: 'all' as const, label: 'All Calls' },
|
{ id: 'all' as const, label: 'All Calls' },
|
||||||
{ id: 'inbound' as const, label: 'Inbound' },
|
{ id: 'inbound' as const, label: 'Inbound' },
|
||||||
{ id: 'outbound' as const, label: 'Outbound' },
|
{ id: 'outbound' as const, label: 'Outbound' },
|
||||||
{ id: 'missed' as const, label: 'Missed' },
|
{ id: 'missed' as const, label: 'Missed' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const agentFilterItems = [
|
||||||
|
{ id: 'all' as const, label: 'All Calls' },
|
||||||
|
{ id: 'inbound' as const, label: 'Inbound' },
|
||||||
|
{ id: 'outbound' as const, label: 'Outbound' },
|
||||||
|
];
|
||||||
|
|
||||||
const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = {
|
const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = {
|
||||||
APPOINTMENT_BOOKED: { label: 'Appt Booked', color: 'success' },
|
APPOINTMENT_BOOKED: { label: 'Appt Booked', color: 'success' },
|
||||||
APPOINTMENT_RESCHEDULED: { label: 'Appt Rescheduled', color: 'warning' },
|
APPOINTMENT_RESCHEDULED: { label: 'Appt Rescheduled', color: 'warning' },
|
||||||
@@ -188,7 +194,7 @@ export const CallHistoryPage = () => {
|
|||||||
<TableCard.Header
|
<TableCard.Header
|
||||||
title={isAdmin ? 'Call History' : 'My Call History'}
|
title={isAdmin ? 'Call History' : 'My Call History'}
|
||||||
badge={String(filteredCalls.length)}
|
badge={String(filteredCalls.length)}
|
||||||
description={`${completedCount} completed \u00B7 ${missedCount} missed`}
|
description={isAdmin ? `${completedCount} completed \u00B7 ${missedCount} missed` : `${completedCount} completed`}
|
||||||
contentTrailing={
|
contentTrailing={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-44">
|
<div className="w-44">
|
||||||
@@ -197,7 +203,7 @@ export const CallHistoryPage = () => {
|
|||||||
placeholder="All Calls"
|
placeholder="All Calls"
|
||||||
selectedKey={filter}
|
selectedKey={filter}
|
||||||
onSelectionChange={(key) => setFilter(key as FilterKey)}
|
onSelectionChange={(key) => setFilter(key as FilterKey)}
|
||||||
items={filterItems}
|
items={isAdmin ? allFilterItems : agentFilterItems}
|
||||||
aria-label="Filter calls"
|
aria-label="Filter calls"
|
||||||
>
|
>
|
||||||
{(item) => (
|
{(item) => (
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
// useNavigate removed — row click opens profile panel
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faUser, faMagnifyingGlass, faEye, faCommentDots, faMessageDots, faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons';
|
import { faUser, faMagnifyingGlass, faCommentDots, faMessageDots, faEllipsisVertical, faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { faIcon } from '@/lib/icon-wrapper';
|
import { faIcon } from '@/lib/icon-wrapper';
|
||||||
|
|
||||||
const SearchLg = faIcon(faMagnifyingGlass);
|
const SearchLg = faIcon(faMagnifyingGlass);
|
||||||
import { Avatar } from '@/components/base/avatar/avatar';
|
import { Avatar } from '@/components/base/avatar/avatar';
|
||||||
// Button removed — actions are icon-only now
|
|
||||||
import { Input } from '@/components/base/input/input';
|
import { Input } from '@/components/base/input/input';
|
||||||
import { Table, TableCard } from '@/components/application/table/table';
|
import { Table, TableCard } from '@/components/application/table/table';
|
||||||
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
|
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
|
||||||
|
|
||||||
import { ClickToCallButton } from '@/components/call-desk/click-to-call-button';
|
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
||||||
import { PatientProfilePanel } from '@/components/shared/patient-profile-panel';
|
import { PatientProfilePanel } from '@/components/shared/patient-profile-panel';
|
||||||
import { useData } from '@/providers/data-provider';
|
import { useData } from '@/providers/data-provider';
|
||||||
import { getInitials } from '@/lib/format';
|
import { getInitials } from '@/lib/format';
|
||||||
@@ -55,9 +54,52 @@ const getPatientEmail = (patient: Patient): string => {
|
|||||||
return patient.emails?.primaryEmail ?? '';
|
return patient.emails?.primaryEmail ?? '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const HamburgerMenu = ({ phone }: { phone: string }) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handler);
|
||||||
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={ref}>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setOpen(!open); }}
|
||||||
|
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
||||||
|
title="More actions"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faEllipsisVertical} className="size-4" />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute top-full right-0 mt-1 w-40 rounded-xl bg-primary shadow-xl ring-1 ring-secondary z-50 overflow-hidden py-1">
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); window.open(`sms:+91${phone}`, '_self'); setOpen(false); }}
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-primary_hover text-left"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faCommentDots} className="size-3.5 text-fg-quaternary" />
|
||||||
|
Send SMS
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); window.open(`https://wa.me/91${phone}`, '_blank'); setOpen(false); }}
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-primary_hover text-left"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faMessageDots} className="size-3.5 text-fg-quaternary" />
|
||||||
|
WhatsApp
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const PatientsPage = () => {
|
export const PatientsPage = () => {
|
||||||
const { patients, loading } = useData();
|
const { patients, loading } = useData();
|
||||||
const navigate = useNavigate();
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null);
|
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null);
|
||||||
const [panelOpen, setPanelOpen] = useState(false);
|
const [panelOpen, setPanelOpen] = useState(false);
|
||||||
@@ -132,10 +174,11 @@ export const PatientsPage = () => {
|
|||||||
<Table>
|
<Table>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Head label="PATIENT" isRowHeader />
|
<Table.Head label="PATIENT" isRowHeader />
|
||||||
<Table.Head label="CONTACT" />
|
<Table.Head label="PHONE" />
|
||||||
|
<Table.Head label="EMAIL" />
|
||||||
<Table.Head label="GENDER" />
|
<Table.Head label="GENDER" />
|
||||||
<Table.Head label="AGE" />
|
<Table.Head label="AGE" />
|
||||||
<Table.Head label="ACTIONS" />
|
<Table.Head label="" className="w-12" />
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body items={pagedPatients} dependencies={[selectedPatient?.id]}>
|
<Table.Body items={pagedPatients} dependencies={[selectedPatient?.id]}>
|
||||||
{(patient) => {
|
{(patient) => {
|
||||||
@@ -180,18 +223,22 @@ export const PatientsPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
|
||||||
{/* Contact */}
|
{/* Phone — clickable to dial */}
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<div className="flex flex-col">
|
|
||||||
{phone ? (
|
{phone ? (
|
||||||
<span className="text-sm text-secondary">{phone}</span>
|
<PhoneActionCell phoneNumber={phone} displayNumber={phone} />
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm text-placeholder">No phone</span>
|
<span className="text-sm text-placeholder">No phone</span>
|
||||||
)}
|
)}
|
||||||
|
</Table.Cell>
|
||||||
|
|
||||||
|
{/* Email */}
|
||||||
|
<Table.Cell>
|
||||||
{email ? (
|
{email ? (
|
||||||
<span className="text-xs text-tertiary truncate max-w-[200px]">{email}</span>
|
<span className="text-sm text-tertiary truncate max-w-[200px] block">{email}</span>
|
||||||
) : null}
|
) : (
|
||||||
</div>
|
<span className="text-sm text-quaternary">—</span>
|
||||||
|
)}
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
|
||||||
{/* Gender */}
|
{/* Gender */}
|
||||||
@@ -208,40 +255,11 @@ export const PatientsPage = () => {
|
|||||||
</span>
|
</span>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Hamburger — SMS + WhatsApp */}
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<div className="flex items-center gap-1">
|
{phone ? (
|
||||||
{phone && (
|
<HamburgerMenu phone={phone} />
|
||||||
<>
|
) : null}
|
||||||
<ClickToCallButton
|
|
||||||
phoneNumber={phone}
|
|
||||||
size="sm"
|
|
||||||
label=""
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => window.open(`sms:+91${phone}`, '_self')}
|
|
||||||
title="SMS"
|
|
||||||
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-brand-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faCommentDots} className="size-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => window.open(`https://wa.me/91${phone}`, '_blank')}
|
|
||||||
title="WhatsApp"
|
|
||||||
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-[#25D366] hover:bg-primary_hover transition duration-100 ease-linear"
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faMessageDots} className="size-4" />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => navigate(`/patient/${patient.id}`)}
|
|
||||||
title="View patient"
|
|
||||||
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faEye} className="size-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user