mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-14 20:12:25 +00:00
feat: quick wins — global search, P360 actions, context panel, route guards
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- Wire GlobalSearch component into app shell top bar (US-10) - P360: Book Appointment button opens AppointmentForm (US-8) - P360: Add Note button creates leadActivity via GraphQL (US-8) - P360: Appointment rows clickable for edit (active statuses only) (US-8) - P360: Display lead status badge (was fetched but not rendered) (US-8) - Context panel: "View 360" link on linked patient → /patient/:id (US-6) - Context panel: Display campaign info from lead.utmCampaign (US-6) - Route guards: Admin-only routes wrapped in RequireAdmin (US-1, US-3) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useCallback, useMemo } from 'react';
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import {
|
import {
|
||||||
faSparkles, faPhone, faChevronDown, faChevronUp,
|
faSparkles, faPhone, faChevronDown, faChevronUp,
|
||||||
@@ -58,6 +59,7 @@ const SectionHeader = ({ icon, label, count, expanded, onToggle }: {
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const ContextPanel = ({ selectedLead, activities, calls, followUps, appointments, patients, callerPhone, isInCall }: ContextPanelProps) => {
|
export const ContextPanel = ({ selectedLead, activities, calls, followUps, appointments, patients, callerPhone, isInCall }: ContextPanelProps) => {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [contextExpanded, setContextExpanded] = useState(true);
|
const [contextExpanded, setContextExpanded] = useState(true);
|
||||||
const [insightExpanded, setInsightExpanded] = useState(true);
|
const [insightExpanded, setInsightExpanded] = useState(true);
|
||||||
const [actionsExpanded, setActionsExpanded] = useState(true);
|
const [actionsExpanded, setActionsExpanded] = useState(true);
|
||||||
@@ -163,6 +165,16 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Campaign info */}
|
||||||
|
{(lead.utmCampaign || lead.campaignId) && (
|
||||||
|
<div className="flex items-center gap-1.5 px-1 py-1">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-tertiary">Campaign</span>
|
||||||
|
<Badge size="sm" color="brand" type="pill-color">
|
||||||
|
{lead.utmCampaign ?? lead.campaignId}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Quick Actions — upcoming appointments + follow-ups + linked patient */}
|
{/* Quick Actions — upcoming appointments + follow-ups + linked patient */}
|
||||||
{(leadAppointments.length > 0 || leadFollowUps.length > 0 || linkedPatient) && (
|
{(leadAppointments.length > 0 || leadFollowUps.length > 0 || linkedPatient) && (
|
||||||
<div>
|
<div>
|
||||||
@@ -223,6 +235,12 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
|
|||||||
{linkedPatient.patientType && (
|
{linkedPatient.patientType && (
|
||||||
<Badge size="sm" color="gray" type="pill-color" className="ml-auto">{linkedPatient.patientType}</Badge>
|
<Badge size="sm" color="gray" type="pill-color" className="ml-auto">{linkedPatient.patientType}</Badge>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/patient/${linkedPatient.id}`)}
|
||||||
|
className="text-[11px] font-medium text-brand-secondary hover:text-brand-secondary_hover shrink-0"
|
||||||
|
>
|
||||||
|
View 360
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useAuth } from '@/providers/auth-provider';
|
|||||||
import { useData } from '@/providers/data-provider';
|
import { useData } from '@/providers/data-provider';
|
||||||
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
|
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
|
||||||
import { useNetworkStatus } from '@/hooks/use-network-status';
|
import { useNetworkStatus } from '@/hooks/use-network-status';
|
||||||
|
import { GlobalSearch } from '@/components/shared/global-search';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { cx } from '@/utils/cx';
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
@@ -119,7 +120,9 @@ export const AppShell = ({ children }: AppShellProps) => {
|
|||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
{/* Persistent top bar — visible on all pages */}
|
{/* Persistent top bar — visible on all pages */}
|
||||||
{(hasAgentConfig || isAdmin) && (
|
{(hasAgentConfig || isAdmin) && (
|
||||||
<div className="flex shrink-0 items-center justify-end gap-2 border-b border-secondary px-4 py-2">
|
<div className="flex shrink-0 items-center gap-2 border-b border-secondary px-4 py-2">
|
||||||
|
<GlobalSearch />
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
{isAdmin && <NotificationBell />}
|
{isAdmin && <NotificationBell />}
|
||||||
{hasAgentConfig && (
|
{hasAgentConfig && (
|
||||||
<>
|
<>
|
||||||
@@ -141,6 +144,7 @@ export const AppShell = ({ children }: AppShellProps) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<ResumeSetupBanner />
|
<ResumeSetupBanner />
|
||||||
<main className="flex flex-1 flex-col overflow-hidden">{children}</main>
|
<main className="flex flex-1 flex-col overflow-hidden">{children}</main>
|
||||||
|
|||||||
10
src/main.tsx
10
src/main.tsx
@@ -10,6 +10,11 @@ const AdminSetupGuard = () => {
|
|||||||
const { isAdmin } = useAuth();
|
const { isAdmin } = useAuth();
|
||||||
return isAdmin ? <SetupWizardPage /> : <Navigate to="/" replace />;
|
return isAdmin ? <SetupWizardPage /> : <Navigate to="/" replace />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const RequireAdmin = () => {
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
return isAdmin ? <Outlet /> : <Navigate to="/" replace />;
|
||||||
|
};
|
||||||
import { RoleRouter } from "@/components/layout/role-router";
|
import { RoleRouter } from "@/components/layout/role-router";
|
||||||
import { NotFound } from "@/pages/not-found";
|
import { NotFound } from "@/pages/not-found";
|
||||||
import { AllLeadsPage } from "@/pages/all-leads";
|
import { AllLeadsPage } from "@/pages/all-leads";
|
||||||
@@ -85,6 +90,8 @@ createRoot(document.getElementById("root")!).render(
|
|||||||
<Route path="/call-desk" element={<CallDeskPage />} />
|
<Route path="/call-desk" element={<CallDeskPage />} />
|
||||||
<Route path="/patients" element={<PatientsPage />} />
|
<Route path="/patients" element={<PatientsPage />} />
|
||||||
<Route path="/appointments" element={<AppointmentsPage />} />
|
<Route path="/appointments" element={<AppointmentsPage />} />
|
||||||
|
{/* Admin-only routes */}
|
||||||
|
<Route element={<RequireAdmin />}>
|
||||||
<Route path="/team-performance" element={<TeamPerformancePage />} />
|
<Route path="/team-performance" element={<TeamPerformancePage />} />
|
||||||
<Route path="/live-monitor" element={<LiveMonitorPage />} />
|
<Route path="/live-monitor" element={<LiveMonitorPage />} />
|
||||||
<Route path="/call-recordings" element={<CallRecordingsPage />} />
|
<Route path="/call-recordings" element={<CallRecordingsPage />} />
|
||||||
@@ -92,8 +99,6 @@ createRoot(document.getElementById("root")!).render(
|
|||||||
<Route path="/team-dashboard" element={<TeamDashboardPage />} />
|
<Route path="/team-dashboard" element={<TeamDashboardPage />} />
|
||||||
<Route path="/reports" element={<ReportsPage />} />
|
<Route path="/reports" element={<ReportsPage />} />
|
||||||
<Route path="/integrations" element={<IntegrationsPage />} />
|
<Route path="/integrations" element={<IntegrationsPage />} />
|
||||||
|
|
||||||
{/* Settings hub + section pages */}
|
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/settings/team" element={<TeamSettingsPage />} />
|
<Route path="/settings/team" element={<TeamSettingsPage />} />
|
||||||
<Route path="/settings/clinics" element={<ClinicsPage />} />
|
<Route path="/settings/clinics" element={<ClinicsPage />} />
|
||||||
@@ -101,6 +106,7 @@ createRoot(document.getElementById("root")!).render(
|
|||||||
<Route path="/settings/telephony" element={<TelephonySettingsPage />} />
|
<Route path="/settings/telephony" element={<TelephonySettingsPage />} />
|
||||||
<Route path="/settings/ai" element={<AiSettingsPage />} />
|
<Route path="/settings/ai" element={<AiSettingsPage />} />
|
||||||
<Route path="/settings/widget" element={<WidgetSettingsPage />} />
|
<Route path="/settings/widget" element={<WidgetSettingsPage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
<Route path="/agent/:id" element={<AgentDetailPage />} />
|
<Route path="/agent/:id" element={<AgentDetailPage />} />
|
||||||
<Route path="/patient/:id" element={<Patient360Page />} />
|
<Route path="/patient/:id" element={<Patient360Page />} />
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ import { Avatar } from '@/components/base/avatar/avatar';
|
|||||||
import { Badge } from '@/components/base/badges/badges';
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
import { Button } from '@/components/base/buttons/button';
|
import { Button } from '@/components/base/buttons/button';
|
||||||
import { ClickToCallButton } from '@/components/call-desk/click-to-call-button';
|
import { ClickToCallButton } from '@/components/call-desk/click-to-call-button';
|
||||||
|
import { AppointmentForm } from '@/components/call-desk/appointment-form';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { formatShortDate, getInitials } from '@/lib/format';
|
import { formatShortDate, getInitials } from '@/lib/format';
|
||||||
|
import { notify } from '@/lib/toast';
|
||||||
import { cx } from '@/utils/cx';
|
import { cx } from '@/utils/cx';
|
||||||
import type { LeadActivity, LeadActivityType, Call, CallDisposition } from '@/types/entities';
|
import type { LeadActivity, LeadActivityType, Call, CallDisposition } from '@/types/entities';
|
||||||
|
|
||||||
@@ -96,15 +98,16 @@ type PatientData = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Appointment row component
|
// Appointment row component
|
||||||
const AppointmentRow = ({ appt }: { appt: any }) => {
|
const AppointmentRow = ({ appt, onEdit }: { appt: any; onEdit?: (appt: any) => void }) => {
|
||||||
const scheduledAt = appt.scheduledAt ? formatShortDate(appt.scheduledAt) : '--';
|
const scheduledAt = appt.scheduledAt ? formatShortDate(appt.scheduledAt) : '--';
|
||||||
const statusColors: Record<string, 'success' | 'brand' | 'warning' | 'error' | 'gray'> = {
|
const statusColors: Record<string, 'success' | 'brand' | 'warning' | 'error' | 'gray'> = {
|
||||||
COMPLETED: 'success', SCHEDULED: 'brand', CONFIRMED: 'brand',
|
COMPLETED: 'success', SCHEDULED: 'brand', CONFIRMED: 'brand',
|
||||||
CANCELLED: 'error', NO_SHOW: 'warning', RESCHEDULED: 'warning',
|
CANCELLED: 'error', NO_SHOW: 'warning', RESCHEDULED: 'warning',
|
||||||
};
|
};
|
||||||
|
const canEdit = appt.status !== 'COMPLETED' && appt.status !== 'CANCELLED' && appt.status !== 'NO_SHOW';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-4 border-b border-secondary px-4 py-3 last:border-b-0">
|
<div className={cx('flex items-center gap-4 border-b border-secondary px-4 py-3 last:border-b-0', canEdit && onEdit && 'cursor-pointer hover:bg-primary_hover transition duration-100 ease-linear')} onClick={() => canEdit && onEdit?.(appt)}>
|
||||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-brand-secondary">
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-full bg-brand-secondary">
|
||||||
<CalendarCheck className="size-4 text-fg-white" />
|
<CalendarCheck className="size-4 text-fg-white" />
|
||||||
</div>
|
</div>
|
||||||
@@ -266,6 +269,9 @@ export const Patient360Page = () => {
|
|||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const [activeTab, setActiveTab] = useState<string>('appointments');
|
const [activeTab, setActiveTab] = useState<string>('appointments');
|
||||||
const [noteText, setNoteText] = useState('');
|
const [noteText, setNoteText] = useState('');
|
||||||
|
const [noteSaving, setNoteSaving] = useState(false);
|
||||||
|
const [apptFormOpen, setApptFormOpen] = useState(false);
|
||||||
|
const [editingAppt, setEditingAppt] = useState<any>(null);
|
||||||
const [patient, setPatient] = useState<PatientData | null>(null);
|
const [patient, setPatient] = useState<PatientData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [activities, setActivities] = useState<LeadActivity[]>([]);
|
const [activities, setActivities] = useState<LeadActivity[]>([]);
|
||||||
@@ -383,6 +389,11 @@ export const Patient360Page = () => {
|
|||||||
{leadInfo.source.replace(/_/g, ' ')}
|
{leadInfo.source.replace(/_/g, ' ')}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{leadInfo?.status && (
|
||||||
|
<Badge size="sm" type="pill-color" color={leadInfo.status === 'CONVERTED' ? 'success' : leadInfo.status === 'NEW' ? 'brand' : 'gray'}>
|
||||||
|
{leadInfo.status.replace(/_/g, ' ')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -423,7 +434,7 @@ export const Patient360Page = () => {
|
|||||||
{phoneRaw && (
|
{phoneRaw && (
|
||||||
<ClickToCallButton phoneNumber={phoneRaw} label="Call" size="sm" />
|
<ClickToCallButton phoneNumber={phoneRaw} label="Call" size="sm" />
|
||||||
)}
|
)}
|
||||||
<Button size="sm" color="secondary" iconLeading={Calendar}>
|
<Button size="sm" color="secondary" iconLeading={Calendar} onClick={() => { setEditingAppt(null); setApptFormOpen(true); }}>
|
||||||
Book Appointment
|
Book Appointment
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" color="secondary" iconLeading={MessageTextSquare01}>
|
<Button size="sm" color="secondary" iconLeading={MessageTextSquare01}>
|
||||||
@@ -472,7 +483,7 @@ export const Patient360Page = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="rounded-xl border border-secondary bg-primary">
|
<div className="rounded-xl border border-secondary bg-primary">
|
||||||
{appointments.map((appt: any) => (
|
{appointments.map((appt: any) => (
|
||||||
<AppointmentRow key={appt.id} appt={appt} />
|
<AppointmentRow key={appt.id} appt={appt} onEdit={(a) => { setEditingAppt(a); setApptFormOpen(true); }} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -538,7 +549,25 @@ export const Patient360Page = () => {
|
|||||||
size="sm"
|
size="sm"
|
||||||
color="primary"
|
color="primary"
|
||||||
iconLeading={Plus}
|
iconLeading={Plus}
|
||||||
isDisabled={noteText.trim() === ''}
|
isDisabled={noteText.trim() === '' || noteSaving}
|
||||||
|
isLoading={noteSaving}
|
||||||
|
onClick={async () => {
|
||||||
|
if (!noteText.trim() || !leadInfo?.id) return;
|
||||||
|
setNoteSaving(true);
|
||||||
|
try {
|
||||||
|
await apiClient.graphql(
|
||||||
|
`mutation($data: LeadActivityCreateInput!) { createLeadActivity(data: $data) { id } }`,
|
||||||
|
{ data: { name: `Note — ${fullName}`, activityType: 'NOTE_ADDED', summary: noteText.trim(), occurredAt: new Date().toISOString(), leadId: leadInfo.id } },
|
||||||
|
);
|
||||||
|
setActivities(prev => [{ id: crypto.randomUUID(), activityType: 'NOTE_ADDED' as LeadActivityType, summary: noteText.trim(), occurredAt: new Date().toISOString(), performedBy: null, previousValue: null, newValue: noteText.trim(), leadId: leadInfo.id }, ...prev]);
|
||||||
|
setNoteText('');
|
||||||
|
notify.success('Note Added');
|
||||||
|
} catch {
|
||||||
|
notify.error('Failed', 'Could not save note');
|
||||||
|
} finally {
|
||||||
|
setNoteSaving(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
Add Note
|
Add Note
|
||||||
</Button>
|
</Button>
|
||||||
@@ -563,6 +592,33 @@ export const Patient360Page = () => {
|
|||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AppointmentForm
|
||||||
|
isOpen={apptFormOpen}
|
||||||
|
onOpenChange={setApptFormOpen}
|
||||||
|
callerNumber={phoneRaw || null}
|
||||||
|
leadName={fullName !== 'Unknown Patient' ? fullName : null}
|
||||||
|
leadId={leadInfo?.id ?? null}
|
||||||
|
patientId={id ?? null}
|
||||||
|
existingAppointment={editingAppt ? {
|
||||||
|
id: editingAppt.id,
|
||||||
|
scheduledAt: editingAppt.scheduledAt,
|
||||||
|
doctorName: editingAppt.doctorName ?? '',
|
||||||
|
department: editingAppt.department ?? '',
|
||||||
|
reasonForVisit: editingAppt.reasonForVisit,
|
||||||
|
status: editingAppt.status,
|
||||||
|
} : null}
|
||||||
|
onSaved={() => {
|
||||||
|
setApptFormOpen(false);
|
||||||
|
setEditingAppt(null);
|
||||||
|
// Refresh patient data
|
||||||
|
if (id) {
|
||||||
|
apiClient.graphql<{ patients: { edges: Array<{ node: PatientData }> } }>(
|
||||||
|
PATIENT_QUERY, { id }, { silent: true },
|
||||||
|
).then(data => setPatient(data.patients.edges[0]?.node ?? null)).catch(() => {});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user