mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
Compare commits
5 Commits
5c9e70da20
...
v0.11-ui-c
| Author | SHA1 | Date | |
|---|---|---|---|
| bdabcb2ea4 | |||
| 313842a922 | |||
| dfcaa175ab | |||
| dd8e05b343 | |||
| df08bcfc19 |
@@ -19,9 +19,12 @@ interface DatePickerProps extends AriaDatePickerProps<DateValue> {
|
||||
onApply?: () => void;
|
||||
/** The function to call when the cancel button is clicked. */
|
||||
onCancel?: () => void;
|
||||
/** Override popover placement — use "top start" in narrow panels
|
||||
* where "bottom start" would overflow the viewport. */
|
||||
popoverPlacement?: 'bottom start' | 'top start' | 'top end' | 'bottom end';
|
||||
}
|
||||
|
||||
export const DatePicker = ({ value: valueProp, defaultValue, onChange, onApply, onCancel, ...props }: DatePickerProps) => {
|
||||
export const DatePicker = ({ value: valueProp, defaultValue, onChange, onApply, onCancel, popoverPlacement, ...props }: DatePickerProps) => {
|
||||
const formatter = useDateFormatter({
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
@@ -40,7 +43,7 @@ export const DatePicker = ({ value: valueProp, defaultValue, onChange, onApply,
|
||||
</AriaGroup>
|
||||
<AriaPopover
|
||||
offset={8}
|
||||
placement="bottom start"
|
||||
placement={popoverPlacement ?? "bottom start"}
|
||||
shouldFlip
|
||||
className={({ isEntering, isExiting }) =>
|
||||
cx(
|
||||
|
||||
@@ -74,43 +74,33 @@ export const PhoneActionCell = ({ phoneNumber, displayNumber, leadId: _leadId, o
|
||||
{/* Clickable phone number — calls directly */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCall}
|
||||
onClick={canCall ? handleCall : undefined}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchEnd={onTouchEnd}
|
||||
onContextMenu={(e) => { e.preventDefault(); setMenuOpen(true); }}
|
||||
disabled={!canCall}
|
||||
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
|
||||
? 'cursor-pointer text-brand-secondary hover:bg-brand-primary hover:text-brand-secondary'
|
||||
: 'cursor-default text-tertiary',
|
||||
? 'cursor-pointer hover:bg-brand-primary'
|
||||
: 'cursor-default',
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPhone} className="size-3" />
|
||||
<span className="whitespace-nowrap">{displayNumber}</span>
|
||||
</button>
|
||||
|
||||
{/* Kebab menu trigger — desktop */}
|
||||
{/* Kebab menu trigger — SMS + WhatsApp */}
|
||||
<button
|
||||
type="button"
|
||||
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" />
|
||||
</button>
|
||||
|
||||
{/* Context menu */}
|
||||
{/* Context menu — SMS + WhatsApp only (dial is the primary click) */}
|
||||
{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">
|
||||
<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
|
||||
type="button"
|
||||
onClick={handleSms}
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
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 { faIcon } from '@/lib/icon-wrapper';
|
||||
import { Table } from '@/components/application/table/table';
|
||||
|
||||
const SearchLg = faIcon(faMagnifyingGlass);
|
||||
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 { formatPhone, formatTimeOnly, formatShortDate } from '@/lib/format';
|
||||
import { notify } from '@/lib/toast';
|
||||
@@ -79,6 +75,9 @@ interface WorklistPanelProps {
|
||||
onSelectItem: (selection: WorklistSelection) => void;
|
||||
selectedItemId: string | null;
|
||||
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';
|
||||
@@ -178,34 +177,9 @@ const formatTimeAgo = (dateStr: string): string => {
|
||||
const formatDisposition = (disposition: string): string =>
|
||||
disposition.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
|
||||
const formatSource = (source: string): string => {
|
||||
const map: Record<string, string> = {
|
||||
FACEBOOK_AD: 'Facebook',
|
||||
GOOGLE_AD: 'Google',
|
||||
WALK_IN: 'Walk-in',
|
||||
REFERRAL: 'Referral',
|
||||
WEBSITE: 'Website',
|
||||
PHONE_INQUIRY: 'Phone',
|
||||
PHONE: 'Phone',
|
||||
OTHER: 'Other',
|
||||
};
|
||||
return map[source] ?? source.replace(/_/g, ' ');
|
||||
};
|
||||
|
||||
// Resolve a DID (e.g. "918041763265") to a friendly branch/campaign name.
|
||||
// The DID is the phone number the caller dialed — it identifies which
|
||||
// hospital branch or campaign the call came through. Falls back to the
|
||||
// last 10 digits if no mapping is found.
|
||||
const formatDid = (did: string): string => {
|
||||
if (!did) return '—';
|
||||
// Known DIDs — loaded from the sidecar theme tokens at boot (via
|
||||
// the ThemeTokenProvider). For now, hardcode nothing — strip country
|
||||
// code and show the DID as a short number so it's at least readable.
|
||||
const digits = did.replace(/\D/g, '');
|
||||
// Strip leading 91 (India) for display
|
||||
const short = digits.length > 10 && digits.startsWith('91') ? digits.slice(2) : digits;
|
||||
return short;
|
||||
};
|
||||
// formatSource + formatDid kept for reference but no longer rendered
|
||||
// in the table — SOURCE/BRANCH column removed from display per user
|
||||
// request. Data stays on the row for future use.
|
||||
|
||||
const IconInbound = faIcon(faPhoneArrowDown);
|
||||
const IconOutbound = faIcon(faPhoneArrowUp);
|
||||
@@ -239,7 +213,7 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
|
||||
// Branch column: prefer the campaign name (e.g. "Cervical Cancer
|
||||
// Screening Drive") over the raw DID. Falls back to formatted DID
|
||||
// for organic calls with no campaign.
|
||||
source: call.campaign?.campaignName ?? (call.callSourceNumber ? formatDid(call.callSourceNumber) : null),
|
||||
source: call.campaign?.campaignName ?? call.callSourceNumber ?? null,
|
||||
lastDisposition: call.disposition ?? null,
|
||||
missedCallId: call.id,
|
||||
});
|
||||
@@ -324,9 +298,8 @@ const buildRows = (missedCalls: MissedCall[], followUps: WorklistFollowUp[], lea
|
||||
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 [search, setSearch] = useState('');
|
||||
// Missed tab always shows PENDING callbacks. Attempted/Completed/Invalid
|
||||
// sub-tabs were removed per QA feedback — pending callbacks are the only
|
||||
// ones agents need to act on from the worklist.
|
||||
@@ -427,8 +400,10 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
||||
const PAGE_SIZE = 15;
|
||||
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 handleSearch = useCallback((value: string) => { setSearch(value); setPage(1); }, []);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredRows.length / PAGE_SIZE));
|
||||
const pagedRows = filteredRows.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
@@ -461,23 +436,22 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||
{/* Filter tabs + search */}
|
||||
<div className="flex shrink-0 items-end justify-between border-b border-secondary px-5 pt-3 pb-0.5">
|
||||
<Tabs selectedKey={tab} onSelectionChange={(key) => handleTabChange(key as TabKey)}>
|
||||
<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="w-44 shrink-0">
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
icon={SearchLg}
|
||||
size="sm"
|
||||
value={search}
|
||||
onChange={handleSearch}
|
||||
aria-label="Search worklist"
|
||||
/>
|
||||
</div>
|
||||
{/* Filter pills — custom buttons matching All Leads pattern */}
|
||||
<div className="flex shrink-0 items-center gap-1.5 px-5 py-2">
|
||||
{tabItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleTabChange(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>
|
||||
|
||||
{/* Missed-call sub-tabs removed per QA feedback — the Missed tab
|
||||
@@ -497,7 +471,6 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
||||
<Table.Head id="priority" label="SCORE" className="w-20" isRowHeader allowsSorting />
|
||||
<Table.Head id="name" label="PATIENT" allowsSorting />
|
||||
<Table.Head label="PHONE" />
|
||||
<Table.Head label={tab === 'missed' ? 'BRANCH' : 'SOURCE'} className="w-28" />
|
||||
<Table.Head id="sla" label="SLA" className="w-24" allowsSorting />
|
||||
</Table.Header>
|
||||
<Table.Body items={pagedRows}>
|
||||
@@ -578,15 +551,6 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
||||
<span className="text-xs text-quaternary italic">No phone</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{row.source ? (
|
||||
<span className="text-xs text-tertiary truncate block max-w-[100px]">
|
||||
{formatSource(row.source)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-quaternary">—</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge size="sm" color={sla.color} type="pill-color">
|
||||
{sla.label}
|
||||
|
||||
98
src/components/layout/page-header.tsx
Normal file
98
src/components/layout/page-header.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
// 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';
|
||||
|
||||
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) => (
|
||||
<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>
|
||||
{controls && (
|
||||
<div className="flex items-center gap-2">
|
||||
{controls}
|
||||
</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 { TableBody as AriaTableBody } from 'react-aria-components';
|
||||
import type { SortDescriptor, Selection } from 'react-aria-components';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faEllipsisVertical } from '@fortawesome/pro-duotone-svg-icons';
|
||||
|
||||
const DotsVertical: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faEllipsisVertical} className={className} />;
|
||||
import { faEye } from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { Table } from '@/components/application/table/table';
|
||||
import { LeadStatusBadge } from '@/components/shared/status-badge';
|
||||
import { SourceTag } from '@/components/shared/source-tag';
|
||||
import { AgeIndicator } from '@/components/shared/age-indicator';
|
||||
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
||||
import { formatPhone, formatShortDate } from '@/lib/format';
|
||||
import { cx } from '@/utils/cx';
|
||||
import type { Lead } from '@/types/entities';
|
||||
@@ -97,6 +94,7 @@ export const LeadTable = ({
|
||||
}, [leads, expandedDupId]);
|
||||
|
||||
const allColumns = [
|
||||
{ id: 'view', label: '', allowsSorting: false, defaultWidth: 40 },
|
||||
{ id: 'phone', label: 'Phone', allowsSorting: true, defaultWidth: 150 },
|
||||
{ id: 'name', label: 'Name', allowsSorting: true, defaultWidth: 160 },
|
||||
{ id: 'email', label: 'Email', allowsSorting: false, defaultWidth: 180 },
|
||||
@@ -109,11 +107,10 @@ export const LeadTable = ({
|
||||
{ id: 'createdAt', label: 'Age', allowsSorting: true, defaultWidth: 80 },
|
||||
{ id: 'spamScore', label: 'Spam', allowsSorting: true, defaultWidth: 70 },
|
||||
{ id: 'dups', label: 'Dups', allowsSorting: false, defaultWidth: 60 },
|
||||
{ id: 'actions', label: '', allowsSorting: false, defaultWidth: 50 },
|
||||
];
|
||||
|
||||
const columns = visibleColumns
|
||||
? allColumns.filter(c => visibleColumns.has(c.id) || c.id === 'actions')
|
||||
? allColumns.filter(c => visibleColumns.has(c.id) || c.id === 'view')
|
||||
: allColumns;
|
||||
|
||||
return (
|
||||
@@ -145,6 +142,7 @@ export const LeadTable = ({
|
||||
const firstName = lead.contactName?.firstName ?? '';
|
||||
const lastName = lead.contactName?.lastName ?? '';
|
||||
const name = `${firstName} ${lastName}`.trim() || '\u2014';
|
||||
const phoneRaw = lead.contactPhone?.[0]?.number ?? '';
|
||||
const phone = lead.contactPhone?.[0]
|
||||
? formatPhone(lead.contactPhone[0])
|
||||
: '\u2014';
|
||||
@@ -158,6 +156,7 @@ export const LeadTable = ({
|
||||
id={row.id}
|
||||
className="bg-warning-primary"
|
||||
>
|
||||
<Table.Cell />
|
||||
<Table.Cell className="pl-10">
|
||||
<span className="text-xs text-tertiary">{phone}</span>
|
||||
</Table.Cell>
|
||||
@@ -191,17 +190,6 @@ export const LeadTable = ({
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -219,12 +207,26 @@ export const LeadTable = ({
|
||||
key={row.id}
|
||||
id={row.id}
|
||||
className={cx(
|
||||
'group/row',
|
||||
isSpamRow && !isSelected && 'bg-warning-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>
|
||||
<span className="font-semibold text-primary">{phone}</span>
|
||||
{phoneRaw ? (
|
||||
<PhoneActionCell phoneNumber={phoneRaw} displayNumber={phone} />
|
||||
) : (
|
||||
<span className="text-sm text-quaternary">{'\u2014'}</span>
|
||||
)}
|
||||
</Table.Cell>}
|
||||
{isCol('name') && <Table.Cell>
|
||||
<span className="text-secondary">{name}</span>
|
||||
@@ -308,15 +310,6 @@ export const LeadTable = ({
|
||||
<span className="text-tertiary">0</span>
|
||||
)}
|
||||
</Table.Cell>}
|
||||
<Table.Cell>
|
||||
<Button
|
||||
size="sm"
|
||||
color="tertiary"
|
||||
iconLeading={DotsVertical}
|
||||
aria-label="Row actions"
|
||||
onClick={() => onViewActivity?.(lead)}
|
||||
/>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
|
||||
type MissedCall = {
|
||||
id: string;
|
||||
@@ -133,9 +134,30 @@ export const useWorklist = (): UseWorklistResult => {
|
||||
useEffect(() => {
|
||||
fetchWorklist();
|
||||
|
||||
// Refresh every 30 seconds
|
||||
const interval = setInterval(fetchWorklist, 30000);
|
||||
return () => clearInterval(interval);
|
||||
// SSE stream for instant worklist updates. No polling fallback —
|
||||
// if SSE breaks, the worklist stops updating and we fix the SSE,
|
||||
// not paper over it with a poll.
|
||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
|
||||
let es: EventSource | null = null;
|
||||
try {
|
||||
es = new EventSource(`${API_URL}/api/supervisor/worklist/stream`);
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('[WORKLIST-SSE]', data);
|
||||
fetchWorklist();
|
||||
if (data.type === 'missed-call') {
|
||||
const name = data.callerName ?? data.callerPhone ?? 'Unknown';
|
||||
notify.warning('Missed Call', `${name} — needs callback`);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
es.onerror = () => {
|
||||
console.warn('[WORKLIST-SSE] Connection error — EventSource will auto-reconnect');
|
||||
};
|
||||
} catch {}
|
||||
|
||||
return () => { es?.close(); };
|
||||
}, [fetchWorklist]);
|
||||
|
||||
return { ...data, loading, error, refresh: fetchWorklist };
|
||||
|
||||
@@ -49,7 +49,8 @@ import { IntegrationsPage } from "@/pages/integrations";
|
||||
import { AgentDetailPage } from "@/pages/agent-detail";
|
||||
import { SettingsPage } from "@/pages/settings";
|
||||
import { MyPerformancePage } from "@/pages/my-performance";
|
||||
import { AppointmentsPage } from "@/pages/appointments";
|
||||
// v2 appointments — testing locally via Tauri before replacing v1
|
||||
import { AppointmentsPageV2 as AppointmentsPage } from "@/pages/appointments-v2";
|
||||
import { TeamPerformancePage } from "@/pages/team-performance";
|
||||
import { LiveMonitorPage } from "@/pages/live-monitor";
|
||||
import { CallRecordingsPage } from "@/pages/call-recordings";
|
||||
|
||||
@@ -11,8 +11,7 @@ import { Input } from '@/components/base/input/input';
|
||||
// Tabs removed — campaign pills handle all filtering now
|
||||
// import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
|
||||
import { PaginationPageDefault } from '@/components/application/pagination/pagination';
|
||||
// TopBar replaced by inline header
|
||||
// import { TopBar } from '@/components/layout/top-bar';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { LeadTable } from '@/components/leads/lead-table';
|
||||
import { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle';
|
||||
// Bulk actions removed — checkboxes hidden
|
||||
@@ -244,13 +243,12 @@ export const AllLeadsPage = () => {
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Header with controls inline */}
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-6 py-3">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold text-primary">All Leads</h1>
|
||||
<p className="text-xs text-tertiary">{total} total</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<PageHeader
|
||||
title="All Leads"
|
||||
subtitle={`${total} total`}
|
||||
infoText="Campaign-sourced marketing leads. Organic callers are on the Contacts page."
|
||||
controls={
|
||||
<>
|
||||
<div className="w-56">
|
||||
<Input
|
||||
placeholder="Search leads..."
|
||||
@@ -273,8 +271,9 @@ export const AllLeadsPage = () => {
|
||||
>
|
||||
Export CSV
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
|
||||
|
||||
725
src/pages/appointments-v2.tsx
Normal file
725
src/pages/appointments-v2.tsx
Normal file
@@ -0,0 +1,725 @@
|
||||
// Appointments v2 — lean table + detail side panel + reschedule + reminder
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faMagnifyingGlass, faPenToSquare, faEye, faBell, faXmark,
|
||||
faCalendarCheck, faUserDoctor, faBuilding, faStethoscope, faNotesMedical,
|
||||
} from '@fortawesome/pro-duotone-svg-icons';
|
||||
import { faIcon } from '@/lib/icon-wrapper';
|
||||
|
||||
const SearchLg = faIcon(faMagnifyingGlass);
|
||||
import { Badge } from '@/components/base/badges/badges';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { Table } from '@/components/application/table/table';
|
||||
import { PaginationCardDefault } from '@/components/application/pagination/pagination';
|
||||
// TopBar replaced by inline header
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { ModalOverlay, Modal, Dialog } from '@/components/application/modals/modal';
|
||||
import { Select } from '@/components/base/select/select';
|
||||
import { DatePicker } from '@/components/application/date-picker/date-picker';
|
||||
import { parseDate, today, getLocalTimeZone } from '@internationalized/date';
|
||||
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { formatPhone, formatDateOnly, formatTimeOnly } from '@/lib/format';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { notify } from '@/lib/toast';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
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';
|
||||
|
||||
const STATUS_COLORS: Record<string, 'brand' | 'success' | 'error' | 'warning' | 'gray'> = {
|
||||
SCHEDULED: 'brand',
|
||||
CONFIRMED: 'brand',
|
||||
COMPLETED: 'success',
|
||||
CANCELLED: 'error',
|
||||
NO_SHOW: 'warning',
|
||||
RESCHEDULED: 'warning',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
SCHEDULED: 'Booked',
|
||||
CONFIRMED: 'Confirmed',
|
||||
COMPLETED: 'Completed',
|
||||
CANCELLED: 'Cancelled',
|
||||
NO_SHOW: 'No Show',
|
||||
RESCHEDULED: 'Rescheduled',
|
||||
};
|
||||
|
||||
const QUERY = `{ appointments(first: 200, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||
id scheduledAt durationMin appointmentType status
|
||||
doctorName department reasonForVisit
|
||||
patient { id fullName { firstName lastName } phones { primaryPhoneNumber } }
|
||||
clinic { id clinicName }
|
||||
doctor { id fullName { firstName lastName } }
|
||||
} } } }`;
|
||||
|
||||
const formatDateTime = (iso: string): string =>
|
||||
`${formatDateOnly(iso)}, ${formatTimeOnly(iso)}`;
|
||||
|
||||
const getPatientName = (appt: AppointmentRecord): string => {
|
||||
if (!appt.patient?.fullName) return 'Unknown';
|
||||
return `${appt.patient.fullName.firstName} ${appt.patient.fullName.lastName}`.trim() || 'Unknown';
|
||||
};
|
||||
|
||||
const getPhone = (appt: AppointmentRecord): string =>
|
||||
appt.patient?.phones?.primaryPhoneNumber ?? '';
|
||||
|
||||
const isUpcoming = (appt: AppointmentRecord): boolean => {
|
||||
if (appt.status !== 'SCHEDULED' && appt.status !== 'CONFIRMED') return false;
|
||||
if (!appt.scheduledAt) return false;
|
||||
return new Date(appt.scheduledAt).getTime() >= Date.now();
|
||||
};
|
||||
|
||||
// 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';
|
||||
};
|
||||
|
||||
const buildReminderMessage = (appt: AppointmentRecord): string => {
|
||||
const name = getPatientName(appt);
|
||||
const doctor = appt.doctorName ?? 'your doctor';
|
||||
const date = appt.scheduledAt ? formatDateTime(appt.scheduledAt) : 'your scheduled time';
|
||||
const branch = appt.clinic?.clinicName ?? 'our clinic';
|
||||
return `Hi ${name}, this is a reminder for your appointment with ${doctor} on ${date} at ${branch}. Please confirm or call us to reschedule.`;
|
||||
};
|
||||
|
||||
// ── Detail Panel ─────────────────────────────────────────────────
|
||||
const DetailRow = ({ icon, label, value }: { icon: any; label: string; value: string }) => (
|
||||
<div className="flex items-start gap-3 py-2.5">
|
||||
<FontAwesomeIcon icon={icon} className="size-4 text-fg-quaternary mt-0.5 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wider text-quaternary">{label}</p>
|
||||
<p className="text-sm text-primary mt-0.5">{value || '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const AppointmentDetailPanel = ({
|
||||
appointment,
|
||||
onClose,
|
||||
onReschedule,
|
||||
}: {
|
||||
appointment: AppointmentRecord;
|
||||
onClose: () => void;
|
||||
onReschedule: () => void;
|
||||
}) => {
|
||||
const editable = canEdit(appointment);
|
||||
const phone = getPhone(appointment);
|
||||
const [reschedulePromptOpen, setReschedulePromptOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between border-b border-secondary px-5 py-3">
|
||||
<h3 className="text-sm font-bold text-primary">Appointment Details</h3>
|
||||
<div className="flex items-center gap-1">
|
||||
{editable && (
|
||||
<button
|
||||
onClick={() => setReschedulePromptOpen(true)}
|
||||
title="Reschedule appointment"
|
||||
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-brand-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
||||
>
|
||||
<FontAwesomeIcon icon={faPenToSquare} className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
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"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-1">
|
||||
<div className="mb-4">
|
||||
<Badge size="md" color={STATUS_COLORS[appointment.status ?? ''] ?? 'gray'} type="pill-color">
|
||||
{STATUS_LABELS[appointment.status ?? ''] ?? appointment.status ?? '—'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Date & Time — 2 lines */}
|
||||
<div className="flex items-start gap-3 py-2.5">
|
||||
<FontAwesomeIcon icon={faCalendarCheck} className="size-4 text-fg-quaternary mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-[11px] font-medium uppercase tracking-wider text-quaternary">Date & Time</p>
|
||||
{appointment.scheduledAt ? (
|
||||
<>
|
||||
<p className="text-sm text-primary mt-0.5">{formatDateOnly(appointment.scheduledAt)}</p>
|
||||
<p className="text-xs text-tertiary">{formatTimeOnly(appointment.scheduledAt)}</p>
|
||||
</>
|
||||
) : <p className="text-sm text-quaternary mt-0.5">—</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DetailRow icon={faUserDoctor} label="Doctor" value={appointment.doctorName ?? '—'} />
|
||||
<DetailRow icon={faStethoscope} label="Department" value={appointment.department ?? '—'} />
|
||||
<DetailRow icon={faBuilding} label="Branch / Clinic" value={appointment.clinic?.clinicName ?? '—'} />
|
||||
<DetailRow icon={faNotesMedical} label="Chief Complaint" value={appointment.reasonForVisit ?? '—'} />
|
||||
|
||||
<div className="border-t border-secondary pt-3 mt-3">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wider text-quaternary mb-1">Patient</p>
|
||||
<p className="text-sm font-medium text-primary">{getPatientName(appointment)}</p>
|
||||
{phone && (
|
||||
<div className="mt-1">
|
||||
<PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reschedule confirm modal — same pattern as call desk */}
|
||||
<ModalOverlay
|
||||
isOpen={reschedulePromptOpen}
|
||||
onOpenChange={(open) => { if (!open) setReschedulePromptOpen(false); }}
|
||||
isDismissable
|
||||
>
|
||||
<Modal className="sm:max-w-md">
|
||||
<Dialog>
|
||||
{() => (
|
||||
<div className="flex flex-col gap-4 rounded-xl bg-primary p-6 shadow-xl ring-1 ring-secondary">
|
||||
<h2 className="text-lg font-semibold text-primary">Reschedule this appointment?</h2>
|
||||
<p className="text-sm text-tertiary">
|
||||
Choose "Yes, reschedule" to change the date, time, or doctor.
|
||||
Choose "No, just view" to see the details without changing anything.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Button size="sm" color="secondary" onClick={() => setReschedulePromptOpen(false)}>
|
||||
No, just view
|
||||
</Button>
|
||||
<Button size="sm" color="primary" onClick={() => { setReschedulePromptOpen(false); onReschedule(); }}>
|
||||
Yes, reschedule
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── 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 };
|
||||
|
||||
const DOCTORS_QUERY = `{ doctors(first: 50) { edges { node {
|
||||
id name fullName { firstName lastName } department
|
||||
} } } }`;
|
||||
|
||||
const ReschedulePanel = ({
|
||||
appointment,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
appointment: AppointmentRecord;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) => {
|
||||
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||
const [department, setDepartment] = useState(appointment.department ?? '');
|
||||
const [doctor, setDoctor] = useState(appointment.doctor?.id ?? '');
|
||||
const [date, setDate] = useState(() => appointment.scheduledAt?.split('T')[0] ?? '');
|
||||
const [timeSlot, setTimeSlot] = useState(() => {
|
||||
if (!appointment.scheduledAt) return '';
|
||||
const dt = new Date(appointment.scheduledAt);
|
||||
return `${String(dt.getHours()).padStart(2, '0')}:${String(dt.getMinutes()).padStart(2, '0')}`;
|
||||
});
|
||||
const [slots, setSlots] = useState<Array<{ id: string; label: string }>>([]);
|
||||
const [reason, setReason] = useState(appointment.reasonForVisit ?? '');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [cancelConfirm, setCancelConfirm] = useState(false);
|
||||
|
||||
// Fetch doctors once
|
||||
useEffect(() => {
|
||||
apiClient.graphql<any>(DOCTORS_QUERY, undefined, { silent: true })
|
||||
.then(data => {
|
||||
const docs = data.doctors.edges.map((e: any) => {
|
||||
const n = e.node;
|
||||
const name = n.fullName
|
||||
? `Dr. ${n.fullName.firstName} ${n.fullName.lastName}`.trim()
|
||||
: n.name;
|
||||
return { id: n.id, name, department: n.department ?? '' };
|
||||
});
|
||||
setDoctors(docs);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Departments derived from 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]);
|
||||
|
||||
// Fetch slots when doctor + date change
|
||||
useEffect(() => {
|
||||
if (!doctor || !date) { setSlots([]); return; }
|
||||
apiClient.get<Array<{ time: string; label: string }>>(`/api/masterdata/slots?doctorId=${doctor}&date=${date}`, { silent: true })
|
||||
.then(s => setSlots(s.map(sl => ({ id: sl.time, label: sl.label }))))
|
||||
.catch(() => setSlots([]));
|
||||
}, [doctor, date]);
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!doctor || !date || !timeSlot) {
|
||||
setError('Please select doctor, date, and time slot');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const scheduledAt = new Date(`${date}T${timeSlot}:00`).toISOString();
|
||||
const selectedDoc = doctors.find(d => d.id === doctor);
|
||||
await apiClient.graphql(
|
||||
`mutation($id: UUID!, $data: AppointmentUpdateInput!) { updateAppointment(id: $id, data: $data) { id } }`,
|
||||
{
|
||||
id: appointment.id,
|
||||
data: {
|
||||
scheduledAt,
|
||||
doctorName: selectedDoc?.name ?? appointment.doctorName,
|
||||
department: department || appointment.department,
|
||||
reasonForVisit: reason || null,
|
||||
status: 'RESCHEDULED',
|
||||
doctorId: doctor,
|
||||
},
|
||||
},
|
||||
);
|
||||
onSaved();
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? 'Failed to update appointment');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiClient.graphql(
|
||||
`mutation($id: UUID!, $data: AppointmentUpdateInput!) { updateAppointment(id: $id, data: $data) { id } }`,
|
||||
{ id: appointment.id, data: { status: 'CANCELLED' } },
|
||||
);
|
||||
notify.success('Appointment Cancelled');
|
||||
onSaved();
|
||||
} catch {
|
||||
setError('Failed to cancel appointment');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between border-b border-secondary px-5 py-3">
|
||||
<h3 className="text-sm font-bold text-primary">Reschedule Appointment</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
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"
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-4">
|
||||
{/* Department */}
|
||||
<div>
|
||||
<span className="text-xs font-medium text-secondary">Department</span>
|
||||
<Select
|
||||
size="sm"
|
||||
placeholder="Select department"
|
||||
selectedKey={department}
|
||||
onSelectionChange={(key) => { setDepartment(String(key)); setDoctor(''); }}
|
||||
items={departments.map(d => ({ id: d, label: d.replace(/_/g, ' ') }))}
|
||||
>
|
||||
{(item) => <Select.Item id={item.id}>{item.label}</Select.Item>}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Doctor */}
|
||||
<div>
|
||||
<span className="text-xs font-medium text-secondary">Doctor <span className="text-error-primary">*</span></span>
|
||||
<Select
|
||||
size="sm"
|
||||
placeholder="Select doctor"
|
||||
selectedKey={doctor}
|
||||
onSelectionChange={(key) => setDoctor(String(key))}
|
||||
items={filteredDoctors.map(d => ({ id: d.id, label: d.name }))}
|
||||
>
|
||||
{(item) => <Select.Item id={item.id}>{item.label}</Select.Item>}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<div>
|
||||
<span className="text-xs font-medium text-secondary">Date <span className="text-error-primary">*</span></span>
|
||||
<DatePicker
|
||||
value={date ? parseDate(date) : null}
|
||||
onChange={(val) => setDate(val ? val.toString() : '')}
|
||||
granularity="day"
|
||||
minValue={today(getLocalTimeZone())}
|
||||
isDisabled={!doctor}
|
||||
popoverPlacement="top start"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Time slots */}
|
||||
{doctor && date && slots.length > 0 && (
|
||||
<div>
|
||||
<span className="text-xs font-medium text-secondary">Time Slot <span className="text-error-primary">*</span></span>
|
||||
<div className="mt-1 grid grid-cols-3 gap-1.5">
|
||||
{slots.map(s => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => setTimeSlot(s.id)}
|
||||
className={cx(
|
||||
'rounded-lg border px-2 py-1.5 text-xs font-medium transition duration-100 ease-linear',
|
||||
timeSlot === s.id
|
||||
? 'border-brand bg-brand-primary text-brand-secondary'
|
||||
: 'border-secondary text-secondary hover:border-brand hover:text-brand-secondary',
|
||||
)}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{doctor && date && slots.length === 0 && (
|
||||
<p className="text-xs text-tertiary">No available slots for this date</p>
|
||||
)}
|
||||
|
||||
{/* Chief Complaint */}
|
||||
<div>
|
||||
<span className="text-xs font-medium text-secondary">Chief Complaint</span>
|
||||
<textarea
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
rows={3}
|
||||
className="mt-1 w-full rounded-lg border border-primary bg-primary px-3 py-2 text-sm text-primary outline-none focus:border-brand-primary resize-y"
|
||||
placeholder="Reason for visit..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-error-primary">{error}</p>}
|
||||
</div>
|
||||
|
||||
{/* Footer buttons */}
|
||||
<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}>
|
||||
Cancel Appointment
|
||||
</Button>
|
||||
<Button size="sm" color="primary" onClick={handleUpdate} isLoading={saving} isDisabled={!doctor || !date || !timeSlot}>
|
||||
Update Appointment
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Cancel confirm modal */}
|
||||
<ModalOverlay
|
||||
isOpen={cancelConfirm}
|
||||
onOpenChange={(open) => { if (!open) setCancelConfirm(false); }}
|
||||
isDismissable
|
||||
>
|
||||
<Modal className="sm:max-w-md">
|
||||
<Dialog>
|
||||
{() => (
|
||||
<div className="flex flex-col gap-4 rounded-xl bg-primary p-6 shadow-xl ring-1 ring-secondary">
|
||||
<h2 className="text-lg font-semibold text-primary">Cancel this appointment?</h2>
|
||||
<p className="text-sm text-tertiary">
|
||||
This will mark the appointment as cancelled. The patient will need to book a new appointment.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Button size="sm" color="secondary" onClick={() => setCancelConfirm(false)}>
|
||||
No, keep it
|
||||
</Button>
|
||||
<Button size="sm" color="primary-destructive" onClick={() => { setCancelConfirm(false); handleCancel(); }} isLoading={saving}>
|
||||
Yes, cancel appointment
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ── Page ─────────────────────────────────────────────────────────
|
||||
export const AppointmentsPageV2 = () => {
|
||||
const [appointments, setAppointments] = useState<AppointmentRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tab, setTab] = useState<StatusTab>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [selectedAppt, setSelectedAppt] = useState<AppointmentRecord | null>(null);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const [rescheduleOpen, setRescheduleOpen] = useState(false);
|
||||
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 counts: Record<string, number> = {};
|
||||
for (const a of appointments) {
|
||||
const s = a.status ?? 'UNKNOWN';
|
||||
counts[s] = (counts[s] ?? 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}, [appointments]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let rows = appointments;
|
||||
if (tab !== 'all') rows = rows.filter(a => a.status === tab);
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
rows = rows.filter(a => {
|
||||
const name = getPatientName(a).toLowerCase();
|
||||
const phone = getPhone(a);
|
||||
const doctor = (a.doctorName ?? '').toLowerCase();
|
||||
return name.includes(q) || phone.includes(q) || doctor.includes(q);
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}, [appointments, tab, search]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||
const pagedRows = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
|
||||
useEffect(() => { setPage(1); }, [tab, search]);
|
||||
|
||||
const tabItems = [
|
||||
{ id: 'all' as const, label: 'All', badge: appointments.length > 0 ? String(appointments.length) : undefined },
|
||||
{ id: 'SCHEDULED' as const, label: 'Booked', badge: statusCounts.SCHEDULED ? String(statusCounts.SCHEDULED) : undefined },
|
||||
{ id: 'COMPLETED' as const, label: 'Completed', badge: statusCounts.COMPLETED ? String(statusCounts.COMPLETED) : undefined },
|
||||
{ id: 'CANCELLED' as const, label: 'Cancelled', badge: statusCounts.CANCELLED ? String(statusCounts.CANCELLED) : undefined },
|
||||
{ id: 'RESCHEDULED' as const, label: 'Rescheduled', badge: statusCounts.RESCHEDULED ? String(statusCounts.RESCHEDULED) : undefined },
|
||||
];
|
||||
|
||||
const handleEditClick = (appt: AppointmentRecord) => {
|
||||
setSelectedAppt(appt);
|
||||
setPanelOpen(true);
|
||||
setRescheduleOpen(false);
|
||||
};
|
||||
|
||||
const handleSendReminder = (appt: AppointmentRecord) => {
|
||||
const phone = getPhone(appt);
|
||||
if (!phone) return;
|
||||
const msg = encodeURIComponent(buildReminderMessage(appt));
|
||||
window.open(`https://wa.me/91${phone}?text=${msg}`, '_blank');
|
||||
notify.success('Reminder', `WhatsApp opened for ${getPatientName(appt)}`);
|
||||
};
|
||||
|
||||
const handleRescheduleSaved = () => {
|
||||
setRescheduleOpen(false);
|
||||
setPanelOpen(false);
|
||||
setSelectedAppt(null);
|
||||
fetchAppointments();
|
||||
notify.success('Appointment Rescheduled');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Appointments"
|
||||
badge={filtered.length}
|
||||
infoText="All scheduled, completed, cancelled, and rescheduled appointments. Click the eye icon to view details or reschedule."
|
||||
controls={
|
||||
<div className="w-56">
|
||||
<Input
|
||||
placeholder="Search patient, doctor..."
|
||||
icon={SearchLg}
|
||||
size="sm"
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
aria-label="Search appointments"
|
||||
/>
|
||||
</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 flex-col overflow-hidden">
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-hidden px-4 pt-3">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<p className="text-sm text-tertiary">Loading appointments...</p>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<p className="text-sm text-quaternary">{search ? 'No matching appointments' : 'No appointments found'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<Table size="sm">
|
||||
<Table.Header>
|
||||
<Table.Head label="" className="w-8" isRowHeader />
|
||||
<Table.Head label="PATIENT" className="min-w-[180px]" />
|
||||
<Table.Head label="DATE & TIME" className="w-28" />
|
||||
<Table.Head label="DOCTOR" className="min-w-[160px]" />
|
||||
<Table.Head label="STATUS" className="w-24" />
|
||||
<Table.Head label="REMIND" className="w-20" />
|
||||
</Table.Header>
|
||||
<Table.Body items={pagedRows}>
|
||||
{(appt) => {
|
||||
const name = getPatientName(appt);
|
||||
const phone = getPhone(appt);
|
||||
const statusLabel = STATUS_LABELS[appt.status ?? ''] ?? appt.status ?? '—';
|
||||
const statusColor = STATUS_COLORS[appt.status ?? ''] ?? 'gray';
|
||||
const upcoming = isUpcoming(appt);
|
||||
const isSelected = selectedAppt?.id === appt.id;
|
||||
|
||||
return (
|
||||
<Table.Row
|
||||
id={appt.id}
|
||||
className={cx('group/row', isSelected && 'bg-brand-primary')}
|
||||
>
|
||||
{/* Eye icon — first column */}
|
||||
<Table.Cell>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleEditClick(appt); }}
|
||||
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>
|
||||
|
||||
{/* Patient: name + phone on 2 lines */}
|
||||
<Table.Cell>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-primary truncate">{name}</p>
|
||||
{phone && <PhoneActionCell phoneNumber={phone} displayNumber={formatPhone({ number: phone, callingCode: '+91' })} />}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
|
||||
{/* Date & Time: date + time on 2 lines */}
|
||||
<Table.Cell>
|
||||
{appt.scheduledAt ? (
|
||||
<div>
|
||||
<p className="text-sm text-primary">{formatDateOnly(appt.scheduledAt)}</p>
|
||||
<p className="text-xs text-tertiary">{formatTimeOnly(appt.scheduledAt)}</p>
|
||||
</div>
|
||||
) : <span className="text-sm text-quaternary">—</span>}
|
||||
</Table.Cell>
|
||||
|
||||
{/* Doctor: name + department on 2 lines */}
|
||||
<Table.Cell>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-primary truncate">{appt.doctorName ?? '—'}</p>
|
||||
{appt.department && <p className="text-xs text-tertiary truncate">{appt.department}</p>}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
|
||||
{/* Status */}
|
||||
<Table.Cell>
|
||||
<Badge size="sm" color={statusColor} type="pill-color">
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
|
||||
{/* Reminder */}
|
||||
<Table.Cell>
|
||||
{upcoming ? (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleSendReminder(appt); }}
|
||||
className="inline-flex items-center gap-1 rounded-lg px-2 py-1 text-xs font-medium text-brand-secondary hover:bg-brand-primary transition duration-100 ease-linear"
|
||||
title="Send WhatsApp reminder"
|
||||
>
|
||||
<FontAwesomeIcon icon={faBell} className="size-3" />
|
||||
Send
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-xs text-quaternary">—</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
|
||||
</Table.Row>
|
||||
);
|
||||
}}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)}
|
||||
<div className="shrink-0">
|
||||
<PaginationCardDefault
|
||||
page={page}
|
||||
total={totalPages}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail side panel */}
|
||||
<div className={cx(
|
||||
"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 && !rescheduleOpen && (
|
||||
<AppointmentDetailPanel
|
||||
appointment={selectedAppt}
|
||||
onClose={() => { setPanelOpen(false); setSelectedAppt(null); }}
|
||||
onReschedule={() => setRescheduleOpen(true)}
|
||||
/>
|
||||
)}
|
||||
{panelOpen && selectedAppt && rescheduleOpen && (
|
||||
<ReschedulePanel
|
||||
appointment={selectedAppt}
|
||||
onClose={() => setRescheduleOpen(false)}
|
||||
onSaved={handleRescheduleSaved}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
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 { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom, sipCallDurationAtom } from '@/state/sip-state';
|
||||
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 type { ContextPanelSubject } from '@/components/call-desk/context-panel';
|
||||
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 { notify } from '@/lib/toast';
|
||||
import { cx } from '@/utils/cx';
|
||||
|
||||
const SearchLg = faIcon(faMagnifyingGlass);
|
||||
|
||||
export const CallDeskPage = () => {
|
||||
const { user } = useAuth();
|
||||
const { leadActivities, calls, followUps: dataFollowUps, patients, appointments } = useData();
|
||||
@@ -30,6 +34,7 @@ export const CallDeskPage = () => {
|
||||
const [diallerOpen, setDiallerOpen] = useState(false);
|
||||
const [dialNumber, setDialNumber] = useState('');
|
||||
const [dialling, setDialling] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
// DEV: simulate incoming call
|
||||
const setSimCallState = useSetAtom(sipCallStateAtom);
|
||||
@@ -166,14 +171,29 @@ export const CallDeskPage = () => {
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Compact header: title + name on left, status + toggle on right */}
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-6 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Header — matches PageHeader visual pattern */}
|
||||
<div className="flex shrink-0 items-center justify-between px-6 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<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 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 ? (
|
||||
<button
|
||||
onClick={startSimCall}
|
||||
@@ -283,6 +303,7 @@ export const CallDeskPage = () => {
|
||||
onSelectItem={handleSelectItem}
|
||||
selectedItemId={selectedItemId}
|
||||
onDialMissedCall={(id) => setActiveMissedCallId(id)}
|
||||
search={search}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,28 +11,35 @@ import {
|
||||
} from '@fortawesome/pro-duotone-svg-icons';
|
||||
|
||||
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 { Button } from '@/components/base/buttons/button';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
import { Select } from '@/components/base/select/select';
|
||||
import { ClickToCallButton } from '@/components/call-desk/click-to-call-button';
|
||||
import { PageHeader } from '@/components/layout/page-header';
|
||||
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
||||
import { formatShortDate, formatPhone } from '@/lib/format';
|
||||
import { computeSlaStatus } from '@/lib/scoring';
|
||||
import { cx } from '@/utils/cx';
|
||||
// cx removed — no longer used after SLA column removal
|
||||
import { useData } from '@/providers/data-provider';
|
||||
import { useAuth } from '@/providers/auth-provider';
|
||||
import { PaginationCardDefault } from '@/components/application/pagination/pagination';
|
||||
import type { Call, CallDirection, CallDisposition } from '@/types/entities';
|
||||
|
||||
type FilterKey = 'all' | 'inbound' | 'outbound' | 'missed';
|
||||
|
||||
const filterItems = [
|
||||
const allFilterItems = [
|
||||
{ id: 'all' as const, label: 'All Calls' },
|
||||
{ id: 'inbound' as const, label: 'Inbound' },
|
||||
{ id: 'outbound' as const, label: 'Outbound' },
|
||||
{ id: 'missed' as const, label: 'Missed' },
|
||||
];
|
||||
|
||||
const agentFilterItems = [
|
||||
{ id: 'all' as const, label: 'All Calls' },
|
||||
{ id: 'inbound' as const, label: 'Inbound' },
|
||||
{ id: 'outbound' as const, label: 'Outbound' },
|
||||
];
|
||||
|
||||
const dispositionConfig: Record<CallDisposition, { label: string; color: 'success' | 'brand' | 'blue-light' | 'warning' | 'gray' | 'error' }> = {
|
||||
APPOINTMENT_BOOKED: { label: 'Appt Booked', color: 'success' },
|
||||
APPOINTMENT_RESCHEDULED: { label: 'Appt Rescheduled', color: 'warning' },
|
||||
@@ -54,13 +61,6 @@ const formatDuration = (seconds: number | null): string => {
|
||||
return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
|
||||
};
|
||||
|
||||
const formatPhoneDisplay = (call: Call): string => {
|
||||
if (call.callerNumber && call.callerNumber.length > 0) {
|
||||
return formatPhone(call.callerNumber[0]);
|
||||
}
|
||||
return '\u2014';
|
||||
};
|
||||
|
||||
const DirectionIcon: FC<{ direction: CallDirection | null; status: Call['callStatus'] }> = ({ direction, status }) => {
|
||||
if (status === 'MISSED') {
|
||||
return <FontAwesomeIcon icon={faPhoneXmark} className="size-4 text-fg-error-secondary" />;
|
||||
@@ -71,12 +71,6 @@ const DirectionIcon: FC<{ direction: CallDirection | null; status: Call['callSta
|
||||
return <FontAwesomeIcon icon={faPhoneArrowDown} className="size-4 text-fg-success-secondary" />;
|
||||
};
|
||||
|
||||
const getCallSla = (call: Call): { percent: number; status: 'low' | 'medium' | 'high' | 'critical' } | null => {
|
||||
if (call.sla == null) return null;
|
||||
const percent = Math.round(call.sla);
|
||||
return { percent, status: computeSlaStatus(percent) };
|
||||
};
|
||||
|
||||
const RecordingPlayer: FC<{ url: string }> = ({ url }) => {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
@@ -88,8 +82,7 @@ const RecordingPlayer: FC<{ url: string }> = ({ url }) => {
|
||||
audio.pause();
|
||||
setIsPlaying(false);
|
||||
} else {
|
||||
audio.play().catch(() => setIsPlaying(false));
|
||||
setIsPlaying(true);
|
||||
audio.play().then(() => setIsPlaying(true)).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -119,11 +112,11 @@ const PAGE_SIZE = 20;
|
||||
|
||||
export const CallHistoryPage = () => {
|
||||
const { calls, leads } = useData();
|
||||
const { user, isAdmin } = useAuth();
|
||||
const [search, setSearch] = useState('');
|
||||
const [filter, setFilter] = useState<FilterKey>('all');
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
// Build a map of lead names by ID for enrichment
|
||||
const leadNameMap = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const lead of leads) {
|
||||
@@ -135,7 +128,10 @@ export const CallHistoryPage = () => {
|
||||
return map;
|
||||
}, [leads]);
|
||||
|
||||
// Sort by time (newest first) and apply filters
|
||||
// Agent sees only their own calls; supervisor sees all
|
||||
const agentConfig = localStorage.getItem('helix_agent_config');
|
||||
const myAgentId = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null;
|
||||
|
||||
const filteredCalls = useMemo(() => {
|
||||
let result = [...calls].sort((a, b) => {
|
||||
const dateA = a.startedAt ? new Date(a.startedAt).getTime() : 0;
|
||||
@@ -143,56 +139,71 @@ export const CallHistoryPage = () => {
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
// Direction / status filter. "Inbound" shows answered inbound only — missed
|
||||
// calls have their own dedicated filter so they don't double-appear.
|
||||
// CC agent: filter to own calls only.
|
||||
// Match on the authoritative agent relation (set by CDR enrichment)
|
||||
// or the raw agentName for unenriched rows. Chain names like
|
||||
// "RamaiahAdmin -> GlobalHealthX" are split — last segment is
|
||||
// the final handler. Missed calls have no handler and are excluded
|
||||
// from the agent's personal history (they belong on the Missed
|
||||
// Calls queue).
|
||||
if (!isAdmin && myAgentId) {
|
||||
const myId = myAgentId.toLowerCase();
|
||||
result = result.filter((c) => {
|
||||
// Missed calls have no handler — exclude from agent history
|
||||
if (c.callStatus === 'MISSED') return false;
|
||||
// Authoritative: agent relation from CDR enrichment
|
||||
if (c.agent?.ozonetelAgentId?.toLowerCase() === myId) return true;
|
||||
// Fallback: parse chain in agentName, match last segment
|
||||
if (c.agentName) {
|
||||
const segments = c.agentName.split('->').map(s => s.trim().toLowerCase());
|
||||
const finalHandler = segments[segments.length - 1];
|
||||
if (finalHandler === myId) return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
if (filter === 'inbound') result = result.filter((c) => c.callDirection === 'INBOUND' && c.callStatus !== 'MISSED');
|
||||
else if (filter === 'outbound') result = result.filter((c) => c.callDirection === 'OUTBOUND');
|
||||
else if (filter === 'missed') result = result.filter((c) => c.callStatus === 'MISSED');
|
||||
|
||||
// Search filter
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
result = result.filter((c) => {
|
||||
const name = c.leadName ?? leadNameMap.get(c.leadId ?? '') ?? '';
|
||||
const phone = c.callerNumber?.[0]?.number ?? '';
|
||||
const agent = c.agentName ?? '';
|
||||
return (
|
||||
name.toLowerCase().includes(q) ||
|
||||
phone.includes(q) ||
|
||||
agent.toLowerCase().includes(q)
|
||||
);
|
||||
return name.toLowerCase().includes(q) || phone.includes(q) || agent.toLowerCase().includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [calls, filter, search, leadNameMap]);
|
||||
}, [calls, filter, search, leadNameMap, isAdmin, myAgentId, user.id]);
|
||||
|
||||
const completedCount = filteredCalls.filter((c) => c.callStatus !== 'MISSED').length;
|
||||
const completedCount = filteredCalls.filter((c) => c.callStatus === 'COMPLETED').length;
|
||||
const missedCount = filteredCalls.filter((c) => c.callStatus === 'MISSED').length;
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredCalls.length / PAGE_SIZE));
|
||||
const pagedCalls = filteredCalls.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||
|
||||
// Reset page when filter/search changes
|
||||
useEffect(() => { setPage(1); }, [filter, search]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex flex-1 flex-col overflow-hidden p-7">
|
||||
<TableCard.Root size="md" className="flex-1 min-h-0">
|
||||
<TableCard.Header
|
||||
title="Call History"
|
||||
badge={String(filteredCalls.length)}
|
||||
description={`${completedCount} completed \u00B7 ${missedCount} missed`}
|
||||
contentTrailing={
|
||||
<div className="flex items-center gap-2">
|
||||
<PageHeader
|
||||
title={isAdmin ? 'Call History' : 'My Call History'}
|
||||
badge={filteredCalls.length}
|
||||
subtitle={isAdmin ? `${completedCount} completed \u00B7 ${missedCount} missed` : `${completedCount} completed`}
|
||||
infoText={isAdmin ? 'All calls across all agents with recordings and dispositions.' : 'Your answered inbound and outbound calls.'}
|
||||
controls={
|
||||
<>
|
||||
<div className="w-44">
|
||||
<Select
|
||||
size="sm"
|
||||
placeholder="All Calls"
|
||||
selectedKey={filter}
|
||||
onSelectionChange={(key) => setFilter(key as FilterKey)}
|
||||
items={filterItems}
|
||||
items={isAdmin ? allFilterItems : agentFilterItems}
|
||||
aria-label="Filter calls"
|
||||
>
|
||||
{(item) => (
|
||||
@@ -212,10 +223,11 @@ export const CallHistoryPage = () => {
|
||||
aria-label="Search calls"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3">
|
||||
{filteredCalls.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16">
|
||||
<h3 className="text-sm font-semibold text-primary">No calls found</h3>
|
||||
@@ -231,18 +243,15 @@ export const CallHistoryPage = () => {
|
||||
<Table.Head label="PHONE" />
|
||||
<Table.Head label="DURATION" className="w-24" />
|
||||
<Table.Head label="OUTCOME" />
|
||||
<Table.Head label="SLA" className="w-24" />
|
||||
<Table.Head label="AGENT" />
|
||||
<Table.Head label="RECORDING" className="w-24" />
|
||||
{/* Agent columns — only visible for supervisor */}
|
||||
{isAdmin && <Table.Head label="AGENT" />}
|
||||
{isAdmin && <Table.Head label="RECORDING" className="w-24" />}
|
||||
<Table.Head label="TIME" />
|
||||
<Table.Head label="ACTIONS" className="w-24" />
|
||||
</Table.Header>
|
||||
<Table.Body items={pagedCalls}>
|
||||
{(call) => {
|
||||
const phoneRawForName = call.callerNumber?.[0]?.number ?? '';
|
||||
const patientName = call.leadName ?? leadNameMap.get(call.leadId ?? '') ?? (phoneRawForName ? formatPhone({ number: phoneRawForName, callingCode: '+91' }) : 'Unknown');
|
||||
const phoneDisplay = formatPhoneDisplay(call);
|
||||
const phoneRaw = call.callerNumber?.[0]?.number ?? '';
|
||||
const patientName = call.leadName ?? leadNameMap.get(call.leadId ?? '') ?? (phoneRaw ? formatPhone({ number: phoneRaw, callingCode: '+91' }) : 'Unknown');
|
||||
const dispositionCfg = call.disposition !== null ? dispositionConfig[call.disposition] : null;
|
||||
|
||||
return (
|
||||
@@ -256,9 +265,14 @@ export const CallHistoryPage = () => {
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span className="text-sm text-tertiary whitespace-nowrap">
|
||||
{phoneDisplay}
|
||||
</span>
|
||||
{phoneRaw ? (
|
||||
<PhoneActionCell
|
||||
phoneNumber={phoneRaw}
|
||||
displayNumber={formatPhone({ number: phoneRaw, callingCode: '+91' })}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm text-quaternary">{'\u2014'}</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span className="text-sm text-secondary whitespace-nowrap">
|
||||
@@ -274,29 +288,14 @@ export const CallHistoryPage = () => {
|
||||
<span className="text-sm text-quaternary">{'\u2014'}</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{(() => {
|
||||
const sla = getCallSla(call);
|
||||
if (!sla) return <span className="text-xs text-quaternary">—</span>;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-medium">
|
||||
<span className={cx(
|
||||
'size-2 rounded-full',
|
||||
sla.status === 'low' && 'bg-success-solid',
|
||||
sla.status === 'medium' && 'bg-warning-solid',
|
||||
sla.status === 'high' && 'bg-error-solid',
|
||||
sla.status === 'critical' && 'bg-error-solid animate-pulse',
|
||||
)} />
|
||||
<span className="text-secondary">{sla.percent}%</span>
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</Table.Cell>
|
||||
{isAdmin && (
|
||||
<Table.Cell>
|
||||
<span className="text-sm text-secondary">
|
||||
{call.agentName ?? '\u2014'}
|
||||
{call.agent?.name ?? call.agentName ?? '\u2014'}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Table.Cell>
|
||||
{call.recordingUrl ? (
|
||||
<RecordingPlayer url={call.recordingUrl} />
|
||||
@@ -304,38 +303,28 @@ export const CallHistoryPage = () => {
|
||||
<span className="text-xs text-quaternary">{'\u2014'}</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
)}
|
||||
<Table.Cell>
|
||||
<span className="text-sm text-tertiary whitespace-nowrap">
|
||||
{call.startedAt ? formatShortDate(call.startedAt) : '\u2014'}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{phoneRaw ? (
|
||||
<ClickToCallButton
|
||||
phoneNumber={phoneRaw}
|
||||
leadId={call.leadId ?? undefined}
|
||||
label="Call"
|
||||
size="sm"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-quaternary">{'\u2014'}</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
}}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)}
|
||||
<div className="shrink-0">
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="shrink-0 border-t border-secondary px-6 py-3">
|
||||
<PaginationCardDefault
|
||||
page={page}
|
||||
total={totalPages}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
</div>
|
||||
</TableCard.Root>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ const SearchLg: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon
|
||||
import { Button } from '@/components/base/buttons/button';
|
||||
import { Input } from '@/components/base/input/input';
|
||||
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 { ColumnToggle, useColumnVisibility } from '@/components/application/table/column-toggle';
|
||||
import { LeadActivitySlideout } from '@/components/leads/lead-activity-slideout';
|
||||
@@ -113,14 +113,12 @@ export const ContactsPage = () => {
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<TopBar title="Contacts" subtitle={`${contacts.length} organic callers`} />
|
||||
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex shrink-0 items-center justify-between px-6 py-3 border-b border-secondary">
|
||||
<p className="text-xs text-tertiary">
|
||||
People who reached out directly — phone, walk-in, referral. Not sourced from campaigns.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<PageHeader
|
||||
title="Contacts"
|
||||
badge={contacts.length}
|
||||
infoText="People who reached out directly — phone, walk-in, referral. Not sourced from campaigns."
|
||||
controls={
|
||||
<>
|
||||
<div className="w-56">
|
||||
<Input
|
||||
placeholder="Search contacts..."
|
||||
@@ -135,9 +133,11 @@ export const ContactsPage = () => {
|
||||
<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-1 overflow-y-auto px-4 pt-3">
|
||||
<LeadTable
|
||||
leads={paged}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Input } from '@/components/base/input/input';
|
||||
import { Table } from '@/components/application/table/table';
|
||||
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
|
||||
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 { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
@@ -238,23 +238,27 @@ export const MissedCallsPage = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<TopBar title="Missed Calls" />
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Tabs + toolbar */}
|
||||
<div className="flex shrink-0 items-end justify-between border-b border-secondary px-6 pt-3 pb-0.5">
|
||||
<PageHeader
|
||||
title="Missed Calls"
|
||||
badge={calls.length}
|
||||
infoText="Inbound calls that were not answered. Agents can call back from here."
|
||||
controls={
|
||||
<>
|
||||
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} />
|
||||
<div className="w-56">
|
||||
<Input placeholder="Search phone, agent, branch..." icon={SearchLg} size="sm" value={search} onChange={handleSearch} />
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
tabs={
|
||||
<Tabs selectedKey={tab} onSelectionChange={(key) => handleTab(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 className="flex items-center gap-3 pb-1">
|
||||
<ColumnToggle columns={columnDefs} visibleColumns={visibleColumns} onToggle={toggleColumn} />
|
||||
<div className="w-56">
|
||||
<Input placeholder="Search phone, agent, branch..." icon={SearchLg} size="sm" value={search} onChange={handleSearch} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Table */}
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3">
|
||||
@@ -287,6 +291,5 @@ export const MissedCallsPage = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
// useNavigate removed — row click opens profile panel
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faUser, faMagnifyingGlass, faEye, faCommentDots, faMessageDots, 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';
|
||||
|
||||
const SearchLg = faIcon(faMagnifyingGlass);
|
||||
import { Avatar } from '@/components/base/avatar/avatar';
|
||||
// Button removed — actions are icon-only now
|
||||
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 { ClickToCallButton } from '@/components/call-desk/click-to-call-button';
|
||||
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
||||
import { PatientProfilePanel } from '@/components/shared/patient-profile-panel';
|
||||
import { useData } from '@/providers/data-provider';
|
||||
import { getInitials } from '@/lib/format';
|
||||
@@ -55,9 +55,9 @@ const getPatientEmail = (patient: Patient): string => {
|
||||
return patient.emails?.primaryEmail ?? '';
|
||||
};
|
||||
|
||||
|
||||
export const PatientsPage = () => {
|
||||
const { patients, loading } = useData();
|
||||
const navigate = useNavigate();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
@@ -85,15 +85,12 @@ export const PatientsPage = () => {
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="flex flex-1 flex-col overflow-y-auto p-7">
|
||||
<TableCard.Root size="sm">
|
||||
<TableCard.Header
|
||||
<PageHeader
|
||||
title="All Patients"
|
||||
badge={filteredPatients.length}
|
||||
description="Manage and view patient records"
|
||||
contentTrailing={
|
||||
<div className="flex items-center gap-2">
|
||||
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"
|
||||
@@ -112,10 +109,12 @@ export const PatientsPage = () => {
|
||||
aria-label="Search patients"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-4 pt-3">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<p className="text-sm text-tertiary">Loading patients...</p>
|
||||
@@ -132,10 +131,10 @@ export const PatientsPage = () => {
|
||||
<Table>
|
||||
<Table.Header>
|
||||
<Table.Head label="PATIENT" isRowHeader />
|
||||
<Table.Head label="CONTACT" />
|
||||
<Table.Head label="PHONE" />
|
||||
<Table.Head label="EMAIL" />
|
||||
<Table.Head label="GENDER" />
|
||||
<Table.Head label="AGE" />
|
||||
<Table.Head label="ACTIONS" />
|
||||
</Table.Header>
|
||||
<Table.Body items={pagedPatients} dependencies={[selectedPatient?.id]}>
|
||||
{(patient) => {
|
||||
@@ -152,7 +151,7 @@ export const PatientsPage = () => {
|
||||
<Table.Row
|
||||
id={patient.id}
|
||||
className={cx(
|
||||
'cursor-pointer',
|
||||
'cursor-pointer group/row',
|
||||
selectedPatient?.id === patient.id && 'bg-brand-primary'
|
||||
)}
|
||||
onAction={() => {
|
||||
@@ -180,18 +179,22 @@ export const PatientsPage = () => {
|
||||
</div>
|
||||
</Table.Cell>
|
||||
|
||||
{/* Contact */}
|
||||
{/* Phone — clickable to dial */}
|
||||
<Table.Cell>
|
||||
<div className="flex flex-col">
|
||||
{phone ? (
|
||||
<span className="text-sm text-secondary">{phone}</span>
|
||||
<PhoneActionCell phoneNumber={phone} displayNumber={phone} />
|
||||
) : (
|
||||
<span className="text-sm text-placeholder">No phone</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
|
||||
{/* Email */}
|
||||
<Table.Cell>
|
||||
{email ? (
|
||||
<span className="text-xs text-tertiary truncate max-w-[200px]">{email}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="text-sm text-tertiary truncate max-w-[200px] block">{email}</span>
|
||||
) : (
|
||||
<span className="text-sm text-quaternary">—</span>
|
||||
)}
|
||||
</Table.Cell>
|
||||
|
||||
{/* Gender */}
|
||||
@@ -208,48 +211,12 @@ export const PatientsPage = () => {
|
||||
</span>
|
||||
</Table.Cell>
|
||||
|
||||
{/* Actions */}
|
||||
<Table.Cell>
|
||||
<div className="flex items-center gap-1">
|
||||
{phone && (
|
||||
<>
|
||||
<ClickToCallButton
|
||||
phoneNumber={phone}
|
||||
size="sm"
|
||||
label=""
|
||||
/>
|
||||
<button
|
||||
onClick={() => window.open(`sms:+91${phone}`, '_self')}
|
||||
title="SMS"
|
||||
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-fg-brand-secondary hover:bg-primary_hover transition duration-100 ease-linear"
|
||||
>
|
||||
<FontAwesomeIcon icon={faCommentDots} className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.open(`https://wa.me/91${phone}`, '_blank')}
|
||||
title="WhatsApp"
|
||||
className="flex size-8 items-center justify-center rounded-lg text-fg-quaternary hover:text-[#25D366] hover:bg-primary_hover transition duration-100 ease-linear"
|
||||
>
|
||||
<FontAwesomeIcon icon={faMessageDots} className="size-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
onClick={() => navigate(`/patient/${patient.id}`)}
|
||||
title="View patient"
|
||||
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"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEye} className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
);
|
||||
}}
|
||||
</Table.Body>
|
||||
</Table>
|
||||
)}
|
||||
</TableCard.Root>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="shrink-0 border-t border-secondary px-6 py-3">
|
||||
|
||||
Reference in New Issue
Block a user