mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
Compare commits
8 Commits
v0.10-apr-
...
fd7ee4fc1f
| Author | SHA1 | Date | |
|---|---|---|---|
| fd7ee4fc1f | |||
| e175735d6c | |||
| 5f3b455edc | |||
| a9d19af1d3 | |||
| b03d0f62cf | |||
| bdabcb2ea4 | |||
| 313842a922 | |||
| dfcaa175ab |
@@ -74,43 +74,33 @@ export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId, o
|
|||||||
{/* Clickable phone number — calls directly */}
|
{/* Clickable phone number — calls directly */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCall}
|
onClick={canCall ? handleCall : undefined}
|
||||||
onTouchStart={onTouchStart}
|
onTouchStart={onTouchStart}
|
||||||
onTouchEnd={onTouchEnd}
|
onTouchEnd={onTouchEnd}
|
||||||
onContextMenu={(e) => { e.preventDefault(); setMenuOpen(true); }}
|
onContextMenu={(e) => { e.preventDefault(); setMenuOpen(true); }}
|
||||||
disabled={!canCall}
|
|
||||||
className={cx(
|
className={cx(
|
||||||
'flex items-center gap-1.5 rounded-md px-1.5 py-1 text-sm transition duration-100 ease-linear',
|
'flex items-center gap-1.5 rounded-md px-1.5 py-1 text-sm text-brand-secondary transition duration-100 ease-linear',
|
||||||
canCall
|
canCall
|
||||||
? 'cursor-pointer text-brand-secondary hover:bg-brand-primary hover:text-brand-secondary'
|
? 'cursor-pointer hover:bg-brand-primary'
|
||||||
: 'cursor-default text-tertiary',
|
: 'cursor-default',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faPhone} className="size-3" />
|
<FontAwesomeIcon icon={faPhone} className="size-3" />
|
||||||
<span className="whitespace-nowrap">{displayNumber}</span>
|
<span className="whitespace-nowrap">{displayNumber}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Kebab menu trigger — desktop */}
|
{/* Kebab menu trigger — SMS + WhatsApp */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={(e) => { e.stopPropagation(); setMenuOpen(!menuOpen); }}
|
onClick={(e) => { e.stopPropagation(); setMenuOpen(!menuOpen); }}
|
||||||
className="flex size-6 items-center justify-center rounded-md text-fg-quaternary opacity-0 group-hover/row:opacity-100 hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
className="flex size-6 items-center justify-center rounded-md text-fg-quaternary hover:text-fg-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faEllipsisVertical} className="size-3" />
|
<FontAwesomeIcon icon={faEllipsisVertical} className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Context menu */}
|
{/* Context menu — SMS + WhatsApp only (dial is the primary click) */}
|
||||||
{menuOpen && (
|
{menuOpen && (
|
||||||
<div className="absolute left-0 top-full z-50 mt-1 w-40 rounded-lg bg-primary shadow-lg ring-1 ring-secondary py-1">
|
<div className="absolute left-0 top-full z-50 mt-1 w-40 rounded-lg bg-primary shadow-lg ring-1 ring-secondary py-1">
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCall}
|
|
||||||
disabled={!canCall}
|
|
||||||
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-secondary hover:bg-primary_hover disabled:text-disabled"
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faPhone} className="size-3.5 text-fg-success-secondary" />
|
|
||||||
Call
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSms}
|
onClick={handleSms}
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { faPhoneArrowDown, faPhoneArrowUp, faMagnifyingGlass } from '@fortawesome/pro-duotone-svg-icons';
|
import { faPhoneArrowDown, faPhoneArrowUp } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import type { SortDescriptor } from 'react-aria-components';
|
import type { SortDescriptor } from 'react-aria-components';
|
||||||
import { faIcon } from '@/lib/icon-wrapper';
|
import { faIcon } from '@/lib/icon-wrapper';
|
||||||
import { Table } from '@/components/application/table/table';
|
import { Table } from '@/components/application/table/table';
|
||||||
|
|
||||||
const SearchLg = faIcon(faMagnifyingGlass);
|
|
||||||
import { Badge } from '@/components/base/badges/badges';
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
import { Input } from '@/components/base/input/input';
|
|
||||||
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
|
|
||||||
import { PhoneActionCell } from './phone-action-cell';
|
import { PhoneActionCell } from './phone-action-cell';
|
||||||
import { formatPhone, formatTimeOnly, formatShortDate } from '@/lib/format';
|
import { formatPhone, formatTimeOnly, formatShortDate } from '@/lib/format';
|
||||||
import { notify } from '@/lib/toast';
|
import { notify } from '@/lib/toast';
|
||||||
@@ -79,6 +75,9 @@ interface WorklistPanelProps {
|
|||||||
onSelectItem: (selection: WorklistSelection) => void;
|
onSelectItem: (selection: WorklistSelection) => void;
|
||||||
selectedItemId: string | null;
|
selectedItemId: string | null;
|
||||||
onDialMissedCall?: (missedCallId: string) => void;
|
onDialMissedCall?: (missedCallId: string) => void;
|
||||||
|
// Lifted from internal state — owned by call-desk.tsx so the search
|
||||||
|
// input can live in the PageHeader row alongside other controls.
|
||||||
|
search: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TabKey = 'all' | 'missed' | 'leads' | 'follow-ups';
|
type TabKey = 'all' | 'missed' | 'leads' | 'follow-ups';
|
||||||
@@ -299,9 +298,8 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
|
|||||||
return actionableRows;
|
return actionableRows;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectItem, selectedItemId, onDialMissedCall }: WorklistPanelProps) => {
|
export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelectItem, selectedItemId, onDialMissedCall, search }: WorklistPanelProps) => {
|
||||||
const [tab, setTab] = useState<TabKey>('all');
|
const [tab, setTab] = useState<TabKey>('all');
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
// Missed tab always shows PENDING callbacks. Attempted/Completed/Invalid
|
// Missed tab always shows PENDING callbacks. Attempted/Completed/Invalid
|
||||||
// sub-tabs were removed per QA feedback — pending callbacks are the only
|
// sub-tabs were removed per QA feedback — pending callbacks are the only
|
||||||
// ones agents need to act on from the worklist.
|
// ones agents need to act on from the worklist.
|
||||||
@@ -402,8 +400,10 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
|||||||
const PAGE_SIZE = 15;
|
const PAGE_SIZE = 15;
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
// Reset page when search changes from parent
|
||||||
|
useEffect(() => { setPage(1); }, [search]);
|
||||||
|
|
||||||
const handleTabChange = useCallback((key: TabKey) => { setTab(key); setPage(1); }, []);
|
const handleTabChange = useCallback((key: TabKey) => { setTab(key); setPage(1); }, []);
|
||||||
const handleSearch = useCallback((value: string) => { setSearch(value); setPage(1); }, []);
|
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(filteredRows.length / PAGE_SIZE));
|
const totalPages = Math.max(1, Math.ceil(filteredRows.length / PAGE_SIZE));
|
||||||
const pagedRows = filteredRows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
const pagedRows = filteredRows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||||
@@ -436,23 +436,22 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||||
{/* Filter tabs + search */}
|
{/* Filter pills — custom buttons matching All Leads pattern */}
|
||||||
<div className="flex shrink-0 items-end justify-between border-b border-secondary px-5 pt-3 pb-0.5">
|
<div className="flex shrink-0 items-center gap-1.5 px-5 py-2">
|
||||||
<Tabs selectedKey={tab} onSelectionChange={(key) => handleTabChange(key as TabKey)}>
|
{tabItems.map((item) => (
|
||||||
<TabList items={tabItems} type="underline" size="sm">
|
<button
|
||||||
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
|
key={item.id}
|
||||||
</TabList>
|
onClick={() => handleTabChange(item.id)}
|
||||||
</Tabs>
|
className={cx(
|
||||||
<div className="w-44 shrink-0">
|
'shrink-0 rounded-full px-3 py-1 text-xs font-medium transition duration-100 ease-linear',
|
||||||
<Input
|
tab === item.id
|
||||||
placeholder="Search..."
|
? 'bg-brand-solid text-white'
|
||||||
icon={SearchLg}
|
: 'bg-secondary text-tertiary hover:text-secondary hover:bg-secondary_hover',
|
||||||
size="sm"
|
)}
|
||||||
value={search}
|
>
|
||||||
onChange={handleSearch}
|
{item.label}{item.badge ? ` (${item.badge})` : ''}
|
||||||
aria-label="Search worklist"
|
</button>
|
||||||
/>
|
))}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Missed-call sub-tabs removed per QA feedback — the Missed tab
|
{/* Missed-call sub-tabs removed per QA feedback — the Missed tab
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { useSip } from '@/providers/sip-provider';
|
|||||||
import { CallWidget } from '@/components/call-desk/call-widget';
|
import { CallWidget } from '@/components/call-desk/call-widget';
|
||||||
import { MaintOtpModal } from '@/components/modals/maint-otp-modal';
|
import { MaintOtpModal } from '@/components/modals/maint-otp-modal';
|
||||||
import { AgentStatusToggle } from '@/components/call-desk/agent-status-toggle';
|
import { AgentStatusToggle } from '@/components/call-desk/agent-status-toggle';
|
||||||
import { NotificationBell } from './notification-bell';
|
|
||||||
import { ResumeSetupBanner } from '@/components/setup/resume-setup-banner';
|
import { ResumeSetupBanner } from '@/components/setup/resume-setup-banner';
|
||||||
import { Badge } from '@/components/base/badges/badges';
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
import { useAuth } from '@/providers/auth-provider';
|
import { useAuth } from '@/providers/auth-provider';
|
||||||
@@ -25,7 +24,7 @@ interface AppShellProps {
|
|||||||
|
|
||||||
export const AppShell = ({ children }: AppShellProps) => {
|
export const AppShell = ({ children }: AppShellProps) => {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const { isCCAgent, isAdmin } = useAuth();
|
const { isCCAgent } = 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();
|
||||||
@@ -118,35 +117,25 @@ export const AppShell = ({ children }: AppShellProps) => {
|
|||||||
<div className="flex h-screen bg-primary">
|
<div className="flex h-screen bg-primary">
|
||||||
<Sidebar activeUrl={pathname} />
|
<Sidebar activeUrl={pathname} />
|
||||||
<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 */}
|
{/* Agent top bar — network indicator + status toggle (agents only) */}
|
||||||
{(hasAgentConfig || isAdmin) && (
|
{hasAgentConfig && (
|
||||||
<div className="flex shrink-0 items-center 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 hidden — navigation on result click
|
|
||||||
routes to Patient 360 with stale appointment state
|
|
||||||
from the call desk. Revisit when the Patient 360
|
|
||||||
route properly resets context on mount. (#4) */}
|
|
||||||
{/* <GlobalSearch /> */}
|
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
{isAdmin && <NotificationBell />}
|
<div className={cx(
|
||||||
{hasAgentConfig && (
|
'flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium',
|
||||||
<>
|
networkQuality === 'good'
|
||||||
<div className={cx(
|
? 'bg-success-primary text-success-primary'
|
||||||
'flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium',
|
: networkQuality === 'offline'
|
||||||
networkQuality === 'good'
|
? 'bg-error-secondary text-error-primary'
|
||||||
? 'bg-success-primary text-success-primary'
|
: 'bg-warning-secondary text-warning-primary',
|
||||||
: networkQuality === 'offline'
|
)}>
|
||||||
? 'bg-error-secondary text-error-primary'
|
<FontAwesomeIcon
|
||||||
: 'bg-warning-secondary text-warning-primary',
|
icon={networkQuality === 'offline' ? faWifiSlash : faWifi}
|
||||||
)}>
|
className="size-3"
|
||||||
<FontAwesomeIcon
|
/>
|
||||||
icon={networkQuality === 'offline' ? faWifiSlash : faWifi}
|
{networkQuality === 'good' ? 'Connected' : networkQuality === 'offline' ? 'No connection' : 'Unstable'}
|
||||||
className="size-3"
|
</div>
|
||||||
/>
|
<AgentStatusToggle isRegistered={isRegistered} connectionStatus={connectionStatus} />
|
||||||
{networkQuality === 'good' ? 'Connected' : networkQuality === 'offline' ? 'No connection' : 'Unstable'}
|
|
||||||
</div>
|
|
||||||
<AgentStatusToggle isRegistered={isRegistered} connectionStatus={connectionStatus} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
102
src/components/layout/page-header.tsx
Normal file
102
src/components/layout/page-header.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
// PageHeader — consistent header layout for all list pages.
|
||||||
|
//
|
||||||
|
// Row 1: Title (+ optional badge + info icon) on the left,
|
||||||
|
// controls (search, columns, export, etc.) on the right.
|
||||||
|
// Row 2: Optional tabs (underline style) — no extra borders.
|
||||||
|
//
|
||||||
|
// The `infoText` prop renders as a hoverable info icon (ⓘ) next to
|
||||||
|
// the title. Long descriptive text goes here instead of inline
|
||||||
|
// subtitle — keeps the header compact.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// <PageHeader
|
||||||
|
// title="Contacts"
|
||||||
|
// badge={16}
|
||||||
|
// infoText="People who reached out directly — phone, walk-in, referral."
|
||||||
|
// controls={<><Input .../> <Button .../></>}
|
||||||
|
// tabs={<Tabs ...><TabList ...>{...}</TabList></Tabs>}
|
||||||
|
// />
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect, type ReactNode } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faCircleInfo } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { NotificationBell } from './notification-bell';
|
||||||
|
import { useAuth } from '@/providers/auth-provider';
|
||||||
|
|
||||||
|
interface PageHeaderProps {
|
||||||
|
title: string;
|
||||||
|
badge?: number | string;
|
||||||
|
/** Short inline text next to badge — use sparingly (e.g. "17 total") */
|
||||||
|
subtitle?: string;
|
||||||
|
/** Longer descriptive text shown on info icon hover/click */
|
||||||
|
infoText?: string;
|
||||||
|
controls?: ReactNode;
|
||||||
|
tabs?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const InfoTooltip = ({ text }: { text: string }) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handler);
|
||||||
|
return () => document.removeEventListener('mousedown', handler);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={ref}>
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen(!open)}
|
||||||
|
onMouseEnter={() => setOpen(true)}
|
||||||
|
onMouseLeave={() => setOpen(false)}
|
||||||
|
className="flex size-5 items-center justify-center rounded-full text-fg-quaternary hover:text-fg-secondary transition duration-100 ease-linear"
|
||||||
|
title={text}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faCircleInfo} className="size-4" />
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute left-0 top-full mt-1 z-50 w-72 rounded-lg bg-primary px-3 py-2 text-xs text-tertiary shadow-lg ring-1 ring-secondary">
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PageHeader = ({ title, badge, subtitle, infoText, controls, tabs }: PageHeaderProps) => {
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
return (
|
||||||
|
<div className="shrink-0">
|
||||||
|
{/* Row 1: title + controls */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="text-lg font-bold text-primary">{title}</h1>
|
||||||
|
{badge != null && (
|
||||||
|
<span className="inline-flex items-center justify-center rounded-full bg-brand-secondary px-2 py-0.5 text-xs font-semibold text-white">
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{subtitle && (
|
||||||
|
<span className="text-sm text-tertiary ml-1">{subtitle}</span>
|
||||||
|
)}
|
||||||
|
{infoText && <InfoTooltip text={infoText} />}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{controls}
|
||||||
|
{isAdmin && <NotificationBell />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: optional tabs — no container border, tab underline is the separator */}
|
||||||
|
{tabs && (
|
||||||
|
<div className="px-6">
|
||||||
|
{tabs}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,17 +1,14 @@
|
|||||||
import type { FC } from 'react';
|
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { TableBody as AriaTableBody } from 'react-aria-components';
|
import { TableBody as AriaTableBody } from 'react-aria-components';
|
||||||
import type { SortDescriptor, Selection } from 'react-aria-components';
|
import type { SortDescriptor, Selection } from 'react-aria-components';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faEllipsisVertical } from '@fortawesome/pro-duotone-svg-icons';
|
import { faEye } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
|
||||||
const DotsVertical: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faEllipsisVertical} className={className} />;
|
|
||||||
import { Badge } from '@/components/base/badges/badges';
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
import { Button } from '@/components/base/buttons/button';
|
|
||||||
import { Table } from '@/components/application/table/table';
|
import { Table } from '@/components/application/table/table';
|
||||||
import { LeadStatusBadge } from '@/components/shared/status-badge';
|
import { LeadStatusBadge } from '@/components/shared/status-badge';
|
||||||
import { SourceTag } from '@/components/shared/source-tag';
|
import { SourceTag } from '@/components/shared/source-tag';
|
||||||
import { AgeIndicator } from '@/components/shared/age-indicator';
|
import { AgeIndicator } from '@/components/shared/age-indicator';
|
||||||
|
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
||||||
import { formatPhone, formatShortDate } from '@/lib/format';
|
import { formatPhone, formatShortDate } from '@/lib/format';
|
||||||
import { cx } from '@/utils/cx';
|
import { cx } from '@/utils/cx';
|
||||||
import type { Lead } from '@/types/entities';
|
import type { Lead } from '@/types/entities';
|
||||||
@@ -97,6 +94,7 @@ export const LeadTable = ({
|
|||||||
}, [leads, expandedDupId]);
|
}, [leads, expandedDupId]);
|
||||||
|
|
||||||
const allColumns = [
|
const allColumns = [
|
||||||
|
{ id: 'view', label: '', allowsSorting: false, defaultWidth: 40 },
|
||||||
{ id: 'phone', label: 'Phone', allowsSorting: true, defaultWidth: 150 },
|
{ id: 'phone', label: 'Phone', allowsSorting: true, defaultWidth: 150 },
|
||||||
{ id: 'name', label: 'Name', allowsSorting: true, defaultWidth: 160 },
|
{ id: 'name', label: 'Name', allowsSorting: true, defaultWidth: 160 },
|
||||||
{ id: 'email', label: 'Email', allowsSorting: false, defaultWidth: 180 },
|
{ id: 'email', label: 'Email', allowsSorting: false, defaultWidth: 180 },
|
||||||
@@ -109,11 +107,10 @@ export const LeadTable = ({
|
|||||||
{ id: 'createdAt', label: 'Age', allowsSorting: true, defaultWidth: 80 },
|
{ id: 'createdAt', label: 'Age', allowsSorting: true, defaultWidth: 80 },
|
||||||
{ id: 'spamScore', label: 'Spam', allowsSorting: true, defaultWidth: 70 },
|
{ id: 'spamScore', label: 'Spam', allowsSorting: true, defaultWidth: 70 },
|
||||||
{ id: 'dups', label: 'Dups', allowsSorting: false, defaultWidth: 60 },
|
{ id: 'dups', label: 'Dups', allowsSorting: false, defaultWidth: 60 },
|
||||||
{ id: 'actions', label: '', allowsSorting: false, defaultWidth: 50 },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const columns = visibleColumns
|
const columns = visibleColumns
|
||||||
? allColumns.filter(c => visibleColumns.has(c.id) || c.id === 'actions')
|
? allColumns.filter(c => visibleColumns.has(c.id) || c.id === 'view')
|
||||||
: allColumns;
|
: allColumns;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -145,6 +142,7 @@ export const LeadTable = ({
|
|||||||
const firstName = lead.contactName?.firstName ?? '';
|
const firstName = lead.contactName?.firstName ?? '';
|
||||||
const lastName = lead.contactName?.lastName ?? '';
|
const lastName = lead.contactName?.lastName ?? '';
|
||||||
const name = `${firstName} ${lastName}`.trim() || '\u2014';
|
const name = `${firstName} ${lastName}`.trim() || '\u2014';
|
||||||
|
const phoneRaw = lead.contactPhone?.[0]?.number ?? '';
|
||||||
const phone = lead.contactPhone?.[0]
|
const phone = lead.contactPhone?.[0]
|
||||||
? formatPhone(lead.contactPhone[0])
|
? formatPhone(lead.contactPhone[0])
|
||||||
: '\u2014';
|
: '\u2014';
|
||||||
@@ -158,6 +156,7 @@ export const LeadTable = ({
|
|||||||
id={row.id}
|
id={row.id}
|
||||||
className="bg-warning-primary"
|
className="bg-warning-primary"
|
||||||
>
|
>
|
||||||
|
<Table.Cell />
|
||||||
<Table.Cell className="pl-10">
|
<Table.Cell className="pl-10">
|
||||||
<span className="text-xs text-tertiary">{phone}</span>
|
<span className="text-xs text-tertiary">{phone}</span>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
@@ -191,17 +190,6 @@ export const LeadTable = ({
|
|||||||
<Table.Cell />
|
<Table.Cell />
|
||||||
<Table.Cell />
|
<Table.Cell />
|
||||||
<Table.Cell />
|
<Table.Cell />
|
||||||
<Table.Cell />
|
|
||||||
<Table.Cell>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button size="sm" color="primary">
|
|
||||||
Merge
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" color="secondary">
|
|
||||||
Keep Separate
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -219,12 +207,26 @@ export const LeadTable = ({
|
|||||||
key={row.id}
|
key={row.id}
|
||||||
id={row.id}
|
id={row.id}
|
||||||
className={cx(
|
className={cx(
|
||||||
|
'group/row',
|
||||||
isSpamRow && !isSelected && 'bg-warning-primary',
|
isSpamRow && !isSelected && 'bg-warning-primary',
|
||||||
isSelected && 'bg-brand-primary',
|
isSelected && 'bg-brand-primary',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<Table.Cell>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); onViewActivity?.(lead); }}
|
||||||
|
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"
|
||||||
|
title="View details"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faEye} className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</Table.Cell>
|
||||||
{isCol('phone') && <Table.Cell>
|
{isCol('phone') && <Table.Cell>
|
||||||
<span className="font-semibold text-primary">{phone}</span>
|
{phoneRaw ? (
|
||||||
|
<PhoneActionCell phoneNumber={phoneRaw} displayNumber={phone} />
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-quaternary">{'\u2014'}</span>
|
||||||
|
)}
|
||||||
</Table.Cell>}
|
</Table.Cell>}
|
||||||
{isCol('name') && <Table.Cell>
|
{isCol('name') && <Table.Cell>
|
||||||
<span className="text-secondary">{name}</span>
|
<span className="text-secondary">{name}</span>
|
||||||
@@ -308,15 +310,6 @@ export const LeadTable = ({
|
|||||||
<span className="text-tertiary">0</span>
|
<span className="text-tertiary">0</span>
|
||||||
)}
|
)}
|
||||||
</Table.Cell>}
|
</Table.Cell>}
|
||||||
<Table.Cell>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
color="tertiary"
|
|
||||||
iconLeading={DotsVertical}
|
|
||||||
aria-label="Row actions"
|
|
||||||
onClick={() => onViewActivity?.(lead)}
|
|
||||||
/>
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type SectionCardProps = {
|
|||||||
href?: string;
|
href?: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
status?: SectionStatus;
|
status?: SectionStatus;
|
||||||
|
disabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Settings hub card. Each card represents one setup-able section (Branding,
|
// Settings hub card. Each card represents one setup-able section (Branding,
|
||||||
@@ -30,26 +31,32 @@ export const SectionCard = ({
|
|||||||
href,
|
href,
|
||||||
onClick,
|
onClick,
|
||||||
status = 'unknown',
|
status = 'unknown',
|
||||||
|
disabled = false,
|
||||||
}: SectionCardProps) => {
|
}: SectionCardProps) => {
|
||||||
const className = cx(
|
const className = cx(
|
||||||
'group block w-full text-left rounded-xl border border-secondary bg-primary p-5 shadow-xs transition hover:border-brand hover:shadow-md',
|
'group block w-full text-left rounded-xl border border-secondary p-5 shadow-xs transition',
|
||||||
|
disabled
|
||||||
|
? 'cursor-not-allowed opacity-50 bg-disabled_subtle'
|
||||||
|
: 'bg-primary hover:border-brand hover:shadow-md',
|
||||||
);
|
);
|
||||||
const body = (
|
const body = (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-secondary">
|
<div className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-secondary">
|
||||||
<FontAwesomeIcon icon={icon} className={cx('size-5', iconColor)} />
|
<FontAwesomeIcon icon={icon} className={cx('size-5', disabled ? 'text-fg-disabled' : iconColor)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h3 className="text-sm font-semibold text-primary">{title}</h3>
|
<h3 className={cx('text-sm font-semibold', disabled ? 'text-disabled' : 'text-primary')}>{title}</h3>
|
||||||
<p className="mt-1 text-xs text-tertiary">{description}</p>
|
<p className="mt-1 text-xs text-tertiary">{description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<FontAwesomeIcon
|
{!disabled && (
|
||||||
icon={faArrowRight}
|
<FontAwesomeIcon
|
||||||
className="size-4 shrink-0 text-quaternary transition group-hover:translate-x-0.5 group-hover:text-brand-primary"
|
icon={faArrowRight}
|
||||||
/>
|
className="size-4 shrink-0 text-quaternary transition group-hover:translate-x-0.5 group-hover:text-brand-primary"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{status !== 'unknown' && (
|
{status !== 'unknown' && (
|
||||||
@@ -70,6 +77,13 @@ export const SectionCard = ({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (disabled) {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (onClick) {
|
if (onClick) {
|
||||||
return (
|
return (
|
||||||
<button type="button" onClick={onClick} className={className}>
|
<button type="button" onClick={onClick} className={className}>
|
||||||
|
|||||||
@@ -11,8 +11,7 @@ import { Input } from '@/components/base/input/input';
|
|||||||
// Tabs removed — campaign pills handle all filtering now
|
// Tabs removed — campaign pills handle all filtering now
|
||||||
// import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
|
// import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
|
||||||
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
|
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
|
||||||
// TopBar replaced by inline header
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
// import { TopBar } from '@/components/layout/top-bar';
|
|
||||||
import { LeadTable } from '@/components/leads/lead-table';
|
import { LeadTable } from '@/components/leads/lead-table';
|
||||||
import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle';
|
import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle';
|
||||||
// Bulk actions removed — checkboxes hidden
|
// Bulk actions removed — checkboxes hidden
|
||||||
@@ -244,37 +243,37 @@ export const AllLeadsPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
{/* Header with controls inline */}
|
<PageHeader
|
||||||
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-6 py-3">
|
title="All Leads"
|
||||||
<div>
|
subtitle={`${total} total`}
|
||||||
<h1 className="text-lg font-bold text-primary">All Leads</h1>
|
infoText="Campaign-sourced marketing leads. Organic callers are on the Contacts page."
|
||||||
<p className="text-xs text-tertiary">{total} total</p>
|
controls={
|
||||||
</div>
|
<>
|
||||||
<div className="flex items-center gap-2">
|
<div className="w-56">
|
||||||
<div className="w-56">
|
<Input
|
||||||
<Input
|
placeholder="Search leads..."
|
||||||
placeholder="Search leads..."
|
icon={SearchLg}
|
||||||
icon={SearchLg}
|
size="sm"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(value) => {
|
||||||
|
setSearchQuery(value);
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
aria-label="Search leads"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} />
|
||||||
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
value={searchQuery}
|
color="secondary"
|
||||||
onChange={(value) => {
|
iconLeading={Download01}
|
||||||
setSearchQuery(value);
|
onClick={handleExportCsv}
|
||||||
setCurrentPage(1);
|
>
|
||||||
}}
|
Export CSV
|
||||||
aria-label="Search leads"
|
</Button>
|
||||||
/>
|
</>
|
||||||
</div>
|
}
|
||||||
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} />
|
/>
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
color="secondary"
|
|
||||||
iconLeading={Download01}
|
|
||||||
onClick={handleExportCsv}
|
|
||||||
>
|
|
||||||
Export CSV
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,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';
|
||||||
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
|
|
||||||
// TopBar replaced by inline header
|
// 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';
|
||||||
@@ -20,6 +19,7 @@ import { Select } from '@/components/base/select/select';
|
|||||||
import { DatePicker } from '@/components/application/date-picker/date-picker';
|
import { DatePicker } from '@/components/application/date-picker/date-picker';
|
||||||
import { parseDate, today, getLocalTimeZone } from '@internationalized/date';
|
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 { formatPhone, formatDateOnly, formatTimeOnly } from '@/lib/format';
|
import { formatPhone, formatDateOnly, formatTimeOnly } from '@/lib/format';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { notify } from '@/lib/toast';
|
import { notify } from '@/lib/toast';
|
||||||
@@ -551,33 +551,44 @@ export const AppointmentsPageV2 = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Header with search inline */}
|
<PageHeader
|
||||||
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-6 py-3">
|
title="Appointments"
|
||||||
<div>
|
badge={filtered.length}
|
||||||
<h1 className="text-lg font-bold text-primary">Appointments</h1>
|
infoText="All scheduled, completed, cancelled, and rescheduled appointments. Click the eye icon to view details or reschedule."
|
||||||
</div>
|
controls={
|
||||||
<div className="w-56">
|
<div className="w-56">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search patient, doctor..."
|
placeholder="Search patient, doctor..."
|
||||||
icon={SearchLg}
|
icon={SearchLg}
|
||||||
size="sm"
|
size="sm"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={setSearch}
|
onChange={setSearch}
|
||||||
aria-label="Search appointments"
|
aria-label="Search appointments"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
|
tabs={
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{tabItems.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => setTab(item.id)}
|
||||||
|
className={cx(
|
||||||
|
'shrink-0 rounded-full px-3 py-1 text-xs font-medium transition duration-100 ease-linear',
|
||||||
|
tab === item.id
|
||||||
|
? 'bg-brand-solid text-white'
|
||||||
|
: 'bg-secondary text-tertiary hover:text-secondary hover:bg-secondary_hover',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.label}{item.badge ? ` ${item.badge}` : ''}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<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">
|
||||||
{/* Tabs */}
|
|
||||||
<div className="flex shrink-0 items-end px-6 pt-2 pb-0.5">
|
|
||||||
<Tabs selectedKey={tab} onSelectionChange={(key) => setTab(key as StatusTab)}>
|
|
||||||
<TabList items={tabItems} type="underline" size="sm">
|
|
||||||
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
|
|
||||||
</TabList>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<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 ? (
|
||||||
@@ -610,7 +621,7 @@ export const AppointmentsPageV2 = () => {
|
|||||||
return (
|
return (
|
||||||
<Table.Row
|
<Table.Row
|
||||||
id={appt.id}
|
id={appt.id}
|
||||||
className={cx(isSelected && 'bg-brand-primary')}
|
className={cx('group/row', isSelected && 'bg-brand-primary')}
|
||||||
>
|
>
|
||||||
{/* Eye icon — first column */}
|
{/* Eye icon — first column */}
|
||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
@@ -627,7 +638,7 @@ export const AppointmentsPageV2 = () => {
|
|||||||
<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 && <p className="text-xs text-tertiary">{formatPhone({ number: phone, callingCode: '+91' })}</p>}
|
{phone && <PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />}
|
||||||
</div>
|
</div>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faSidebarFlip, faSidebar, faPhone, faXmark, faDeleteLeft, faFlask } from '@fortawesome/pro-duotone-svg-icons';
|
import { faSidebarFlip, faSidebar, faPhone, faXmark, faDeleteLeft, faFlask, faMagnifyingGlass, faCircleInfo } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { useSetAtom } from 'jotai';
|
import { useSetAtom } from 'jotai';
|
||||||
import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom, sipCallDurationAtom } from '@/state/sip-state';
|
import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom, sipCallDurationAtom } from '@/state/sip-state';
|
||||||
import { useAuth } from '@/providers/auth-provider';
|
import { useAuth } from '@/providers/auth-provider';
|
||||||
@@ -12,11 +12,15 @@ import type { WorklistSelection } from '@/components/call-desk/worklist-panel';
|
|||||||
import { ContextPanel } from '@/components/call-desk/context-panel';
|
import { ContextPanel } from '@/components/call-desk/context-panel';
|
||||||
import type { ContextPanelSubject } from '@/components/call-desk/context-panel';
|
import type { ContextPanelSubject } from '@/components/call-desk/context-panel';
|
||||||
import { ActiveCallCard } from '@/components/call-desk/active-call-card';
|
import { ActiveCallCard } from '@/components/call-desk/active-call-card';
|
||||||
|
import { Input } from '@/components/base/input/input';
|
||||||
|
import { faIcon } from '@/lib/icon-wrapper';
|
||||||
|
|
||||||
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';
|
||||||
|
|
||||||
|
const SearchLg = faIcon(faMagnifyingGlass);
|
||||||
|
|
||||||
export const CallDeskPage = () => {
|
export const CallDeskPage = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { leadActivities, calls, followUps: dataFollowUps, patients, appointments } = useData();
|
const { leadActivities, calls, followUps: dataFollowUps, patients, appointments } = useData();
|
||||||
@@ -30,6 +34,7 @@ export const CallDeskPage = () => {
|
|||||||
const [diallerOpen, setDiallerOpen] = useState(false);
|
const [diallerOpen, setDiallerOpen] = useState(false);
|
||||||
const [dialNumber, setDialNumber] = useState('');
|
const [dialNumber, setDialNumber] = useState('');
|
||||||
const [dialling, setDialling] = useState(false);
|
const [dialling, setDialling] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
// DEV: simulate incoming call
|
// DEV: simulate incoming call
|
||||||
const setSimCallState = useSetAtom(sipCallStateAtom);
|
const setSimCallState = useSetAtom(sipCallStateAtom);
|
||||||
@@ -166,14 +171,29 @@ export const CallDeskPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
{/* Compact header: title + name on left, status + toggle on right */}
|
{/* Header — matches PageHeader visual pattern */}
|
||||||
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-6 py-3">
|
<div className="flex shrink-0 items-center justify-between px-6 py-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2">
|
||||||
<h1 className="text-lg font-bold text-primary">Call Desk</h1>
|
<h1 className="text-lg font-bold text-primary">Call Desk</h1>
|
||||||
<span className="text-sm text-tertiary">{user.name}</span>
|
<span className="text-sm text-tertiary ml-1">{user.name}</span>
|
||||||
|
<span className="flex size-5 items-center justify-center text-fg-quaternary" title="Your active worklist — missed calls, leads, and follow-ups prioritised by SLA.">
|
||||||
|
<FontAwesomeIcon icon={faCircleInfo} className="size-4" />
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{!isInCall && (
|
||||||
|
<div className="w-52">
|
||||||
|
<Input
|
||||||
|
placeholder="Search worklist..."
|
||||||
|
icon={SearchLg}
|
||||||
|
size="sm"
|
||||||
|
value={search}
|
||||||
|
onChange={setSearch}
|
||||||
|
aria-label="Search worklist"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{import.meta.env.DEV && (!isInCall ? (
|
{import.meta.env.DEV && (!isInCall ? (
|
||||||
<button
|
<button
|
||||||
onClick={startSimCall}
|
onClick={startSimCall}
|
||||||
@@ -283,6 +303,7 @@ export const CallDeskPage = () => {
|
|||||||
onSelectItem={handleSelectItem}
|
onSelectItem={handleSelectItem}
|
||||||
selectedItemId={selectedItemId}
|
selectedItemId={selectedItemId}
|
||||||
onDialMissedCall={(id) => setActiveMissedCallId(id)}
|
onDialMissedCall={(id) => setActiveMissedCallId(id)}
|
||||||
|
search={search}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,11 +11,12 @@ import {
|
|||||||
} from '@fortawesome/pro-duotone-svg-icons';
|
} from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
|
||||||
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faMagnifyingGlass} className={className} />;
|
||||||
import { Table, TableCard } from '@/components/application/table/table';
|
import { Table } from '@/components/application/table/table';
|
||||||
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 { Input } from '@/components/base/input/input';
|
import { Input } from '@/components/base/input/input';
|
||||||
import { Select } from '@/components/base/select/select';
|
import { Select } from '@/components/base/select/select';
|
||||||
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
||||||
import { formatShortDate, formatPhone } from '@/lib/format';
|
import { formatShortDate, formatPhone } from '@/lib/format';
|
||||||
// cx removed — no longer used after SLA column removal
|
// cx removed — no longer used after SLA column removal
|
||||||
@@ -189,44 +190,44 @@ export const CallHistoryPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<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 p-7">
|
<PageHeader
|
||||||
<TableCard.Root size="md" className="flex-1 min-h-0">
|
title={isAdmin ? 'Call History' : 'My Call History'}
|
||||||
<TableCard.Header
|
badge={filteredCalls.length}
|
||||||
title={isAdmin ? 'Call History' : 'My Call History'}
|
subtitle={isAdmin ? `${completedCount} completed \u00B7 ${missedCount} missed` : `${completedCount} completed`}
|
||||||
badge={String(filteredCalls.length)}
|
infoText={isAdmin ? 'All calls across all agents with recordings and dispositions.' : 'Your answered inbound and outbound calls.'}
|
||||||
description={isAdmin ? `${completedCount} completed \u00B7 ${missedCount} missed` : `${completedCount} completed`}
|
controls={
|
||||||
contentTrailing={
|
<>
|
||||||
<div className="flex items-center gap-2">
|
<div className="w-44">
|
||||||
<div className="w-44">
|
<Select
|
||||||
<Select
|
size="sm"
|
||||||
size="sm"
|
placeholder="All Calls"
|
||||||
placeholder="All Calls"
|
selectedKey={filter}
|
||||||
selectedKey={filter}
|
onSelectionChange={(key) => setFilter(key as FilterKey)}
|
||||||
onSelectionChange={(key) => setFilter(key as FilterKey)}
|
items={isAdmin ? allFilterItems : agentFilterItems}
|
||||||
items={isAdmin ? allFilterItems : agentFilterItems}
|
aria-label="Filter calls"
|
||||||
aria-label="Filter calls"
|
>
|
||||||
>
|
{(item) => (
|
||||||
{(item) => (
|
<Select.Item id={item.id} label={item.label}>
|
||||||
<Select.Item id={item.id} label={item.label}>
|
{item.label}
|
||||||
{item.label}
|
</Select.Item>
|
||||||
</Select.Item>
|
)}
|
||||||
)}
|
</Select>
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="w-56">
|
|
||||||
<Input
|
|
||||||
placeholder="Search calls..."
|
|
||||||
icon={SearchLg}
|
|
||||||
size="sm"
|
|
||||||
value={search}
|
|
||||||
onChange={(value) => setSearch(value)}
|
|
||||||
aria-label="Search calls"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
<div className="w-56">
|
||||||
/>
|
<Input
|
||||||
|
placeholder="Search calls..."
|
||||||
|
icon={SearchLg}
|
||||||
|
size="sm"
|
||||||
|
value={search}
|
||||||
|
onChange={(value) => setSearch(value)}
|
||||||
|
aria-label="Search calls"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3">
|
||||||
{filteredCalls.length === 0 ? (
|
{filteredCalls.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-16">
|
<div className="flex flex-col items-center justify-center py-16">
|
||||||
<h3 className="text-sm font-semibold text-primary">No calls found</h3>
|
<h3 className="text-sm font-semibold text-primary">No calls found</h3>
|
||||||
@@ -314,15 +315,16 @@ export const CallHistoryPage = () => {
|
|||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table>
|
</Table>
|
||||||
)}
|
)}
|
||||||
<div className="shrink-0">
|
|
||||||
<PaginationCardDefault
|
|
||||||
page={page}
|
|
||||||
total={totalPages}
|
|
||||||
onPageChange={setPage}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TableCard.Root>
|
|
||||||
</div>
|
</div>
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="shrink-0 border-t border-secondary px-6 py-3">
|
||||||
|
<PaginationCardDefault
|
||||||
|
page={page}
|
||||||
|
total={totalPages}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ 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 { PaginationPageDefault } from '@/components/application/pagination/pagination';
|
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
|
||||||
import { TopBar } from '@/components/layout/top-bar';
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle';
|
import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle';
|
||||||
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
||||||
import { RecordingAnalysisSlideout } from '@/components/call-desk/recording-analysis';
|
import { RecordingAnalysisSlideout } from '@/components/call-desk/recording-analysis';
|
||||||
@@ -26,6 +26,7 @@ type RecordingRecord = {
|
|||||||
callStatus: string | null;
|
callStatus: string | null;
|
||||||
callerNumber: { primaryPhoneNumber: string } | null;
|
callerNumber: { primaryPhoneNumber: string } | null;
|
||||||
agentName: string | null;
|
agentName: string | null;
|
||||||
|
agent: { id: string; name: string; ozonetelAgentId: string } | null;
|
||||||
startedAt: string | null;
|
startedAt: string | null;
|
||||||
durationSec: number | null;
|
durationSec: number | null;
|
||||||
disposition: string | null;
|
disposition: string | null;
|
||||||
@@ -35,7 +36,8 @@ type RecordingRecord = {
|
|||||||
|
|
||||||
const QUERY = `{ calls(first: 200, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
const QUERY = `{ calls(first: 200, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
||||||
id createdAt direction callStatus callerNumber { primaryPhoneNumber }
|
id createdAt direction callStatus callerNumber { primaryPhoneNumber }
|
||||||
agentName startedAt durationSec disposition sla
|
agentName agent { id name ozonetelAgentId }
|
||||||
|
startedAt durationSec disposition sla
|
||||||
recording { primaryLinkUrl primaryLinkLabel }
|
recording { primaryLinkUrl primaryLinkLabel }
|
||||||
} } } }`;
|
} } } }`;
|
||||||
|
|
||||||
@@ -109,7 +111,7 @@ export const CallRecordingsPage = () => {
|
|||||||
const dirColor: 'blue' | 'brand' = call.direction === 'INBOUND' ? 'blue' : 'brand';
|
const dirColor: 'blue' | 'brand' = call.direction === 'INBOUND' ? 'blue' : 'brand';
|
||||||
switch (colId) {
|
switch (colId) {
|
||||||
case 'agent':
|
case 'agent':
|
||||||
return <span className="text-sm text-primary">{call.agentName || '—'}</span>;
|
return <span className="text-sm text-primary">{call.agent?.name ?? call.agentName ?? '—'}</span>;
|
||||||
case 'caller':
|
case 'caller':
|
||||||
return phone
|
return phone
|
||||||
? <PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
|
? <PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
|
||||||
@@ -207,7 +209,7 @@ export const CallRecordingsPage = () => {
|
|||||||
if (search.trim()) {
|
if (search.trim()) {
|
||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
result = result.filter(c =>
|
result = result.filter(c =>
|
||||||
(c.agentName ?? '').toLowerCase().includes(q) ||
|
(c.agent?.name ?? c.agentName ?? '').toLowerCase().includes(q) ||
|
||||||
(c.callerNumber?.primaryPhoneNumber ?? '').includes(q) ||
|
(c.callerNumber?.primaryPhoneNumber ?? '').includes(q) ||
|
||||||
(c.disposition ?? '').toLowerCase().includes(q),
|
(c.disposition ?? '').toLowerCase().includes(q),
|
||||||
);
|
);
|
||||||
@@ -217,7 +219,7 @@ export const CallRecordingsPage = () => {
|
|||||||
const dir = sortDescriptor.direction === 'ascending' ? 1 : -1;
|
const dir = sortDescriptor.direction === 'ascending' ? 1 : -1;
|
||||||
result = [...result].sort((a, b) => {
|
result = [...result].sort((a, b) => {
|
||||||
switch (sortDescriptor.column) {
|
switch (sortDescriptor.column) {
|
||||||
case 'agent': return (a.agentName ?? '').localeCompare(b.agentName ?? '') * dir;
|
case 'agent': return (a.agent?.name ?? a.agentName ?? '').localeCompare(b.agent?.name ?? b.agentName ?? '') * dir;
|
||||||
case 'dateTime': {
|
case 'dateTime': {
|
||||||
const ta = a.startedAt ? new Date(a.startedAt).getTime() : 0;
|
const ta = a.startedAt ? new Date(a.startedAt).getTime() : 0;
|
||||||
const tb = b.startedAt ? new Date(b.startedAt).getTime() : 0;
|
const tb = b.startedAt ? new Date(b.startedAt).getTime() : 0;
|
||||||
@@ -238,19 +240,20 @@ export const CallRecordingsPage = () => {
|
|||||||
const handleSearch = (val: string) => { setSearch(val); setCurrentPage(1); };
|
const handleSearch = (val: string) => { setSearch(val); setCurrentPage(1); };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
<TopBar title="Call Recordings" />
|
<PageHeader
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
title="Call Recordings"
|
||||||
{/* Toolbar */}
|
badge={filtered.length}
|
||||||
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-6 py-3">
|
infoText="All call recordings with AI analysis, dispositions, and playback."
|
||||||
<span className="text-sm text-tertiary">{filtered.length} recordings</span>
|
controls={
|
||||||
<div className="flex items-center gap-3">
|
<>
|
||||||
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} />
|
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} />
|
||||||
<div className="w-56">
|
<div className="w-56">
|
||||||
<Input placeholder="Search agent, phone, disposition..." icon={SearchLg} size="sm" value={search} onChange={handleSearch} />
|
<Input placeholder="Search agent, phone, disposition..." icon={SearchLg} size="sm" value={search} onChange={handleSearch} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3">
|
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3">
|
||||||
@@ -264,7 +267,7 @@ export const CallRecordingsPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-1 flex-col min-h-0 overflow-auto">
|
<div className="flex flex-1 flex-col min-h-0 overflow-auto">
|
||||||
<Table size="sm" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor}>
|
<Table key={Array.from(visibleColumns).sort().join(',')} size="sm" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor}>
|
||||||
<Table.Header columns={activeColumns}>
|
<Table.Header columns={activeColumns}>
|
||||||
{(col) => (
|
{(col) => (
|
||||||
<Table.Head
|
<Table.Head
|
||||||
@@ -278,7 +281,7 @@ export const CallRecordingsPage = () => {
|
|||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body items={pagedRows}>
|
<Table.Body items={pagedRows}>
|
||||||
{(call) => (
|
{(call) => (
|
||||||
<Table.Row id={call.id} columns={activeColumns}>
|
<Table.Row id={call.id} columns={activeColumns} className="group/row">
|
||||||
{(col) => (
|
{(col) => (
|
||||||
<Table.Cell key={col.id}>
|
<Table.Cell key={col.id}>
|
||||||
{renderRecordingCell(call, col.id)}
|
{renderRecordingCell(call, col.id)}
|
||||||
@@ -322,7 +325,6 @@ export const CallRecordingsPage = () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ export const CampaignsPage = () => {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
|
isDisabled
|
||||||
iconLeading={({ className }: { className?: string }) => (
|
iconLeading={({ className }: { className?: string }) => (
|
||||||
<FontAwesomeIcon icon={faPenToSquare} className={className} />
|
<FontAwesomeIcon icon={faPenToSquare} className={className} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon
|
|||||||
import { Button } from '@/components/base/buttons/button';
|
import { Button } from '@/components/base/buttons/button';
|
||||||
import { Input } from '@/components/base/input/input';
|
import { Input } from '@/components/base/input/input';
|
||||||
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
|
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
|
||||||
import { TopBar } from '@/components/layout/top-bar';
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
import { LeadTable } from '@/components/leads/lead-table';
|
import { LeadTable } from '@/components/leads/lead-table';
|
||||||
import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle';
|
import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle';
|
||||||
import { LeadActivitySlideout } from '@/components/leads/lead-activity-slideout';
|
import { LeadActivitySlideout } from '@/components/leads/lead-activity-slideout';
|
||||||
@@ -113,14 +113,12 @@ export const ContactsPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
<TopBar title="Contacts" subtitle={`${contacts.length} organic callers`} />
|
<PageHeader
|
||||||
|
title="Contacts"
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
badge={contacts.length}
|
||||||
<div className="flex shrink-0 items-center justify-between px-6 py-3 border-b border-secondary">
|
infoText="People who reached out directly — phone, walk-in, referral. Not sourced from campaigns."
|
||||||
<p className="text-xs text-tertiary">
|
controls={
|
||||||
People who reached out directly — phone, walk-in, referral. Not sourced from campaigns.
|
<>
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-56">
|
<div className="w-56">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search contacts..."
|
placeholder="Search contacts..."
|
||||||
@@ -135,9 +133,11 @@ export const ContactsPage = () => {
|
|||||||
<Button size="sm" color="secondary" iconLeading={Download01} onClick={handleExportCsv}>
|
<Button size="sm" color="secondary" iconLeading={Download01} onClick={handleExportCsv}>
|
||||||
Export CSV
|
Export CSV
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
<div className="flex-1 overflow-y-auto px-4 pt-3">
|
<div className="flex-1 overflow-y-auto px-4 pt-3">
|
||||||
<LeadTable
|
<LeadTable
|
||||||
leads={paged}
|
leads={paged}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
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 { faHeadset, faPhoneVolume, faPause, faClock, faSparkles, faCalendarCheck, faClockRotateLeft } from '@fortawesome/pro-duotone-svg-icons';
|
import { faHeadset, faPhoneVolume, faPause, faClock, faSparkles, faCalendarCheck, faClockRotateLeft } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { TopBar } from '@/components/layout/top-bar';
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
import { Badge } from '@/components/base/badges/badges';
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
import { Table } from '@/components/application/table/table';
|
import { Table } from '@/components/application/table/table';
|
||||||
import { BargeControls } from '@/components/call-desk/barge-controls';
|
import { BargeControls } from '@/components/call-desk/barge-controls';
|
||||||
@@ -55,26 +55,48 @@ export const LiveMonitorPage = () => {
|
|||||||
const [contextLoading, setContextLoading] = useState(false);
|
const [contextLoading, setContextLoading] = useState(false);
|
||||||
const { leads } = useData();
|
const { leads } = useData();
|
||||||
|
|
||||||
// Poll active calls every 5 seconds
|
// Initial load + SSE stream for real-time active call updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchCalls = () => {
|
// Initial snapshot
|
||||||
apiClient.get<ActiveCall[]>('/api/supervisor/active-calls', { silent: true })
|
apiClient.get<ActiveCall[]>('/api/supervisor/active-calls', { silent: true })
|
||||||
.then(calls => {
|
.then(setActiveCalls)
|
||||||
setActiveCalls(calls);
|
.catch(() => {})
|
||||||
// If selected call ended, clear selection
|
.finally(() => setLoading(false));
|
||||||
if (selectedCall && !calls.find(c => c.ucid === selectedCall.ucid)) {
|
|
||||||
setSelectedCall(null);
|
|
||||||
setCallerContext(null);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {})
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchCalls();
|
// SSE stream — receives update/remove events in real-time
|
||||||
const interval = setInterval(fetchCalls, 5000);
|
const apiUrl = import.meta.env.VITE_API_URL ?? '';
|
||||||
return () => clearInterval(interval);
|
const es = new EventSource(`${apiUrl}/api/supervisor/active-calls/stream`);
|
||||||
}, [selectedCall?.ucid]);
|
es.onmessage = (msg) => {
|
||||||
|
try {
|
||||||
|
const event = JSON.parse(msg.data) as { type: 'update' | 'remove'; call?: ActiveCall; ucid: string };
|
||||||
|
setActiveCalls(prev => {
|
||||||
|
if (event.type === 'remove') {
|
||||||
|
return prev.filter(c => c.ucid !== event.ucid);
|
||||||
|
}
|
||||||
|
if (event.type === 'update' && event.call) {
|
||||||
|
const exists = prev.find(c => c.ucid === event.ucid);
|
||||||
|
if (exists) {
|
||||||
|
return prev.map(c => c.ucid === event.ucid ? event.call! : c);
|
||||||
|
}
|
||||||
|
return [...prev, event.call];
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
es.onerror = () => {
|
||||||
|
// SSE reconnects automatically; no-op
|
||||||
|
};
|
||||||
|
return () => es.close();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Clear selection if the selected call ended
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedCall && !activeCalls.find(c => c.ucid === selectedCall.ucid)) {
|
||||||
|
setSelectedCall(null);
|
||||||
|
setCallerContext(null);
|
||||||
|
}
|
||||||
|
}, [activeCalls, selectedCall]);
|
||||||
|
|
||||||
// Tick every second for duration display
|
// Tick every second for duration display
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -160,7 +182,11 @@ export const LiveMonitorPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TopBar title="Live Call Monitor" subtitle="Monitor, whisper, or barge into active calls" />
|
<PageHeader
|
||||||
|
title="Live Call Monitor"
|
||||||
|
badge={activeCalls.length}
|
||||||
|
infoText="Monitor, whisper, or barge into active calls in real-time."
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* Left panel — KPIs + call list */}
|
{/* Left panel — KPIs + call list */}
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ const SearchLg = faIcon(faMagnifyingGlass);
|
|||||||
import { Badge } from '@/components/base/badges/badges';
|
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 { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
|
|
||||||
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
|
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
|
||||||
import { TopBar } from '@/components/layout/top-bar';
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle';
|
import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle';
|
||||||
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
@@ -125,14 +124,15 @@ const renderCell = (call: MissedCallRecord, colId: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const DynamicMissedCallTable = ({ calls, columns, sortDescriptor, onSortChange }: {
|
const DynamicMissedCallTable = ({ calls, columns, columnKey, sortDescriptor, onSortChange }: {
|
||||||
calls: MissedCallRecord[];
|
calls: MissedCallRecord[];
|
||||||
columns: ColDef[];
|
columns: ColDef[];
|
||||||
|
columnKey: string;
|
||||||
sortDescriptor: SortDescriptor;
|
sortDescriptor: SortDescriptor;
|
||||||
onSortChange: (desc: SortDescriptor) => void;
|
onSortChange: (desc: SortDescriptor) => void;
|
||||||
}) => (
|
}) => (
|
||||||
<div className="flex flex-1 flex-col min-h-0 overflow-auto">
|
<div className="flex flex-1 flex-col min-h-0 overflow-auto">
|
||||||
<Table size="sm" sortDescriptor={sortDescriptor} onSortChange={onSortChange}>
|
<Table key={columnKey} size="sm" sortDescriptor={sortDescriptor} onSortChange={onSortChange}>
|
||||||
<Table.Header columns={columns}>
|
<Table.Header columns={columns}>
|
||||||
{(col) => (
|
{(col) => (
|
||||||
<Table.Head
|
<Table.Head
|
||||||
@@ -146,7 +146,7 @@ const DynamicMissedCallTable = ({ calls, columns, sortDescriptor, onSortChange }
|
|||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body items={calls}>
|
<Table.Body items={calls}>
|
||||||
{(call) => (
|
{(call) => (
|
||||||
<Table.Row id={call.id} columns={columns}>
|
<Table.Row id={call.id} columns={columns} className="group/row">
|
||||||
{(col) => (
|
{(col) => (
|
||||||
<Table.Cell key={col.id}>
|
<Table.Cell key={col.id}>
|
||||||
{renderCell(call, col.id)}
|
{renderCell(call, col.id)}
|
||||||
@@ -238,55 +238,70 @@ export const MissedCallsPage = () => {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
<TopBar title="Missed Calls" />
|
<PageHeader
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
title="Missed Calls"
|
||||||
{/* Tabs + toolbar */}
|
badge={calls.length}
|
||||||
<div className="flex shrink-0 items-end justify-between border-b border-secondary px-6 pt-3 pb-0.5">
|
infoText="Inbound calls that were not answered. Agents can call back from here."
|
||||||
<Tabs selectedKey={tab} onSelectionChange={(key) => handleTab(key as StatusTab)}>
|
controls={
|
||||||
<TabList items={tabItems} type="underline" size="sm">
|
<>
|
||||||
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
|
|
||||||
</TabList>
|
|
||||||
</Tabs>
|
|
||||||
<div className="flex items-center gap-3 pb-1">
|
|
||||||
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} />
|
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} />
|
||||||
<div className="w-56">
|
<div className="w-56">
|
||||||
<Input placeholder="Search phone, agent, branch..." icon={SearchLg} size="sm" value={search} onChange={handleSearch} />
|
<Input placeholder="Search phone, agent, branch..." icon={SearchLg} size="sm" value={search} onChange={handleSearch} />
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
tabs={
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{tabItems.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => handleTab(item.id)}
|
||||||
|
className={cx(
|
||||||
|
'shrink-0 rounded-full px-3 py-1 text-xs font-medium transition duration-100 ease-linear',
|
||||||
|
tab === item.id
|
||||||
|
? 'bg-brand-solid text-white'
|
||||||
|
: 'bg-secondary text-tertiary hover:text-secondary hover:bg-secondary_hover',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.label}{item.badge ? ` ${item.badge}` : ''}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3">
|
<div className="flex flex-1 flex-col min-h-0 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">
|
||||||
<p className="text-sm text-tertiary">Loading missed calls...</p>
|
<p className="text-sm text-tertiary">Loading missed calls...</p>
|
||||||
</div>
|
|
||||||
) : filtered.length === 0 ? (
|
|
||||||
<div className="flex items-center justify-center py-12">
|
|
||||||
<p className="text-sm text-quaternary">{search ? 'No matching calls' : 'No missed calls'}</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<DynamicMissedCallTable
|
|
||||||
calls={pagedRows}
|
|
||||||
columns={columnDefs.filter(c => visibleColumns.has(c.id))}
|
|
||||||
sortDescriptor={sortDescriptor}
|
|
||||||
onSortChange={setSortDescriptor}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div className="shrink-0 border-t border-secondary px-6 py-3">
|
|
||||||
<PaginationPageDefault
|
|
||||||
page={currentPage}
|
|
||||||
total={totalPages}
|
|
||||||
onPageChange={setCurrentPage}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<p className="text-sm text-quaternary">{search ? 'No matching calls' : 'No missed calls'}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DynamicMissedCallTable
|
||||||
|
calls={pagedRows}
|
||||||
|
columns={columnDefs.filter(c => visibleColumns.has(c.id))}
|
||||||
|
columnKey={Array.from(visibleColumns).sort().join(',')}
|
||||||
|
sortDescriptor={sortDescriptor}
|
||||||
|
onSortChange={setSortDescriptor}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="shrink-0 border-t border-secondary px-6 py-3">
|
||||||
|
<PaginationPageDefault
|
||||||
|
page={currentPage}
|
||||||
|
total={totalPages}
|
||||||
|
onPageChange={setCurrentPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
// useNavigate removed — row click opens profile panel
|
// useNavigate removed — row click opens profile panel
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faUser, faMagnifyingGlass, faCommentDots, faMessageDots, faEllipsisVertical, faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons';
|
import { faUser, faMagnifyingGlass, faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { faIcon } from '@/lib/icon-wrapper';
|
import { faIcon } from '@/lib/icon-wrapper';
|
||||||
|
|
||||||
const SearchLg = faIcon(faMagnifyingGlass);
|
const SearchLg = faIcon(faMagnifyingGlass);
|
||||||
import { Avatar } from '@/components/base/avatar/avatar';
|
import { Avatar } from '@/components/base/avatar/avatar';
|
||||||
import { Input } from '@/components/base/input/input';
|
import { Input } from '@/components/base/input/input';
|
||||||
import { Table, TableCard } from '@/components/application/table/table';
|
import { Table } from '@/components/application/table/table';
|
||||||
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
|
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
|
||||||
|
|
||||||
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
||||||
@@ -54,49 +55,6 @@ const getPatientEmail = (patient: Patient): string => {
|
|||||||
return patient.emails?.primaryEmail ?? '';
|
return patient.emails?.primaryEmail ?? '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const HamburgerMenu = ({ phone }: { phone: string }) => {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
const handler = (e: MouseEvent) => {
|
|
||||||
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
|
||||||
};
|
|
||||||
document.addEventListener('mousedown', handler);
|
|
||||||
return () => document.removeEventListener('mousedown', handler);
|
|
||||||
}, [open]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative" ref={ref}>
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); setOpen(!open); }}
|
|
||||||
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="More actions"
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faEllipsisVertical} className="size-4" />
|
|
||||||
</button>
|
|
||||||
{open && (
|
|
||||||
<div className="absolute top-full right-0 mt-1 w-40 rounded-xl bg-primary shadow-xl ring-1 ring-secondary z-50 overflow-hidden py-1">
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); window.open(`sms:+91${phone}`, '_self'); setOpen(false); }}
|
|
||||||
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-primary_hover text-left"
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faCommentDots} className="size-3.5 text-fg-quaternary" />
|
|
||||||
Send SMS
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); window.open(`https://wa.me/91${phone}`, '_blank'); setOpen(false); }}
|
|
||||||
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-primary hover:bg-primary_hover text-left"
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faMessageDots} className="size-3.5 text-fg-quaternary" />
|
|
||||||
WhatsApp
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PatientsPage = () => {
|
export const PatientsPage = () => {
|
||||||
const { patients, loading } = useData();
|
const { patients, loading } = useData();
|
||||||
@@ -127,37 +85,36 @@ export const PatientsPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<PageHeader
|
||||||
|
title="All Patients"
|
||||||
|
badge={filteredPatients.length}
|
||||||
|
infoText="Manage and view patient records"
|
||||||
|
controls={
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setPanelOpen(!panelOpen)}
|
||||||
|
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={panelOpen ? 'Hide patient profile' : 'Show patient profile'}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={panelOpen ? faSidebarFlip : faSidebar} className="size-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="w-56">
|
||||||
|
<Input
|
||||||
|
placeholder="Search by name or phone..."
|
||||||
|
icon={SearchLg}
|
||||||
|
size="sm"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={handleSearch}
|
||||||
|
aria-label="Search patients"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
<div className="flex flex-1 flex-col overflow-y-auto p-7">
|
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3">
|
||||||
<TableCard.Root size="sm">
|
|
||||||
<TableCard.Header
|
|
||||||
title="All Patients"
|
|
||||||
badge={filteredPatients.length}
|
|
||||||
description="Manage and view patient records"
|
|
||||||
contentTrailing={
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setPanelOpen(!panelOpen)}
|
|
||||||
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={panelOpen ? 'Hide patient profile' : 'Show patient profile'}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={panelOpen ? faSidebarFlip : faSidebar} className="size-4" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="w-56">
|
|
||||||
<Input
|
|
||||||
placeholder="Search by name or phone..."
|
|
||||||
icon={SearchLg}
|
|
||||||
size="sm"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={handleSearch}
|
|
||||||
aria-label="Search patients"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-20">
|
<div className="flex items-center justify-center py-20">
|
||||||
<p className="text-sm text-tertiary">Loading patients...</p>
|
<p className="text-sm text-tertiary">Loading patients...</p>
|
||||||
@@ -178,7 +135,6 @@ export const PatientsPage = () => {
|
|||||||
<Table.Head label="EMAIL" />
|
<Table.Head label="EMAIL" />
|
||||||
<Table.Head label="GENDER" />
|
<Table.Head label="GENDER" />
|
||||||
<Table.Head label="AGE" />
|
<Table.Head label="AGE" />
|
||||||
<Table.Head label="" className="w-12" />
|
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body items={pagedPatients} dependencies={[selectedPatient?.id]}>
|
<Table.Body items={pagedPatients} dependencies={[selectedPatient?.id]}>
|
||||||
{(patient) => {
|
{(patient) => {
|
||||||
@@ -195,7 +151,7 @@ export const PatientsPage = () => {
|
|||||||
<Table.Row
|
<Table.Row
|
||||||
id={patient.id}
|
id={patient.id}
|
||||||
className={cx(
|
className={cx(
|
||||||
'cursor-pointer',
|
'cursor-pointer group/row',
|
||||||
selectedPatient?.id === patient.id && 'bg-brand-primary'
|
selectedPatient?.id === patient.id && 'bg-brand-primary'
|
||||||
)}
|
)}
|
||||||
onAction={() => {
|
onAction={() => {
|
||||||
@@ -255,25 +211,18 @@ export const PatientsPage = () => {
|
|||||||
</span>
|
</span>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
|
||||||
{/* Hamburger — SMS + WhatsApp */}
|
|
||||||
<Table.Cell>
|
|
||||||
{phone ? (
|
|
||||||
<HamburgerMenu phone={phone} />
|
|
||||||
) : null}
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
</Table.Row>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table>
|
</Table>
|
||||||
)}
|
)}
|
||||||
</TableCard.Root>
|
|
||||||
|
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="shrink-0 border-t border-secondary px-6 py-3">
|
<div className="shrink-0 border-t border-secondary px-6 py-3">
|
||||||
<PaginationPageDefault page={currentPage} total={totalPages} onPageChange={setCurrentPage} />
|
<PaginationPageDefault page={currentPage} total={totalPages} onPageChange={setCurrentPage} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Patient Profile Panel - collapsible with smooth transition */}
|
{/* Patient Profile Panel - collapsible with smooth transition */}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
faPalette,
|
faPalette,
|
||||||
faShieldHalved,
|
faShieldHalved,
|
||||||
} from '@fortawesome/pro-duotone-svg-icons';
|
} from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { TopBar } from '@/components/layout/top-bar';
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
import { SectionCard } from '@/components/setup/section-card';
|
import { SectionCard } from '@/components/setup/section-card';
|
||||||
import {
|
import {
|
||||||
SETUP_STEP_NAMES,
|
SETUP_STEP_NAMES,
|
||||||
@@ -50,7 +50,7 @@ export const SettingsPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
<TopBar title="Settings" subtitle="Configure your hospital workspace" />
|
<PageHeader title="Settings" infoText="Configure your hospital workspace." />
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-8">
|
<div className="flex-1 overflow-y-auto p-8">
|
||||||
<div className="mx-auto max-w-5xl">
|
<div className="mx-auto max-w-5xl">
|
||||||
@@ -73,6 +73,7 @@ export const SettingsPage = () => {
|
|||||||
icon={faBuilding}
|
icon={faBuilding}
|
||||||
href="/settings/clinics"
|
href="/settings/clinics"
|
||||||
status={STEP_TO_STATUS(state, 'clinics')}
|
status={STEP_TO_STATUS(state, 'clinics')}
|
||||||
|
disabled
|
||||||
/>
|
/>
|
||||||
<SectionCard
|
<SectionCard
|
||||||
title={SETUP_STEP_LABELS.doctors.title}
|
title={SETUP_STEP_LABELS.doctors.title}
|
||||||
@@ -80,6 +81,7 @@ export const SettingsPage = () => {
|
|||||||
icon={faStethoscope}
|
icon={faStethoscope}
|
||||||
href="/settings/doctors"
|
href="/settings/doctors"
|
||||||
status={STEP_TO_STATUS(state, 'doctors')}
|
status={STEP_TO_STATUS(state, 'doctors')}
|
||||||
|
disabled
|
||||||
/>
|
/>
|
||||||
<SectionCard
|
<SectionCard
|
||||||
title={SETUP_STEP_LABELS.team.title}
|
title={SETUP_STEP_LABELS.team.title}
|
||||||
@@ -87,6 +89,7 @@ export const SettingsPage = () => {
|
|||||||
icon={faUserTie}
|
icon={faUserTie}
|
||||||
href="/settings/team"
|
href="/settings/team"
|
||||||
status={STEP_TO_STATUS(state, 'team')}
|
status={STEP_TO_STATUS(state, 'team')}
|
||||||
|
disabled
|
||||||
/>
|
/>
|
||||||
</SectionGroup>
|
</SectionGroup>
|
||||||
|
|
||||||
@@ -98,6 +101,7 @@ export const SettingsPage = () => {
|
|||||||
icon={faPhone}
|
icon={faPhone}
|
||||||
href="/settings/telephony"
|
href="/settings/telephony"
|
||||||
status={STEP_TO_STATUS(state, 'telephony')}
|
status={STEP_TO_STATUS(state, 'telephony')}
|
||||||
|
disabled
|
||||||
/>
|
/>
|
||||||
<SectionCard
|
<SectionCard
|
||||||
title={SETUP_STEP_LABELS.ai.title}
|
title={SETUP_STEP_LABELS.ai.title}
|
||||||
@@ -105,12 +109,14 @@ export const SettingsPage = () => {
|
|||||||
icon={faRobot}
|
icon={faRobot}
|
||||||
href="/settings/ai"
|
href="/settings/ai"
|
||||||
status={STEP_TO_STATUS(state, 'ai')}
|
status={STEP_TO_STATUS(state, 'ai')}
|
||||||
|
disabled
|
||||||
/>
|
/>
|
||||||
<SectionCard
|
<SectionCard
|
||||||
title="Website widget"
|
title="Website widget"
|
||||||
description="Embed the chat + booking widget on your hospital website."
|
description="Embed the chat + booking widget on your hospital website."
|
||||||
icon={faGlobe}
|
icon={faGlobe}
|
||||||
href="/settings/widget"
|
href="/settings/widget"
|
||||||
|
disabled
|
||||||
/>
|
/>
|
||||||
<SectionCard
|
<SectionCard
|
||||||
title="Routing rules"
|
title="Routing rules"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons';
|
import { faSidebarFlip, faSidebar } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
import { AiChatPanel } from '@/components/call-desk/ai-chat-panel';
|
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';
|
||||||
@@ -55,36 +56,36 @@ export const TeamDashboardPage = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
{/* Header */}
|
<PageHeader
|
||||||
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-6 py-3">
|
title="Team Dashboard"
|
||||||
<div className="flex items-center gap-3">
|
subtitle={dateRangeLabel}
|
||||||
<h1 className="text-lg font-bold text-primary">Team Dashboard</h1>
|
infoText="Aggregated call metrics, agent performance, and operational alerts."
|
||||||
<span className="text-sm text-tertiary">{dateRangeLabel}</span>
|
controls={
|
||||||
</div>
|
<>
|
||||||
<div className="flex items-center gap-2">
|
<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
|
||||||
<button
|
onClick={() => setAiOpen(!aiOpen)}
|
||||||
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"
|
||||||
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'}
|
||||||
title={aiOpen ? 'Hide AI panel' : 'Show AI panel'}
|
>
|
||||||
>
|
<FontAwesomeIcon icon={aiOpen ? faSidebarFlip : faSidebar} className="size-4" />
|
||||||
<FontAwesomeIcon icon={aiOpen ? faSidebarFlip : faSidebar} className="size-4" />
|
</button>
|
||||||
</button>
|
</>
|
||||||
</div>
|
}
|
||||||
</div>
|
/>
|
||||||
|
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* Main content — scrollable column with KPIs pinned at the
|
{/* Main content — scrollable column with KPIs pinned at the
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
faUsers, faPhoneVolume, faCalendarCheck, faPhoneMissed,
|
faUsers, faPhoneVolume, faCalendarCheck, faPhoneMissed,
|
||||||
faPercent, faTriangleExclamation,
|
faPercent, faTriangleExclamation,
|
||||||
} from '@fortawesome/pro-duotone-svg-icons';
|
} from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { TopBar } from '@/components/layout/top-bar';
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
import { Badge } from '@/components/base/badges/badges';
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
import { Table } from '@/components/application/table/table';
|
import { Table } from '@/components/application/table/table';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
@@ -291,25 +291,28 @@ export const TeamPerformancePage = () => {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
<TopBar title="Team Performance" />
|
<PageHeader title="Team Dashboard" infoText="Aggregated metrics across all agents." />
|
||||||
<div className="flex flex-1 items-center justify-center">
|
<div className="flex flex-1 items-center justify-center">
|
||||||
<p className="text-sm text-tertiary">Loading team performance...</p>
|
<p className="text-sm text-tertiary">Loading team performance...</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
<TopBar title="Team Performance" subtitle="Aggregated metrics across all agents" />
|
<PageHeader
|
||||||
|
title="Team Dashboard"
|
||||||
|
infoText="Aggregated metrics across all agents."
|
||||||
|
controls={<DateFilter value={range} onChange={setRange} />}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col overflow-y-auto">
|
<div className="flex flex-1 flex-col overflow-y-auto">
|
||||||
{/* Section 1: Key Metrics */}
|
{/* Section 1: Key Metrics */}
|
||||||
<div className="px-6 pt-5">
|
<div className="px-6 pt-5">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="mb-4">
|
||||||
<h3 className="text-sm font-semibold text-secondary">Key Metrics</h3>
|
<h3 className="text-sm font-semibold text-secondary">Key Metrics</h3>
|
||||||
<DateFilter value={range} onChange={setRange} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-5 gap-3">
|
<div className="grid grid-cols-5 gap-3">
|
||||||
<KpiCard icon={faUsers} value={activeAgents} label="Active Agents" color="bg-brand-secondary" />
|
<KpiCard icon={faUsers} value={activeAgents} label="Active Agents" color="bg-brand-secondary" />
|
||||||
@@ -510,6 +513,6 @@ export const TeamPerformancePage = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user