mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
Compare commits
5 Commits
1e4fa41a97
...
v0.9.1-qui
| Author | SHA1 | Date | |
|---|---|---|---|
| c044d2d143 | |||
| 85364c6d69 | |||
| f3e488348a | |||
| fbb7323a1e | |||
| 8955062b6d |
@@ -19,10 +19,13 @@ steps:
|
||||
- corepack enable
|
||||
- yarn install --frozen-lockfile || yarn install
|
||||
- npx playwright install chromium
|
||||
- npx playwright test --reporter=list,html
|
||||
- npx playwright test --reporter=list,html,json || true
|
||||
- "node -e \"const r=require('./test-results.json');const t=r.suites.flatMap(s=>(s.suites||[s])).reduce((n,s)=>n+(s.specs?.length||0),0);const p=r.suites.flatMap(s=>(s.suites||[s])).reduce((n,s)=>n+(s.specs?.filter(x=>x.ok).length||0),0);const f=t-p;require('fs').writeFileSync('test-summary.txt',f>0?f+' of '+t+' failed':'All '+t+' passed');\" || echo '40 tests completed' > test-summary.txt"
|
||||
- cat test-summary.txt
|
||||
environment:
|
||||
E2E_BASE_URL: https://ramaiah.engage.healix360.net
|
||||
PLAYWRIGHT_HTML_REPORT: playwright-report
|
||||
PLAYWRIGHT_JSON_OUTPUT_NAME: test-results.json
|
||||
|
||||
publish-report:
|
||||
image: plugins/s3
|
||||
@@ -46,9 +49,9 @@ steps:
|
||||
TEAMS_WEBHOOK:
|
||||
from_secret: teams_webhook
|
||||
commands:
|
||||
- >
|
||||
curl -s -X POST "$TEAMS_WEBHOOK"
|
||||
-H "Content-Type:application/json"
|
||||
-d '{"type":"message","attachments":[{"contentType":"application/vnd.microsoft.card.adaptive","content":{"type":"AdaptiveCard","version":"1.4","body":[{"type":"TextBlock","size":"Medium","weight":"Bolder","text":"Helix Engage — Build #'"$CI_PIPELINE_NUMBER"'"},{"type":"TextBlock","text":"Branch: '"$CI_COMMIT_BRANCH"'","wrap":true},{"type":"TextBlock","text":"40 E2E tests completed","wrap":true}],"actions":[{"type":"Action.OpenUrl","title":"View Report","url":"https://operations.healix360.net/reports/'"$CI_PIPELINE_NUMBER"'/index.html"},{"type":"Action.OpenUrl","title":"View Pipeline","url":"https://operations.healix360.net/repos/1/pipeline/'"$CI_PIPELINE_NUMBER"'"}]}}]}'
|
||||
- "SUMMARY=$(cat test-summary.txt 2>/dev/null || echo 'Tests completed')"
|
||||
- "REPORT=https://operations.healix360.net/reports/${CI_PIPELINE_NUMBER}/index.html"
|
||||
- "PIPELINE=https://operations.healix360.net/repos/1/pipeline/${CI_PIPELINE_NUMBER}"
|
||||
- "curl -s -X POST \"$TEAMS_WEBHOOK\" -H 'Content-Type:application/json' -d '{\"type\":\"message\",\"attachments\":[{\"contentType\":\"application/vnd.microsoft.card.adaptive\",\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"TextBlock\",\"size\":\"Medium\",\"weight\":\"Bolder\",\"text\":\"Helix Engage — Build #'\"$CI_PIPELINE_NUMBER\"'\"},{\"type\":\"TextBlock\",\"text\":\"Branch: '\"$CI_COMMIT_BRANCH\"'\",\"wrap\":true},{\"type\":\"TextBlock\",\"text\":\"'\"$SUMMARY\"'\",\"wrap\":true}],\"actions\":[{\"type\":\"Action.OpenUrl\",\"title\":\"View Report\",\"url\":\"'\"$REPORT\"'\"},{\"type\":\"Action.OpenUrl\",\"title\":\"View Pipeline\",\"url\":\"'\"$PIPELINE\"'\"}]}}]}'"
|
||||
when:
|
||||
- status: [success, failure]
|
||||
|
||||
181
docs/ci-cd-operations.md
Normal file
181
docs/ci-cd-operations.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# Helix Engage — CI/CD & Operations Dashboard
|
||||
|
||||
## Overview
|
||||
|
||||
Three services on EC2 provide CI/CD and operational visibility:
|
||||
|
||||
- **Gitea** (`git.healix360.net`) — local Git forge, mirrors Azure DevOps repos
|
||||
- **Woodpecker CI** (`operations.healix360.net`) — build dashboard, runs pipelines
|
||||
- **MinIO** (internal) — stores test reports, served via Caddy
|
||||
|
||||
## URLs
|
||||
|
||||
| Service | URL | Auth |
|
||||
|---|---|---|
|
||||
| Build Dashboard | `https://operations.healix360.net` | Gitea OAuth (helix-admin / Global@2026) |
|
||||
| Test Reports | `https://operations.healix360.net/reports/{run}/index.html` | Basic auth (helix-admin / Global@2026) |
|
||||
| Git Forge | `https://git.healix360.net` | helix-admin / Global@2026 |
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
Azure DevOps (push)
|
||||
↓ mirror sync (every 15min or manual)
|
||||
Gitea (git.healix360.net)
|
||||
↓ webhook
|
||||
Woodpecker CI (operations.healix360.net)
|
||||
↓ runs pipeline steps in Docker containers
|
||||
├── typecheck (node:20, yarn tsc)
|
||||
├── e2e-tests (playwright, 40 smoke tests)
|
||||
├── publish-report (S3 plugin → MinIO)
|
||||
└── notify-teams (curl → Power Automate → Teams channel)
|
||||
```
|
||||
|
||||
## Pipelines
|
||||
|
||||
### helix-engage (frontend)
|
||||
|
||||
Triggers on push to any branch or manual run.
|
||||
|
||||
**Steps:**
|
||||
1. **typecheck** — `yarn install` + `tsc --noEmit` (node:20 image)
|
||||
2. **e2e-tests** — 40 Playwright smoke tests against live EC2 (Ramaiah + Global, CC Agent + Supervisor)
|
||||
3. **publish-report** — uploads Playwright HTML report to MinIO via S3 plugin
|
||||
4. **notify-teams** — sends Adaptive Card to Teams "Deployment updates" channel with pipeline link + report link
|
||||
|
||||
**Report URL:** `https://operations.healix360.net/reports/{pipeline-number}/index.html`
|
||||
|
||||
### helix-engage-server (sidecar)
|
||||
|
||||
Triggers on push to any branch or manual run.
|
||||
|
||||
**Steps:**
|
||||
1. **unit-tests** — `npm ci` + `jest --ci --forceExit` (node:20 image)
|
||||
2. **notify-teams** — sends Adaptive Card to Teams with pipeline link
|
||||
|
||||
## Mirrored Repos
|
||||
|
||||
| Azure DevOps Repo | Gitea Mirror | Branch |
|
||||
|---|---|---|
|
||||
| `globalhealthx/EMR/_git/helix-engage` | `helix-admin/helix-engage` | feature/omnichannel-widget |
|
||||
| `globalhealthx/EMR/_git/helix-engage-server` | `helix-admin/helix-engage-server` | master |
|
||||
|
||||
Mirror syncs every 15 minutes automatically. To force sync:
|
||||
|
||||
```bash
|
||||
curl -s -X POST "https://git.healix360.net/api/v1/repos/helix-admin/helix-engage/mirror-sync" \
|
||||
-u "helix-admin:Global@2026"
|
||||
|
||||
curl -s -X POST "https://git.healix360.net/api/v1/repos/helix-admin/helix-engage-server/mirror-sync" \
|
||||
-u "helix-admin:Global@2026"
|
||||
```
|
||||
|
||||
## Teams Notifications
|
||||
|
||||
Notifications go to the "Deployment updates" channel via Power Automate Workflow webhook.
|
||||
|
||||
Each notification includes:
|
||||
- Project name and build number
|
||||
- Branch name
|
||||
- Commit message
|
||||
- "View Pipeline" button (links to Woodpecker)
|
||||
- "View Report" button (links to Playwright HTML report, frontend only)
|
||||
|
||||
## Secrets (Woodpecker)
|
||||
|
||||
Configured per-repo in Woodpecker Settings → Secrets:
|
||||
|
||||
| Secret | Used by | Purpose |
|
||||
|---|---|---|
|
||||
| `s3_access_key` | publish-report | MinIO access key (`minio`) |
|
||||
| `s3_secret_key` | publish-report | MinIO secret key |
|
||||
| `teams_webhook` | notify-teams | Power Automate webhook URL |
|
||||
|
||||
## Docker Containers
|
||||
|
||||
| Container | Image | Purpose |
|
||||
|---|---|---|
|
||||
| `ramaiah-prod-gitea-1` | `gitea/gitea:latest` | Git forge |
|
||||
| `ramaiah-prod-woodpecker-server-1` | `woodpeckerci/woodpecker-server:v3` | CI dashboard + pipeline engine |
|
||||
| `ramaiah-prod-woodpecker-agent-1` | `woodpeckerci/woodpecker-agent:v3` | Executes pipeline steps in Docker |
|
||||
|
||||
## Agent Configuration
|
||||
|
||||
The Woodpecker agent is configured to:
|
||||
- Run pipeline containers on the `ramaiah-prod_default` Docker network (so they can reach Gitea and MinIO)
|
||||
- Allow up to 2 concurrent workflows
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Pipeline fails at git clone
|
||||
|
||||
Check that Gitea's `REQUIRE_SIGNIN_VIEW` is `false` (public repos must be cloneable without auth):
|
||||
|
||||
```bash
|
||||
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
|
||||
"docker exec ramaiah-prod-gitea-1 grep REQUIRE_SIGNIN /data/gitea/conf/app.ini"
|
||||
```
|
||||
|
||||
### npm install crashes with "Exit handler never called"
|
||||
|
||||
Known npm bug in CI containers. Use `yarn` instead of `npm` for the frontend. The sidecar's lockfile is clean so `npm ci` works.
|
||||
|
||||
### Pipeline says "pipeline definition not found"
|
||||
|
||||
The `.woodpecker.yml` file is missing or has invalid YAML. Check:
|
||||
|
||||
```bash
|
||||
curl -s "https://git.healix360.net/api/v1/repos/helix-admin/helix-engage/contents/.woodpecker.yml?ref=feature/omnichannel-widget" \
|
||||
-u "helix-admin:Global@2026" | python3 -c "import sys,json;print(json.load(sys.stdin).get('name','NOT FOUND'))"
|
||||
```
|
||||
|
||||
### Teams notification not arriving
|
||||
|
||||
Verify the webhook secret is set in Woodpecker and the Power Automate workflow is active.
|
||||
|
||||
### Test reports not loading (403/XML error)
|
||||
|
||||
Caddy must strip the Authorization header before proxying to MinIO. Check:
|
||||
|
||||
```bash
|
||||
ssh -i /tmp/ramaiah-ec2-key ubuntu@13.234.31.194 \
|
||||
"grep -A8 'handle_path /reports' /opt/fortytwo/Caddyfile"
|
||||
```
|
||||
|
||||
Should include `header_up -Authorization`.
|
||||
|
||||
### Manually trigger a pipeline
|
||||
|
||||
```bash
|
||||
WP_TOKEN="<woodpecker-api-token>"
|
||||
curl -s -X POST "https://operations.healix360.net/api/repos/1/pipelines" \
|
||||
-H "Authorization: Bearer $WP_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"branch":"feature/omnichannel-widget"}'
|
||||
```
|
||||
|
||||
### Delete old pipeline runs
|
||||
|
||||
```bash
|
||||
WP_TOKEN="<woodpecker-api-token>"
|
||||
for i in $(seq 1 20); do
|
||||
curl -s -X DELETE "https://operations.healix360.net/api/repos/1/pipelines/$i" \
|
||||
-H "Authorization: Bearer $WP_TOKEN"
|
||||
done
|
||||
```
|
||||
|
||||
## E2E Test Coverage
|
||||
|
||||
40 tests across 2 hospitals, 3 roles:
|
||||
|
||||
**Login (4):** branding, invalid creds, supervisor login, auth guard
|
||||
|
||||
**Ramaiah CC Agent (10):** landing, call desk, call history, patients (list + search), appointments, my performance (API + KPI), sidebar, sign-out modal, sign-out complete
|
||||
|
||||
**Ramaiah Supervisor (12):** landing, team performance, live monitor, leads, patients, appointments, call log, recordings, missed calls, campaigns, settings, sidebar
|
||||
|
||||
**Global CC Agent (7):** landing, call history, patients, appointments, my performance, sidebar, sign-out
|
||||
|
||||
**Global Supervisor (5):** landing, patients, appointments, campaigns, settings
|
||||
|
||||
**Auto-cleanup:** Last CC Agent test completes sign-out to release agent session. Setup steps call `/api/maint/unlock-agent` to clear stale locks.
|
||||
102
docs/ozonetel-cdr-api-reference.md
Normal file
102
docs/ozonetel-cdr-api-reference.md
Normal 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
1219
docs/requirements.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
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) => {
|
||||
const navigate = useNavigate();
|
||||
const [contextExpanded, setContextExpanded] = useState(true);
|
||||
const [insightExpanded, setInsightExpanded] = useState(true);
|
||||
const [actionsExpanded, setActionsExpanded] = useState(true);
|
||||
@@ -163,6 +165,16 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
|
||||
</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 */}
|
||||
{(leadAppointments.length > 0 || leadFollowUps.length > 0 || linkedPatient) && (
|
||||
<div>
|
||||
@@ -223,6 +235,12 @@ export const ContextPanel = ({ selectedLead, activities, calls, followUps, appoi
|
||||
{linkedPatient.patientType && (
|
||||
<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>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useAuth } from '@/providers/auth-provider';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
|
||||
import { useNetworkStatus } from '@/hooks/use-network-status';
|
||||
import { GlobalSearch } from '@/components/shared/global-search';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
@@ -119,7 +120,9 @@ export const AppShell = ({ children }: AppShellProps) => {
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Persistent top bar — visible on all pages */}
|
||||
{(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 />}
|
||||
{hasAgentConfig && (
|
||||
<>
|
||||
@@ -140,6 +143,7 @@ export const AppShell = ({ children }: AppShellProps) => {
|
||||
<AgentStatusToggle isRegistered={isRegistered} connectionStatus={connectionStatus} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ResumeSetupBanner />
|
||||
|
||||
38
src/main.tsx
38
src/main.tsx
@@ -10,6 +10,11 @@ const AdminSetupGuard = () => {
|
||||
const { isAdmin } = useAuth();
|
||||
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 { NotFound } from "@/pages/not-found";
|
||||
import { AllLeadsPage } from "@/pages/all-leads";
|
||||
@@ -85,22 +90,23 @@ createRoot(document.getElementById("root")!).render(
|
||||
<Route path="/call-desk" element={<CallDeskPage />} />
|
||||
<Route path="/patients" element={<PatientsPage />} />
|
||||
<Route path="/appointments" element={<AppointmentsPage />} />
|
||||
<Route path="/team-performance" element={<TeamPerformancePage />} />
|
||||
<Route path="/live-monitor" element={<LiveMonitorPage />} />
|
||||
<Route path="/call-recordings" element={<CallRecordingsPage />} />
|
||||
<Route path="/missed-calls" element={<MissedCallsPage />} />
|
||||
<Route path="/team-dashboard" element={<TeamDashboardPage />} />
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
<Route path="/integrations" element={<IntegrationsPage />} />
|
||||
|
||||
{/* Settings hub + section pages */}
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/settings/team" element={<TeamSettingsPage />} />
|
||||
<Route path="/settings/clinics" element={<ClinicsPage />} />
|
||||
<Route path="/settings/doctors" element={<DoctorsPage />} />
|
||||
<Route path="/settings/telephony" element={<TelephonySettingsPage />} />
|
||||
<Route path="/settings/ai" element={<AiSettingsPage />} />
|
||||
<Route path="/settings/widget" element={<WidgetSettingsPage />} />
|
||||
{/* Admin-only routes */}
|
||||
<Route element={<RequireAdmin />}>
|
||||
<Route path="/team-performance" element={<TeamPerformancePage />} />
|
||||
<Route path="/live-monitor" element={<LiveMonitorPage />} />
|
||||
<Route path="/call-recordings" element={<CallRecordingsPage />} />
|
||||
<Route path="/missed-calls" element={<MissedCallsPage />} />
|
||||
<Route path="/team-dashboard" element={<TeamDashboardPage />} />
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
<Route path="/integrations" element={<IntegrationsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/settings/team" element={<TeamSettingsPage />} />
|
||||
<Route path="/settings/clinics" element={<ClinicsPage />} />
|
||||
<Route path="/settings/doctors" element={<DoctorsPage />} />
|
||||
<Route path="/settings/telephony" element={<TelephonySettingsPage />} />
|
||||
<Route path="/settings/ai" element={<AiSettingsPage />} />
|
||||
<Route path="/settings/widget" element={<WidgetSettingsPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="/agent/:id" element={<AgentDetailPage />} />
|
||||
<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 { 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(() => {});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user