feat: consistent UI across all list pages — PhoneActionCell, custom pills, eye icon
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

- PhoneActionCell: kebab always visible (SMS + WhatsApp), Call removed from menu,
  phone number always brand-colored regardless of telephony state
- LeadTable: replaced actions kebab column with eye icon (first column) for
  view activity, phone column now uses PhoneActionCell
- Worklist: React Aria Tabs replaced with custom pill buttons matching All Leads
  pattern (bg-brand-solid on selected), search lifted to call-desk.tsx header
- Appointments: underline tabs replaced with custom pills, phone in patient cell
  uses PhoneActionCell, group/row added to rows
- Patients: removed redundant HamburgerMenu column, group/row on rows
- Call Desk: search input in header row, cleaned up duplicate imports

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-16 23:05:32 +05:30
parent 313842a922
commit bdabcb2ea4
6 changed files with 94 additions and 136 deletions

View File

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

View File

@@ -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';
@@ -299,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.
@@ -402,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);
@@ -436,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

View File

@@ -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>
);
}}