feat: quick wins — global search, P360 actions, context panel, route guards
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:
2026-04-12 13:31:56 +05:30
parent 85364c6d69
commit c044d2d143
4 changed files with 106 additions and 22 deletions

View File

@@ -15,8 +15,10 @@ import { Avatar } from '@/components/base/avatar/avatar';
import { Badge } from '@/components/base/badges/badges';
import { Button } from '@/components/base/buttons/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 { formatShortDate, getInitials } from '@/lib/format';
import { notify } from '@/lib/toast';
import { cx } from '@/utils/cx';
import type { LeadActivity, LeadActivityType, Call, CallDisposition } from '@/types/entities';
@@ -96,15 +98,16 @@ type PatientData = {
};
// 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 statusColors: Record<string, 'success' | 'brand' | 'warning' | 'error' | 'gray'> = {
COMPLETED: 'success', SCHEDULED: 'brand', CONFIRMED: 'brand',
CANCELLED: 'error', NO_SHOW: 'warning', RESCHEDULED: 'warning',
};
const canEdit = appt.status !== 'COMPLETED' && appt.status !== 'CANCELLED' && appt.status !== 'NO_SHOW';
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">
<CalendarCheck className="size-4 text-fg-white" />
</div>
@@ -266,6 +269,9 @@ export const Patient360Page = () => {
const { id } = useParams<{ id: string }>();
const [activeTab, setActiveTab] = useState<string>('appointments');
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 [loading, setLoading] = useState(true);
const [activities, setActivities] = useState<LeadActivity[]>([]);
@@ -383,6 +389,11 @@ export const Patient360Page = () => {
{leadInfo.source.replace(/_/g, ' ')}
</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>
@@ -423,7 +434,7 @@ export const Patient360Page = () => {
{phoneRaw && (
<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
</Button>
<Button size="sm" color="secondary" iconLeading={MessageTextSquare01}>
@@ -472,7 +483,7 @@ export const Patient360Page = () => {
) : (
<div className="rounded-xl border border-secondary bg-primary">
{appointments.map((appt: any) => (
<AppointmentRow key={appt.id} appt={appt} />
<AppointmentRow key={appt.id} appt={appt} onEdit={(a) => { setEditingAppt(a); setApptFormOpen(true); }} />
))}
</div>
)}
@@ -538,7 +549,25 @@ export const Patient360Page = () => {
size="sm"
color="primary"
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
</Button>
@@ -563,6 +592,33 @@ export const Patient360Page = () => {
</Tabs>
</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(() => {});
}
}}
/>
</>
);
};