2 Commits

Author SHA1 Message Date
c044d2d143 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>
2026-04-12 13:31:56 +05:30
85364c6d69 docs: add requirements tracker and Ozonetel CDR API reference
- requirements.md: full 16-user-story tracker with verified implementation
  status, code references, Ozonetel API findings, platform capability notes,
  and implementation guides for search (includeInSearch), barge/whisper, and
  appointment notifications
- ozonetel-cdr-api-reference.md: all 42 CDR fields, 3 endpoints (detailed,
  UCID, paginated), sidecar mapping status, known gotchas (nullable fields,
  field name inconsistency, rate limits)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 12:53:33 +05:30
6 changed files with 1427 additions and 22 deletions

View File

@@ -0,0 +1,102 @@
# Ozonetel CDR API Reference
> Source: [Ozonetel docs](https://docs.ozonetel.com/reference/get_ca-reports-fetchcdrdetails)
## Endpoints
| Endpoint | Path | Use Case |
|----------|------|----------|
| Fetch CDR Detailed | `GET /ca_reports/fetchCDRDetails` | All CDR for a single day |
| Fetch CDR by UCID | `GET /ca_reports/fetchCdrByUCID` | Single call lookup by UCID |
| Fetch CDR Paginated | `GET /ca_reports/fetchCdrByPagination` | Paginated CDR with `totalCount` |
## Common Constraints
- **Auth**: Bearer token (via `POST /ca_apis/caToken/generateToken`)
- **Rate limit**: 2 requests per minute (all CDR endpoints)
- **Date range**: Single day only (`fromDate` and `toDate` must be same date)
- **Lookback**: 15 days maximum from time of request
- **Mandatory params**: `fromDate`, `toDate`, `userName` (+ `ucid` for UCID endpoint)
- **Date format**: `YYYY-MM-DD HH:MM:SS`
## Domain
- Domestic: `in1-ccaas-api.ozonetel.com`
- International: `api.ccaas.ozonetel.com`
## CDR Record Fields (42 fields)
| Field | Type | Description | Sidecar Status |
|-------|------|-------------|----------------|
| `AgentDialStatus` | string | Agent's dial attempt status (e.g., "answered") | Not mapped |
| `AgentID` | string | Agent identifier | **Mapped** — filter CDR by agent |
| `AgentName` | string | Agent name | **Mapped** — fallback filter |
| `CallAudio` | string | URL to call recording (S3) | Not mapped (recording via platform) |
| `CallDate` | string | Date of call (YYYY-MM-DD) | Not mapped |
| `CallID` | number | Unique call identifier | Not mapped |
| `CallerConfAudioFile` | string | Conference audio file | Not mapped |
| `CallerID` | string | Caller's phone number | Not mapped |
| `CampaignName` | string | Associated campaign name | Not mapped — **available for US-15** |
| `Comments` | string | Additional comments | Not mapped |
| `ConferenceDuration` | string | Conference duration (HH:MM:SS) | Not mapped |
| `CustomerDialStatus` | string | Customer dial status | Not mapped |
| `CustomerRingTime` | string | Customer phone ring time | Not mapped — **missed call analysis** |
| `DID` | string | Direct inward dial number | Not mapped — **available for US-2 branch display** |
| `DialOutName` | string | Dialed party name | Not mapped |
| `DialStatus` | string | Overall dial status | Not mapped |
| `DialedNumber` | string | Phone number dialed | Not mapped |
| `Disposition` | string | Call disposition/outcome | **Mapped** — disposition breakdown |
| `Duration` | string | Total call duration | Not mapped |
| `DynamicDID` | string | Dynamic DID reference | Not mapped |
| `E164` | string | E.164 formatted phone number | Not mapped |
| `EndTime` | string | Call end time | Not mapped |
| `Event` | string | Event type (e.g., "AgentDial") | Not mapped |
| `HandlingTime` | string/null | Total handling time — **CAN BE NULL** | Not mapped — **available for US-13 avg handling** |
| `HangupBy` | string | Who terminated call | Not mapped |
| `HoldDuration` | string | Time on hold | Not mapped — **available for US-12** |
| `Location` | string | Caller location | Not mapped |
| `PickupTime` | string | When call was answered | Not mapped |
| `Rating` | number | Call quality rating | Not mapped |
| `RatingComments` | string | Rating comments | Not mapped |
| `Skill` | string | Agent skill/queue name | Not mapped |
| `StartTime` | string | Call start time | Not mapped |
| `Status` | string | Call status (Answered/NotAnswered) | **Mapped** — inbound/missed split |
| `TalkTime` | string | Active talk duration | **Mapped** — avg talk time calc |
| `TimeToAnswer` | string | Duration until answer | Not mapped — **available for lead response KPI** |
| `TransferType` | string | Type of transfer | Not mapped — **available for US-3 audit** |
| `TransferredTo` / `TransferTo` | string | Transfer target — **field name varies by endpoint** | Not mapped |
| `Type` | string | Call type (InBound/Manual/Progressive) | **Mapped** — inbound/outbound split |
| `UCID` | number | Unique call identifier | Not mapped |
| `UUI` | string | User-to-user information | Not mapped |
| `WrapUpEndTime` | string/null | Wrapup completion time — **CAN BE NULL** | Not mapped |
| `WrapUpStartTime` | string/null | Wrapup start time — **CAN BE NULL** | Not mapped |
| `WrapupDuration` | string/null | Wrapup duration — **CAN BE NULL** | Not mapped — **available for US-12** |
## Pagination Endpoint Extra Fields
| Field | Description |
|-------|-------------|
| `totalCount` | Total number of records matching the query |
## Known Issues / Gotchas
1. **`HandlingTime`, `WrapupDuration`, `WrapUpStartTime`, `WrapUpEndTime` can be `null`** — when agent didn't complete wrapup (seen in UCID endpoint example). Code must null-guard these.
2. **Field name inconsistency**: `TransferredTo` in fetchCDRDetails vs `TransferTo` in pagination endpoint. Handle both.
3. **`WrapUpEndTime` vs `WrapupEndTime`**: casing differs between endpoints (camelCase vs mixed). Handle both.
4. **Single-day constraint**: `fromDate` and `toDate` must be the same date. For multi-day range, call once per day.
5. **Rate limit 2 req/min**: For a 7-day weekly report that needs CDR + summary per day = 14 API calls = 7 minutes minimum. Consider caching daily results.
## Current Sidecar Usage
**Endpoint used**: `fetchCDRDetails` only (in `ozonetel-agent.service.ts`)
**Fields actively mapped** (6 of 42):
- `AgentID` / `AgentName` — agent filtering
- `Type` — inbound/outbound split
- `Status` — answered/missed split
- `TalkTime` — avg talk time calculation
- `Disposition` — disposition breakdown chart
**Not yet used**:
- `fetchCdrByUCID` — useful for Patient 360 single-call drill-down
- `fetchCdrByPagination` — useful for high-volume days (current approach loads all records into memory)

1219
docs/requirements.md Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -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(() => {});
}
}}
/>
</> </>
); );
}; };