8 Commits

Author SHA1 Message Date
d36086f6da docs: per-tenant frontend deploy paths in runbook
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Ramaiah and Global now have separate frontend dirs on EC2
(frontend-ramaiah, frontend-global) with tenant-specific VITE_API_URL
baked at build time. Also updated api-client.ts to fallback to
window.location.origin when VITE_API_URL is empty.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 10:50:51 +05:30
cfe9e0bb77 fix: clean outbound call gating — confirmedAnswered state with 3s debounce (#568)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Replace layered customerAnswered/wasAnsweredRef with clean two-concern
design:

- customerAnswered: live derived value (is customer on line right now?)
- confirmedAnswered: latched state (did real conversation happen?)
  Inbound: immediate. Outbound: 3s debounce filters voicemail.
  Never resets until handleReset — survives acw→ended timing gap.

Buttons use confirmedAnswered for outbound (no flash during voicemail),
customerAnswered for inbound (immediate). Disposition routing uses
confirmedAnswered for both directions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 14:06:41 +05:30
923c99bf17 fix: outbound call — debounce customer-answered, auto-dispose on no-answer (#568)
Ozonetel sends 'in-call' even for voicemail (~4s before ACW), which
briefly enabled action buttons and poisoned wasAnsweredRef. Three fixes:

1. Debounce customerAnswered for outbound: require 'in-call' to hold 5s
   before enabling buttons (filters voicemail/IVR pickup)
2. Use live customerAnswered (not stale latch) for outbound call-end
   routing — unanswered calls go to Back to Worklist, not disposition
3. Auto-dispose with NO_ANSWER on unanswered outbound to release agent
   from ACW immediately (was waiting 30s for server safety net)

Also: hide AI FAB for CC agents in app-shell.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 13:47:02 +05:30
a306311f08 fix: disable Book Appt/Enquiry until customer answers outbound call (#568)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
For outbound calls, SIP state transitions to 'active' when the agent's
bridge connects — before the customer picks up. Ozonetel state stays
'calling' until customer answers, then goes to 'in-call'.

Now reads ozonetelState from useAgentState and computes customerAnswered
(callState=active AND ozonetelState!=calling). Action buttons (Book Appt,
Enquiry, Transfer) disabled until customerAnswered is true.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 11:41:04 +05:30
d0e34fa9dd feat: global AI assistant floating button for supervisors (#578)
- AiFloatingButton: FAB (bottom-right) opens a slide-in drawer with
  the supervisor AI chat panel. Close button collapses drawer, FAB
  reappears. Chat state persists across open/close and page navigation.
- app-shell: mounts FAB for admin users (isAdmin), same pattern as
  CallWidget for agents.
- team-dashboard: removed inline AI panel + toggle button — replaced
  by the global FAB. Dashboard content reclaims the full width.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 10:45:45 +05:30
7e5d910197 feat: network loss alert banner during active call (#572)
Shows prominent banner on active-call-card when network drops:
- Offline: red banner "Network connection lost — call may have dropped"
- Unstable: yellow banner "Network unstable — call quality may be affected"
Uses existing useNetworkStatus hook. Banner disappears when network recovers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 09:56:35 +05:30
dd4240ee7f fix: remove Cancel button from outbound ringing state (#574)
Product decision: agent cannot abort outbound call while ringing.
Risk accepted — misdialled calls will connect before agent can cancel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 09:44:44 +05:30
85976803a1 fix: unify appointment data source — single DataProvider, immediate refresh
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- appointments-v2: migrated from local query/state to useData().appointments.
  Removed AppointmentRecord type, QUERY, fetchAppointments(), local useState.
  All field references updated to transformed Appointment type (appointmentStatus,
  patientName, patientPhone, clinicName, doctorId).
- active-call-card: calls refresh() after appointment book/reschedule/cancel
  so pills update immediately. Also invalidates sidecar Redis cache.
- One source of truth — all appointment consumers read from DataProvider.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:20:55 +05:30
7 changed files with 213 additions and 167 deletions

View File

@@ -159,19 +159,33 @@ REDIS_URL=redis://localhost:6379
### Frontend ### Frontend
Each tenant has its own frontend directory on EC2. The `VITE_API_URL` is baked at build time so each build points to the correct sidecar.
```bash ```bash
# Helper — reuse in all commands below # Helper — reuse in all commands below
EC2="SSHPASS='SasiSuman@2007' sshpass -P 'Enter passphrase' -e ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no ubuntu@13.234.31.194" EC2="SSHPASS='SasiSuman@2007' sshpass -P 'Enter passphrase' -e ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no ubuntu@13.234.31.194"
EC2_RSYNC="SSHPASS='SasiSuman@2007' sshpass -P 'Enter passphrase' -e ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no" EC2_RSYNC="SSHPASS='SasiSuman@2007' sshpass -P 'Enter passphrase' -e ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no"
cd helix-engage && npm run build cd helix-engage
# ── Ramaiah (production pilot — deploy stable builds only) ──
VITE_API_URL=https://ramaiah.engage.healix360.net npm run build
rsync -avz -e "$EC2_RSYNC" \ rsync -avz -e "$EC2_RSYNC" \
dist/ ubuntu@13.234.31.194:/opt/fortytwo/helix-engage-frontend/ dist/ ubuntu@13.234.31.194:/opt/fortytwo/frontend-ramaiah/
eval $EC2 "cd /opt/fortytwo && sudo docker compose restart caddy" # ── Global (staging — new features land here first) ──
VITE_API_URL=https://global.engage.healix360.net npm run build
rsync -avz -e "$EC2_RSYNC" \
dist/ ubuntu@13.234.31.194:/opt/fortytwo/frontend-global/
``` ```
| Tenant | Frontend Dir | API URL (baked) | Caddy Root |
|---|---|---|---|
| Ramaiah | `/opt/fortytwo/frontend-ramaiah/` | `https://ramaiah.engage.healix360.net` | `/srv/engage-ramaiah` |
| Global | `/opt/fortytwo/frontend-global/` | `https://global.engage.healix360.net` | `/srv/engage-global` |
**Important:** Always build with the correct `VITE_API_URL` for the target tenant. A build without it (or with `localhost`) will break login and API calls.
### Sidecar ### Sidecar
```bash ```bash

View File

@@ -22,6 +22,7 @@ import { formatPhone, formatShortDate } from '@/lib/format';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { useAuth } from '@/providers/auth-provider'; import { useAuth } from '@/providers/auth-provider';
import { useAgentState } from '@/hooks/use-agent-state'; import { useAgentState } from '@/hooks/use-agent-state';
import { useNetworkStatus } from '@/hooks/use-network-status';
import { cx } from '@/utils/cx'; import { cx } from '@/utils/cx';
import { notify } from '@/lib/toast'; import { notify } from '@/lib/toast';
import type { Lead, CallDisposition } from '@/types/entities'; import type { Lead, CallDisposition } from '@/types/entities';
@@ -42,6 +43,7 @@ const formatDuration = (seconds: number): string => {
export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete }: ActiveCallCardProps) => { export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete }: ActiveCallCardProps) => {
const { user } = useAuth(); const { user } = useAuth();
const { callState, callDuration, callUcid, isMuted, isOnHold, answer, hangup, toggleMute, toggleHold } = useSip(); const { callState, callDuration, callUcid, isMuted, isOnHold, answer, hangup, toggleMute, toggleHold } = useSip();
const networkQuality = useNetworkStatus();
const setCallState = useSetAtom(sipCallStateAtom); const setCallState = useSetAtom(sipCallStateAtom);
const setCallerNumber = useSetAtom(sipCallerNumberAtom); const setCallerNumber = useSetAtom(sipCallerNumberAtom);
const setCallUcid = useSetAtom(sipCallUcidAtom); const setCallUcid = useSetAtom(sipCallUcidAtom);
@@ -71,7 +73,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
// Upcoming appointments for this caller (if returning patient) — drives // Upcoming appointments for this caller (if returning patient) — drives
// the pill row above AppointmentForm so the agent can edit existing // the pill row above AppointmentForm so the agent can edit existing
// bookings in addition to creating new ones. // bookings in addition to creating new ones.
const { appointments } = useData(); const { appointments, refresh } = useData();
const leadAppointments = useMemo(() => { const leadAppointments = useMemo(() => {
const patientId = (lead as any)?.patientId; const patientId = (lead as any)?.patientId;
if (!patientId) return []; if (!patientId) return [];
@@ -103,10 +105,44 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
const agentConfig = localStorage.getItem('helix_agent_config'); const agentConfig = localStorage.getItem('helix_agent_config');
const agentIdForState = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null; const agentIdForState = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null;
const { supervisorPresence } = useAgentState(agentIdForState); const { state: ozonetelState, supervisorPresence } = useAgentState(agentIdForState);
const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND'); const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND');
const wasAnsweredRef = useRef(callState === 'active'); const isOutbound = callDirectionRef.current === 'OUTBOUND';
// customerAnswered — live signal (is customer on the line RIGHT NOW?)
const customerAnswered = callState === 'active' && (!isOutbound || ozonetelState === 'in-call');
// confirmedAnswered — latched state (did a real conversation happen?)
// Inbound: set true on active (immediate). Outbound: set true after
// in-call holds 5+ seconds (filters voicemail). Never resets — survives
// the acw→ended timing gap. Used for disposition routing AND outbound
// button gating.
const [confirmedAnswered, setConfirmedAnswered] = useState(false);
const unansweredDisposeFired = useRef(false);
useEffect(() => {
if (!isOutbound && callState === 'active') {
setConfirmedAnswered(true);
}
}, [callState, isOutbound]);
useEffect(() => {
if (isOutbound && customerAnswered && !confirmedAnswered) {
const timer = setTimeout(() => {
console.log(`[CALL-DBG] ▶ Outbound debounce passed — customer confirmed answered`);
setConfirmedAnswered(true);
}, 3000);
return () => clearTimeout(timer);
}
}, [customerAnswered, isOutbound, confirmedAnswered]);
// Button gating: inbound uses live signal, outbound uses debounced latch
const buttonsEnabled = isOutbound ? confirmedAnswered : customerAnswered;
// ── DEBUG: trace every state change ──
useEffect(() => {
console.log(`[CALL-DBG] callState=${callState} ozonetel=${ozonetelState} direction=${callDirectionRef.current} isOutbound=${isOutbound} customerAnswered=${customerAnswered} confirmedAnswered=${confirmedAnswered} buttonsEnabled=${buttonsEnabled}`);
}, [callState, ozonetelState, isOutbound, customerAnswered, confirmedAnswered, buttonsEnabled]);
useEffect(() => { useEffect(() => {
console.log(`[ACTIVE-CALL-CARD] Mounted: state=${callState} direction=${callDirectionRef.current} ucid=${callUcid ?? 'none'} lead=${lead?.id ?? 'none'} phone=${callerPhone}`); console.log(`[ACTIVE-CALL-CARD] Mounted: state=${callState} direction=${callDirectionRef.current} ucid=${callUcid ?? 'none'} lead=${lead?.id ?? 'none'} phone=${callerPhone}`);
@@ -124,13 +160,16 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
}; };
}, [callUcid]); }, [callUcid]);
// Detect caller disconnect: call was active and ended without agent pressing End // Detect caller disconnect: call ended without agent pressing End.
// Uses confirmedAnsweredRef (stable latch) — not the live customerAnswered.
useEffect(() => { useEffect(() => {
if (wasAnsweredRef.current && !dispositionOpen && (callState === 'ended' || callState === 'failed')) { if (!(callState === 'ended' || callState === 'failed') || dispositionOpen) return;
console.log(`[CALL-DBG] ▶ CALL ENDED: confirmedAnswered=${confirmedAnswered} isOutbound=${isOutbound} customerAnswered=${customerAnswered} callState=${callState}`);
if (confirmedAnswered) {
setCallerDisconnected(true); setCallerDisconnected(true);
setDispositionOpen(true); setDispositionOpen(true);
} }
}, [callState, dispositionOpen]); }, [callState, dispositionOpen]); // eslint-disable-line react-hooks/exhaustive-deps
const firstName = lead?.contactName?.firstName ?? ''; const firstName = lead?.contactName?.firstName ?? '';
const lastName = lead?.contactName?.lastName ?? ''; const lastName = lead?.contactName?.lastName ?? '';
@@ -180,6 +219,11 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
const handleAppointmentSaved = (outcome: 'BOOKED' | 'RESCHEDULED' | 'CANCELLED') => { const handleAppointmentSaved = (outcome: 'BOOKED' | 'RESCHEDULED' | 'CANCELLED') => {
setAppointmentOpen(false); setAppointmentOpen(false);
refresh();
// Invalidate sidecar's caller context cache so AI gets fresh appointment data
if (lead?.id) {
apiClient.post('/api/caller/invalidate-context', { leadId: lead.id }, { silent: true }).catch(() => {});
}
if (outcome === 'RESCHEDULED') { if (outcome === 'RESCHEDULED') {
addActions('RESCHEDULE'); addActions('RESCHEDULE');
notify.success('Appointment Rescheduled'); notify.success('Appointment Rescheduled');
@@ -195,6 +239,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
const handleReset = () => { const handleReset = () => {
setDispositionOpen(false); setDispositionOpen(false);
setCallerDisconnected(false); setCallerDisconnected(false);
setConfirmedAnswered(false);
setActionsTaken([]); setActionsTaken([]);
setCallState('idle'); setCallState('idle');
setCallerNumber(null); setCallerNumber(null);
@@ -203,6 +248,26 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
onCallComplete?.(); onCallComplete?.();
}; };
// Auto-dispose unanswered outbound calls to release agent from ACW immediately
useEffect(() => {
if (!confirmedAnswered && isOutbound && (callState === 'ended' || callState === 'failed') && callUcid && !unansweredDisposeFired.current) {
unansweredDisposeFired.current = true;
const agentCfg = JSON.parse(localStorage.getItem('helix_agent_config') ?? '{}');
console.log(`[CALL-DBG] ▶ Auto-disposing unanswered outbound: ucid=${callUcid} agent=${agentCfg.ozonetelAgentId}`);
apiClient.post('/api/ozonetel/dispose', {
ucid: callUcid,
disposition: 'NO_ANSWER',
agentId: agentCfg.ozonetelAgentId,
callerPhone,
direction: 'OUTBOUND',
durationSec: 0,
leadId: lead?.id ?? null,
leadName: fullName || null,
notes: 'Auto-disposed — customer did not answer',
}).catch((err) => console.error('[CALL-DBG] Auto-dispose failed:', err));
}
}, [callState, callUcid]); // eslint-disable-line react-hooks/exhaustive-deps
// Outbound ringing // Outbound ringing
if (callState === 'ringing-out') { if (callState === 'ringing-out') {
return ( return (
@@ -220,11 +285,9 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
{fullName && <p className="text-sm text-secondary">{phoneDisplay}</p>} {fullName && <p className="text-sm text-secondary">{phoneDisplay}</p>}
</div> </div>
</div> </div>
<div className="mt-3 flex gap-2"> {/* Cancel button removed per product — risk: agent can't abort
<Button size="sm" color="primary-destructive" onClick={() => { hangup(); handleReset(); }}> a misdialled outbound call before the customer answers.
Cancel <Button size="sm" color="primary-destructive" onClick={() => { hangup(); handleReset(); }}>Cancel</Button> */}
</Button>
</div>
</div> </div>
); );
} }
@@ -254,8 +317,8 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
); );
} }
// Unanswered call (ringing → ended without ever reaching active) if (!confirmedAnswered && (callState === 'ended' || callState === 'failed')) {
if (!wasAnsweredRef.current && (callState === 'ended' || callState === 'failed')) { console.log(`[CALL-DBG] ▶ BACK-TO-WORKLIST PATH: confirmedAnswered=${confirmedAnswered} isOutbound=${isOutbound}`);
return ( return (
<div className="rounded-xl border border-secondary bg-primary p-4 text-center"> <div className="rounded-xl border border-secondary bg-primary p-4 text-center">
<FontAwesomeIcon icon={faPhoneHangup} className="size-6 text-fg-quaternary mb-2" /> <FontAwesomeIcon icon={faPhoneHangup} className="size-6 text-fg-quaternary mb-2" />
@@ -270,10 +333,23 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
// Active call // Active call
if (callState === 'active' || dispositionOpen) { if (callState === 'active' || dispositionOpen) {
wasAnsweredRef.current = true;
return ( return (
<> <>
<div className={cx('flex flex-col rounded-xl border border-brand bg-primary overflow-hidden', (appointmentOpen || enquiryOpen || transferOpen) && 'flex-1 min-h-0')}> <div className={cx('flex flex-col rounded-xl border border-brand bg-primary overflow-hidden', (appointmentOpen || enquiryOpen || transferOpen) && 'flex-1 min-h-0')}>
{/* Network loss alert — prominent banner during active call */}
{networkQuality !== 'good' && (
<div className={cx(
'shrink-0 px-4 py-2 text-xs font-medium text-center',
networkQuality === 'offline'
? 'bg-error-solid text-white'
: 'bg-warning-secondary text-warning-primary',
)}>
{networkQuality === 'offline'
? 'Network connection lost — call may have dropped'
: 'Network unstable — call quality may be affected'}
</div>
)}
{/* Pinned: caller info + controls */} {/* Pinned: caller info + controls */}
<div className="shrink-0 p-4"> <div className="shrink-0 p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -338,17 +414,17 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
<Button size="sm" color={appointmentOpen ? 'primary' : 'secondary'} <Button size="sm" color={appointmentOpen ? 'primary' : 'secondary'}
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />} iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />}
isDisabled={!wasAnsweredRef.current} isDisabled={!buttonsEnabled}
onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); setTransferOpen(false); }}> onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); setTransferOpen(false); }}>
{leadAppointments.length > 0 ? 'New / Reschedule Appt' : 'New Appt'} {leadAppointments.length > 0 ? 'New / Reschedule Appt' : 'New Appt'}
</Button> </Button>
<Button size="sm" color={enquiryOpen ? 'primary' : 'secondary'} <Button size="sm" color={enquiryOpen ? 'primary' : 'secondary'}
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />} iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />}
isDisabled={!wasAnsweredRef.current} isDisabled={!buttonsEnabled}
onClick={() => { setEnquiryOpen(!enquiryOpen); setAppointmentOpen(false); setTransferOpen(false); }}>Enquiry</Button> onClick={() => { setEnquiryOpen(!enquiryOpen); setAppointmentOpen(false); setTransferOpen(false); }}>Enquiry</Button>
<Button size="sm" color={transferOpen ? 'primary' : 'secondary'} <Button size="sm" color={transferOpen ? 'primary' : 'secondary'}
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} {...rest} />} iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} {...rest} />}
isDisabled={!wasAnsweredRef.current} isDisabled={!buttonsEnabled}
onClick={() => { setTransferOpen(!transferOpen); setAppointmentOpen(false); setEnquiryOpen(false); }}>Transfer</Button> onClick={() => { setTransferOpen(!transferOpen); setAppointmentOpen(false); setEnquiryOpen(false); }}>Transfer</Button>
<Button size="sm" color="primary-destructive" className="ml-auto" <Button size="sm" color="primary-destructive" className="ml-auto"
@@ -530,12 +606,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
isOpen={dispositionOpen} isOpen={dispositionOpen}
callerName={fullName || phoneDisplay} callerName={fullName || phoneDisplay}
callerDisconnected={callerDisconnected} callerDisconnected={callerDisconnected}
// wasAnsweredRef only flips true once callState reaches callAnswered={confirmedAnswered}
// 'active'. Outbound callbacks that never connect keep
// this false, which narrows the disposition options to
// no-answer outcomes and prevents SLA-gaming dispositions
// like Info Provided on a call the customer never took.
callAnswered={wasAnsweredRef.current}
actionsTaken={actionsTaken} actionsTaken={actionsTaken}
onSubmit={handleDisposition} onSubmit={handleDisposition}
onDismiss={() => { onDismiss={() => {

View File

@@ -15,6 +15,7 @@ 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 { GlobalSearch } from '@/components/shared/global-search';
import { AiFloatingButton } from '@/components/shared/ai-floating-button';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { cx } from '@/utils/cx'; import { cx } from '@/utils/cx';
@@ -24,7 +25,7 @@ interface AppShellProps {
export const AppShell = ({ children }: AppShellProps) => { export const AppShell = ({ children }: AppShellProps) => {
const { pathname } = useLocation(); const { pathname } = useLocation();
const { isCCAgent } = useAuth(); const { isCCAgent, isAdmin } = useAuth();
const { isOpen, activeAction, close } = useMaintShortcuts(); const { isOpen, activeAction, close } = useMaintShortcuts();
const { connectionStatus, isRegistered } = useSip(); const { connectionStatus, isRegistered } = useSip();
const networkQuality = useNetworkStatus(); const networkQuality = useNetworkStatus();
@@ -143,6 +144,7 @@ export const AppShell = ({ children }: AppShellProps) => {
<main className="flex flex-1 flex-col overflow-hidden">{children}</main> <main className="flex flex-1 flex-col overflow-hidden">{children}</main>
</div> </div>
{isCCAgent && pathname !== '/' && pathname !== '/call-desk' && <CallWidget />} {isCCAgent && pathname !== '/' && pathname !== '/call-desk' && <CallWidget />}
{isAdmin && !isCCAgent && <AiFloatingButton />}
</div> </div>
<MaintOtpModal <MaintOtpModal
isOpen={isOpen} isOpen={isOpen}

View File

@@ -0,0 +1,50 @@
import { useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSparkles, faXmark } from '@fortawesome/pro-duotone-svg-icons';
import { AiChatPanel } from '@/components/call-desk/ai-chat-panel';
import { cx } from '@/utils/cx';
export const AiFloatingButton = () => {
const [open, setOpen] = useState(false);
return (
<>
{/* FAB — bottom right, hidden when drawer is open */}
{!open && (
<button
onClick={() => setOpen(true)}
className="fixed bottom-6 right-6 z-50 flex size-12 items-center justify-center rounded-full bg-brand-solid text-white shadow-lg hover:bg-brand-solid_hover transition duration-100 ease-linear"
title="AI Assistant"
>
<FontAwesomeIcon icon={faSparkles} className="size-5" />
</button>
)}
{/* Drawer — slides in from right */}
<div className={cx(
'fixed top-0 right-0 z-50 h-full bg-primary border-l border-secondary shadow-xl transition-all duration-200 ease-linear flex flex-col',
open ? 'w-[400px]' : 'w-0 overflow-hidden border-l-0',
)}>
{open && (
<>
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-4 py-3">
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faSparkles} className="size-3.5 text-fg-brand-primary" />
<span className="text-sm font-semibold text-primary">AI Assistant</span>
</div>
<button
onClick={() => setOpen(false)}
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"
>
<FontAwesomeIcon icon={faXmark} className="size-4" />
</button>
</div>
<div className="flex-1 min-h-0 overflow-hidden">
<AiChatPanel callerContext={{ type: 'supervisor' }} />
</div>
</>
)}
</div>
</>
);
};

View File

@@ -1,6 +1,9 @@
import { notify } from './toast'; import { notify } from './toast';
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100'; // In production, use the current origin — Caddy routes /api/* to the
// correct per-tenant sidecar based on hostname. Only use VITE_API_URL
// for local dev (pointing to a specific sidecar).
const API_URL = import.meta.env.VITE_API_URL || window.location.origin;
class AuthError extends Error { class AuthError extends Error {
constructor(message = 'Authentication required') { constructor(message = 'Authentication required') {

View File

@@ -1,4 +1,5 @@
// Appointments v2 — lean table + detail side panel + reschedule + reminder // Appointments v2 — lean table + detail side panel + reschedule
// Uses DataProvider as single source of truth for appointment data.
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { import {
@@ -12,7 +13,6 @@ import { Badge } from '@/components/base/badges/badges';
import { Input } from '@/components/base/input/input'; import { Input } from '@/components/base/input/input';
import { Table } from '@/components/application/table/table'; import { Table } from '@/components/application/table/table';
import { PaginationCardDefault } from '@/components/application/pagination/pagination'; import { PaginationCardDefault } from '@/components/application/pagination/pagination';
// TopBar replaced by inline header
import { Button } from '@/components/base/buttons/button'; import { Button } from '@/components/base/buttons/button';
import { ModalOverlay, Modal, Dialog } from '@/components/application/modals/modal'; import { ModalOverlay, Modal, Dialog } from '@/components/application/modals/modal';
import { Select } from '@/components/base/select/select'; import { Select } from '@/components/base/select/select';
@@ -21,33 +21,11 @@ import { parseDate, today, getLocalTimeZone } from '@internationalized/date';
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell'; import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
import { PageHeader } from '@/components/layout/page-header'; import { PageHeader } from '@/components/layout/page-header';
import { formatPhone, formatDateOnly, formatTimeOnly } from '@/lib/format'; import { formatPhone, formatDateOnly, formatTimeOnly } from '@/lib/format';
import { useData } from '@/providers/data-provider';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast'; import { notify } from '@/lib/toast';
import { cx } from '@/utils/cx'; import { cx } from '@/utils/cx';
import type { Appointment } from '@/types/entities';
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'; type StatusTab = 'all' | 'SCHEDULED' | 'COMPLETED' | 'CANCELLED' | 'RESCHEDULED';
@@ -69,26 +47,14 @@ const STATUS_LABELS: Record<string, string> = {
RESCHEDULED: 'Rescheduled', RESCHEDULED: 'Rescheduled',
}; };
const QUERY = `{ appointments(first: 200, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node { const getPatientName = (appt: Appointment): string =>
id scheduledAt durationMin appointmentType status appt.patientName || 'Unknown';
doctorName department reasonForVisit
patient { id fullName { firstName lastName } phones { primaryPhoneNumber } }
clinic { id clinicName }
doctor { id fullName { firstName lastName } }
} } } }`;
const getPatientName = (appt: AppointmentRecord): string => { const getPhone = (appt: Appointment): string =>
if (!appt.patient?.fullName) return 'Unknown'; appt.patientPhone ?? '';
return `${appt.patient.fullName.firstName} ${appt.patient.fullName.lastName}`.trim() || 'Unknown';
};
const getPhone = (appt: AppointmentRecord): string => const canEdit = (appt: Appointment): boolean =>
appt.patient?.phones?.primaryPhoneNumber ?? ''; appt.appointmentStatus !== 'COMPLETED' && appt.appointmentStatus !== 'CANCELLED' && appt.appointmentStatus !== 'NO_SHOW';
// 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';
};
// ── Detail Panel ───────────────────────────────────────────────── // ── Detail Panel ─────────────────────────────────────────────────
const DetailRow = ({ icon, label, value }: { icon: any; label: string; value: string }) => ( const DetailRow = ({ icon, label, value }: { icon: any; label: string; value: string }) => (
@@ -106,7 +72,7 @@ const AppointmentDetailPanel = ({
onClose, onClose,
onReschedule, onReschedule,
}: { }: {
appointment: AppointmentRecord; appointment: Appointment;
onClose: () => void; onClose: () => void;
onReschedule: () => void; onReschedule: () => void;
}) => { }) => {
@@ -138,12 +104,11 @@ const AppointmentDetailPanel = ({
</div> </div>
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-1"> <div className="flex-1 overflow-y-auto px-5 py-4 space-y-1">
<div className="mb-4"> <div className="mb-4">
<Badge size="md" color={STATUS_COLORS[appointment.status ?? ''] ?? 'gray'} type="pill-color"> <Badge size="md" color={STATUS_COLORS[appointment.appointmentStatus ?? ''] ?? 'gray'} type="pill-color">
{STATUS_LABELS[appointment.status ?? ''] ?? appointment.status ?? '—'} {STATUS_LABELS[appointment.appointmentStatus ?? ''] ?? appointment.appointmentStatus ?? '—'}
</Badge> </Badge>
</div> </div>
{/* Date & Time — 2 lines */}
<div className="flex items-start gap-3 py-2.5"> <div className="flex items-start gap-3 py-2.5">
<FontAwesomeIcon icon={faCalendarCheck} className="size-4 text-fg-quaternary mt-0.5 shrink-0" /> <FontAwesomeIcon icon={faCalendarCheck} className="size-4 text-fg-quaternary mt-0.5 shrink-0" />
<div> <div>
@@ -159,7 +124,7 @@ const AppointmentDetailPanel = ({
<DetailRow icon={faUserDoctor} label="Doctor" value={appointment.doctorName ?? '—'} /> <DetailRow icon={faUserDoctor} label="Doctor" value={appointment.doctorName ?? '—'} />
<DetailRow icon={faStethoscope} label="Department" value={appointment.department ?? '—'} /> <DetailRow icon={faStethoscope} label="Department" value={appointment.department ?? '—'} />
<DetailRow icon={faBuilding} label="Branch / Clinic" value={appointment.clinic?.clinicName ?? '—'} /> <DetailRow icon={faBuilding} label="Branch / Clinic" value={appointment.clinicName ?? '—'} />
<DetailRow icon={faNotesMedical} label="Chief Complaint" value={appointment.reasonForVisit ?? '—'} /> <DetailRow icon={faNotesMedical} label="Chief Complaint" value={appointment.reasonForVisit ?? '—'} />
<div className="border-t border-secondary pt-3 mt-3"> <div className="border-t border-secondary pt-3 mt-3">
@@ -173,7 +138,6 @@ const AppointmentDetailPanel = ({
</div> </div>
</div> </div>
{/* Reschedule confirm modal — same pattern as call desk */}
<ModalOverlay <ModalOverlay
isOpen={reschedulePromptOpen} isOpen={reschedulePromptOpen}
onOpenChange={(open) => { if (!open) setReschedulePromptOpen(false); }} onOpenChange={(open) => { if (!open) setReschedulePromptOpen(false); }}
@@ -186,7 +150,6 @@ const AppointmentDetailPanel = ({
<h2 className="text-lg font-semibold text-primary">Reschedule this appointment?</h2> <h2 className="text-lg font-semibold text-primary">Reschedule this appointment?</h2>
<p className="text-sm text-tertiary"> <p className="text-sm text-tertiary">
Choose "Yes, reschedule" to change the date, time, or doctor. Choose "Yes, reschedule" to change the date, time, or doctor.
Choose "No, just view" to see the details without changing anything.
</p> </p>
<div className="flex items-center gap-2 justify-end"> <div className="flex items-center gap-2 justify-end">
<Button size="sm" color="secondary" onClick={() => setReschedulePromptOpen(false)}> <Button size="sm" color="secondary" onClick={() => setReschedulePromptOpen(false)}>
@@ -206,10 +169,6 @@ const AppointmentDetailPanel = ({
}; };
// ── Reschedule Panel ───────────────────────────────────────────── // ── 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 }; type Doctor = { id: string; name: string; department: string };
const DOCTORS_QUERY = `{ doctors(first: 50) { edges { node { const DOCTORS_QUERY = `{ doctors(first: 50) { edges { node {
@@ -221,13 +180,13 @@ const ReschedulePanel = ({
onClose, onClose,
onSaved, onSaved,
}: { }: {
appointment: AppointmentRecord; appointment: Appointment;
onClose: () => void; onClose: () => void;
onSaved: () => void; onSaved: () => void;
}) => { }) => {
const [doctors, setDoctors] = useState<Doctor[]>([]); const [doctors, setDoctors] = useState<Doctor[]>([]);
const [department, setDepartment] = useState(appointment.department ?? ''); const [department, setDepartment] = useState(appointment.department ?? '');
const [doctor, setDoctor] = useState(appointment.doctor?.id ?? ''); const [doctor, setDoctor] = useState(appointment.doctorId ?? '');
const [date, setDate] = useState(() => appointment.scheduledAt?.split('T')[0] ?? ''); const [date, setDate] = useState(() => appointment.scheduledAt?.split('T')[0] ?? '');
const [timeSlot, setTimeSlot] = useState(() => { const [timeSlot, setTimeSlot] = useState(() => {
if (!appointment.scheduledAt) return ''; if (!appointment.scheduledAt) return '';
@@ -240,7 +199,6 @@ const ReschedulePanel = ({
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [cancelConfirm, setCancelConfirm] = useState(false); const [cancelConfirm, setCancelConfirm] = useState(false);
// Fetch doctors once
useEffect(() => { useEffect(() => {
apiClient.graphql<any>(DOCTORS_QUERY, undefined, { silent: true }) apiClient.graphql<any>(DOCTORS_QUERY, undefined, { silent: true })
.then(data => { .then(data => {
@@ -256,11 +214,9 @@ const ReschedulePanel = ({
.catch(() => {}); .catch(() => {});
}, []); }, []);
// Departments derived from doctors
const departments = useMemo(() => [...new Set(doctors.map(d => d.department).filter(Boolean))], [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]); const filteredDoctors = useMemo(() => department ? doctors.filter(d => d.department === department) : doctors, [doctors, department]);
// Fetch slots when doctor + date change
useEffect(() => { useEffect(() => {
if (!doctor || !date) { setSlots([]); return; } if (!doctor || !date) { setSlots([]); return; }
apiClient.get<Array<{ time: string; label: string }>>(`/api/masterdata/slots?doctorId=${doctor}&date=${date}`, { silent: true }) apiClient.get<Array<{ time: string; label: string }>>(`/api/masterdata/slots?doctorId=${doctor}&date=${date}`, { silent: true })
@@ -329,7 +285,6 @@ const ReschedulePanel = ({
</div> </div>
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-4"> <div className="flex-1 overflow-y-auto px-5 py-4 space-y-4">
{/* Department */}
<div> <div>
<span className="text-xs font-medium text-secondary">Department</span> <span className="text-xs font-medium text-secondary">Department</span>
<Select <Select
@@ -343,7 +298,6 @@ const ReschedulePanel = ({
</Select> </Select>
</div> </div>
{/* Doctor */}
<div> <div>
<span className="text-xs font-medium text-secondary">Doctor <span className="text-error-primary">*</span></span> <span className="text-xs font-medium text-secondary">Doctor <span className="text-error-primary">*</span></span>
<Select <Select
@@ -357,7 +311,6 @@ const ReschedulePanel = ({
</Select> </Select>
</div> </div>
{/* Date */}
<div> <div>
<span className="text-xs font-medium text-secondary">Date <span className="text-error-primary">*</span></span> <span className="text-xs font-medium text-secondary">Date <span className="text-error-primary">*</span></span>
<DatePicker <DatePicker
@@ -370,7 +323,6 @@ const ReschedulePanel = ({
/> />
</div> </div>
{/* Time slots */}
{doctor && date && slots.length > 0 && ( {doctor && date && slots.length > 0 && (
<div> <div>
<span className="text-xs font-medium text-secondary">Time Slot <span className="text-error-primary">*</span></span> <span className="text-xs font-medium text-secondary">Time Slot <span className="text-error-primary">*</span></span>
@@ -396,7 +348,6 @@ const ReschedulePanel = ({
<p className="text-xs text-tertiary">No available slots for this date</p> <p className="text-xs text-tertiary">No available slots for this date</p>
)} )}
{/* Chief Complaint */}
<div> <div>
<span className="text-xs font-medium text-secondary">Chief Complaint</span> <span className="text-xs font-medium text-secondary">Chief Complaint</span>
<textarea <textarea
@@ -411,7 +362,6 @@ const ReschedulePanel = ({
{error && <p className="text-sm text-error-primary">{error}</p>} {error && <p className="text-sm text-error-primary">{error}</p>}
</div> </div>
{/* Footer buttons */}
<div className="flex items-center justify-between gap-2 border-t border-secondary px-5 py-3"> <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}> <Button size="sm" color="primary-destructive" onClick={() => setCancelConfirm(true)} isDisabled={saving}>
Cancel Appointment Cancel Appointment
@@ -421,7 +371,6 @@ const ReschedulePanel = ({
</Button> </Button>
</div> </div>
{/* Cancel confirm modal */}
<ModalOverlay <ModalOverlay
isOpen={cancelConfirm} isOpen={cancelConfirm}
onOpenChange={(open) => { if (!open) setCancelConfirm(false); }} onOpenChange={(open) => { if (!open) setCancelConfirm(false); }}
@@ -454,37 +403,31 @@ const ReschedulePanel = ({
// ── Page ───────────────────────────────────────────────────────── // ── Page ─────────────────────────────────────────────────────────
export const AppointmentsPageV2 = () => { export const AppointmentsPageV2 = () => {
const [appointments, setAppointments] = useState<AppointmentRecord[]>([]); const { appointments, loading, refresh } = useData();
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<StatusTab>('all'); const [tab, setTab] = useState<StatusTab>('all');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [selectedAppt, setSelectedAppt] = useState<AppointmentRecord | null>(null); const [selectedAppt, setSelectedAppt] = useState<Appointment | null>(null);
const [panelOpen, setPanelOpen] = useState(false); const [panelOpen, setPanelOpen] = useState(false);
const [rescheduleOpen, setRescheduleOpen] = useState(false); const [rescheduleOpen, setRescheduleOpen] = useState(false);
const PAGE_SIZE = 20; 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 statusCounts = useMemo(() => {
const counts: Record<string, number> = {}; const counts: Record<string, number> = {};
for (const a of appointments) { for (const a of appointments) {
const s = a.status ?? 'UNKNOWN'; const s = a.appointmentStatus ?? 'UNKNOWN';
counts[s] = (counts[s] ?? 0) + 1; counts[s] = (counts[s] ?? 0) + 1;
} }
return counts; return counts;
}, [appointments]); }, [appointments]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
let rows = appointments; let rows = [...appointments].sort((a, b) => {
if (tab !== 'all') rows = rows.filter(a => a.status === tab); const da = a.scheduledAt ? new Date(a.scheduledAt).getTime() : 0;
const db = b.scheduledAt ? new Date(b.scheduledAt).getTime() : 0;
return db - da;
});
if (tab !== 'all') rows = rows.filter(a => a.appointmentStatus === tab);
if (search.trim()) { if (search.trim()) {
const q = search.toLowerCase(); const q = search.toLowerCase();
rows = rows.filter(a => { rows = rows.filter(a => {
@@ -510,18 +453,17 @@ export const AppointmentsPageV2 = () => {
{ id: 'RESCHEDULED' as const, label: 'Rescheduled', badge: statusCounts.RESCHEDULED ? String(statusCounts.RESCHEDULED) : undefined }, { id: 'RESCHEDULED' as const, label: 'Rescheduled', badge: statusCounts.RESCHEDULED ? String(statusCounts.RESCHEDULED) : undefined },
]; ];
const handleEditClick = (appt: AppointmentRecord) => { const handleEditClick = (appt: Appointment) => {
setSelectedAppt(appt); setSelectedAppt(appt);
setPanelOpen(true); setPanelOpen(true);
setRescheduleOpen(false); setRescheduleOpen(false);
}; };
const handleRescheduleSaved = () => { const handleRescheduleSaved = () => {
setRescheduleOpen(false); setRescheduleOpen(false);
setPanelOpen(false); setPanelOpen(false);
setSelectedAppt(null); setSelectedAppt(null);
fetchAppointments(); refresh();
notify.success('Appointment Rescheduled'); notify.success('Appointment Rescheduled');
}; };
@@ -530,7 +472,7 @@ export const AppointmentsPageV2 = () => {
<PageHeader <PageHeader
title="Appointments" title="Appointments"
badge={filtered.length} badge={filtered.length}
infoText="All scheduled, completed, cancelled, and rescheduled appointments. Click the eye icon to view details or reschedule." infoText="All scheduled, completed, cancelled, and rescheduled appointments. Click a row to view details or reschedule."
controls={ controls={
<div className="w-56"> <div className="w-56">
<Input <Input
@@ -565,7 +507,6 @@ export const AppointmentsPageV2 = () => {
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
<div className="flex flex-1 flex-col overflow-hidden px-4 pt-3"> <div className="flex flex-1 flex-col overflow-hidden px-4 pt-3">
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
@@ -587,8 +528,8 @@ export const AppointmentsPageV2 = () => {
{(appt) => { {(appt) => {
const name = getPatientName(appt); const name = getPatientName(appt);
const phone = getPhone(appt); const phone = getPhone(appt);
const statusLabel = STATUS_LABELS[appt.status ?? ''] ?? appt.status ?? '—'; const statusLabel = STATUS_LABELS[appt.appointmentStatus ?? ''] ?? appt.appointmentStatus ?? '—';
const statusColor = STATUS_COLORS[appt.status ?? ''] ?? 'gray'; const statusColor = STATUS_COLORS[appt.appointmentStatus ?? ''] ?? 'gray';
const isSelected = selectedAppt?.id === appt.id; const isSelected = selectedAppt?.id === appt.id;
return ( return (
@@ -597,16 +538,12 @@ export const AppointmentsPageV2 = () => {
className={cx('group/row cursor-pointer', isSelected && 'bg-brand-primary')} className={cx('group/row cursor-pointer', isSelected && 'bg-brand-primary')}
onAction={() => handleEditClick(appt)} onAction={() => handleEditClick(appt)}
> >
{/* Patient: name + phone on 2 lines */}
<Table.Cell> <Table.Cell>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium text-primary truncate">{name}</p> <p className="text-sm font-medium text-primary truncate">{name}</p>
{phone && <PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />} {phone && <PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />}
</div> </div>
</Table.Cell> </Table.Cell>
{/* Date & Time: date + time on 2 lines */}
<Table.Cell> <Table.Cell>
{appt.scheduledAt ? ( {appt.scheduledAt ? (
<div> <div>
@@ -615,23 +552,17 @@ export const AppointmentsPageV2 = () => {
</div> </div>
) : <span className="text-sm text-quaternary"></span>} ) : <span className="text-sm text-quaternary"></span>}
</Table.Cell> </Table.Cell>
{/* Doctor: name + department on 2 lines */}
<Table.Cell> <Table.Cell>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm text-primary truncate">{appt.doctorName ?? '—'}</p> <p className="text-sm text-primary truncate">{appt.doctorName ?? '—'}</p>
{appt.department && <p className="text-xs text-tertiary truncate">{appt.department}</p>} {appt.department && <p className="text-xs text-tertiary truncate">{appt.department}</p>}
</div> </div>
</Table.Cell> </Table.Cell>
{/* Status */}
<Table.Cell> <Table.Cell>
<Badge size="sm" color={statusColor} type="pill-color"> <Badge size="sm" color={statusColor} type="pill-color">
{statusLabel} {statusLabel}
</Badge> </Badge>
</Table.Cell> </Table.Cell>
</Table.Row> </Table.Row>
); );
}} }}
@@ -648,7 +579,6 @@ export const AppointmentsPageV2 = () => {
</div> </div>
</div> </div>
{/* Detail side panel */}
<div className={cx( <div className={cx(
"shrink-0 border-l border-secondary bg-primary flex flex-col overflow-hidden transition-all duration-200 ease-linear", "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 ? "w-[380px]" : "w-0 border-l-0",

View File

@@ -1,8 +1,5 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons';
import { PageHeader } from '@/components/layout/page-header'; import { PageHeader } from '@/components/layout/page-header';
import { AiChatPanel } from '@/components/call-desk/ai-chat-panel';
import { DashboardKpi } from '@/components/dashboard/kpi-cards'; import { DashboardKpi } from '@/components/dashboard/kpi-cards';
import { MissedQueue } from '@/components/dashboard/missed-queue'; import { MissedQueue } from '@/components/dashboard/missed-queue';
import { import {
@@ -29,7 +26,6 @@ const getDateRangeStart = (range: DateRange): Date => {
export const TeamDashboardPage = () => { export const TeamDashboardPage = () => {
const { calls, leads, campaigns, loading } = useData(); const { calls, leads, campaigns, loading } = useData();
const [dateRange, setDateRange] = useState<DateRange>('week'); const [dateRange, setDateRange] = useState<DateRange>('week');
const [aiOpen, setAiOpen] = useState(true);
// Pull the richer supervisor rollup (NPS, idle, time breakdown, alerts) // Pull the richer supervisor rollup (NPS, idle, time breakdown, alerts)
// from the sidecar. Only `today`/`week`/`month` overlap with the rollup's // from the sidecar. Only `today`/`week`/`month` overlap with the rollup's
@@ -61,29 +57,20 @@ export const TeamDashboardPage = () => {
subtitle={dateRangeLabel} subtitle={dateRangeLabel}
infoText="Aggregated call metrics, agent performance, and operational alerts." infoText="Aggregated call metrics, agent performance, and operational alerts."
controls={ controls={
<> <div className="flex rounded-lg border border-secondary overflow-hidden">
<div className="flex rounded-lg border border-secondary overflow-hidden"> {(['today', 'week', 'month'] as const).map((range) => (
{(['today', 'week', 'month'] as const).map((range) => ( <button
<button key={range}
key={range} onClick={() => setDateRange(range)}
onClick={() => setDateRange(range)} className={cx(
className={cx( "px-3 py-1 text-xs font-medium transition duration-100 ease-linear",
"px-3 py-1 text-xs font-medium transition duration-100 ease-linear", dateRange === range ? 'bg-active text-brand-secondary' : 'text-tertiary hover:bg-primary_hover',
dateRange === range ? 'bg-active text-brand-secondary' : 'text-tertiary hover:bg-primary_hover', )}
)} >
> {range === 'today' ? 'Today' : range === 'week' ? 'Week' : 'Month'}
{range === 'today' ? 'Today' : range === 'week' ? 'Week' : 'Month'} </button>
</button> ))}
))} </div>
</div>
<button
onClick={() => setAiOpen(!aiOpen)}
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={aiOpen ? 'Hide AI panel' : 'Show AI panel'}
>
<FontAwesomeIcon icon={aiOpen ? faSidebarFlip : faSidebar} className="size-4" />
</button>
</>
} }
/> />
@@ -154,17 +141,6 @@ export const TeamDashboardPage = () => {
</div> </div>
</div> </div>
{/* AI panel — collapsible */}
<div className={cx(
"shrink-0 border-l border-secondary bg-primary flex flex-col overflow-hidden transition-all duration-200 ease-linear",
aiOpen ? "w-[380px]" : "w-0 border-l-0",
)}>
{aiOpen && (
<div className="flex h-full flex-col p-4">
<AiChatPanel callerContext={{ type: 'supervisor' }} />
</div>
)}
</div>
</div> </div>
</div> </div>