Files
helix-engage/src/components/application/table/table.tsx
saridsa2 4598740efe feat: inline forms, transfer redesign, patient fixes, UI polish
- Appointment/enquiry forms reverted to inline rendering (not modals)
- Forms: flat scrollable section with pinned footer, no card wrapper
- Appointment form: DatePicker component, date prefilled, removed Returning Patient checkbox
- Enquiry form: removed disposition dropdown, lead status defaults to CONTACTED
- Transfer dialog: agent picker with live status, doctor list with department, select-then-connect flow
- Transfer: removed external number input, moved Cancel/Connect to pinned header row
- Button mutual exclusivity: Book Appt / Enquiry / Transfer close each other
- Patient name write-back: appointment + enquiry forms update patient fullName after save
- Caller cache invalidation: POST /api/caller/invalidate after name update
- Follow-up fix (#513): assignedAgent, patientId, date validation in createFollowUp
- Patients page: removed status filters + column, added pagination (15/page)
- Pending badge removed from call desk header
- Table resize handles visible (bg-tertiary pill)
- Sim call button: dev-only (import.meta.env.DEV)
- CallControlStrip component (reusable, not currently mounted)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:14:38 +05:30

313 lines
13 KiB
TypeScript

import type { ComponentPropsWithRef, FC, HTMLAttributes, ReactNode, Ref, TdHTMLAttributes, ThHTMLAttributes } from "react";
import { createContext, isValidElement, useContext } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faArrowDown, faSort, faCopy, faPenToSquare, faCircleQuestion, faTrash } from "@fortawesome/pro-duotone-svg-icons";
const Edit01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faPenToSquare} className={className} />;
const Copy01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faCopy} className={className} />;
const Trash01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faTrash} className={className} />;
import type {
CellProps as AriaCellProps,
ColumnProps as AriaColumnProps,
RowProps as AriaRowProps,
TableHeaderProps as AriaTableHeaderProps,
TableProps as AriaTableProps,
} from "react-aria-components";
import {
Cell as AriaCell,
Collection as AriaCollection,
Column as AriaColumn,
ColumnResizer as AriaColumnResizer,
Group as AriaGroup,
ResizableTableContainer as AriaResizableTableContainer,
Row as AriaRow,
Table as AriaTable,
TableBody as AriaTableBody,
TableHeader as AriaTableHeader,
useTableOptions,
} from "react-aria-components";
import { Badge } from "@/components/base/badges/badges";
import { Checkbox } from "@/components/base/checkbox/checkbox";
import { Dropdown } from "@/components/base/dropdown/dropdown";
import { Tooltip, TooltipTrigger } from "@/components/base/tooltip/tooltip";
import { cx } from "@/utils/cx";
export const TableRowActionsDropdown = () => (
<Dropdown.Root>
<Dropdown.DotsButton />
<Dropdown.Popover className="w-min">
<Dropdown.Menu>
<Dropdown.Item icon={Edit01}>
<span className="pr-4">Edit</span>
</Dropdown.Item>
<Dropdown.Item icon={Copy01}>
<span className="pr-4">Copy link</span>
</Dropdown.Item>
<Dropdown.Item icon={Trash01}>
<span className="pr-4">Delete</span>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown.Root>
);
const TableContext = createContext<{ size: "sm" | "md" }>({ size: "md" });
const TableCardRoot = ({ children, className, size = "md", ...props }: HTMLAttributes<HTMLDivElement> & { size?: "sm" | "md" }) => {
return (
<TableContext.Provider value={{ size }}>
<div {...props} className={cx("flex flex-col overflow-hidden rounded-xl bg-primary shadow-xs ring-1 ring-secondary", className)}>
{children}
</div>
</TableContext.Provider>
);
};
interface TableCardHeaderProps {
/** The title of the table card header. */
title: string;
/** The badge displayed next to the title. */
badge?: ReactNode;
/** The description of the table card header. */
description?: string;
/** The content displayed after the title and badge. */
contentTrailing?: ReactNode;
/** The class name of the table card header. */
className?: string;
}
const TableCardHeader = ({ title, badge, description, contentTrailing, className }: TableCardHeaderProps) => {
const { size } = useContext(TableContext);
return (
<div
className={cx(
"relative shrink-0 flex flex-col items-start gap-4 border-b border-secondary bg-primary px-4 md:flex-row",
size === "sm" ? "py-4 md:px-5" : "py-5 md:px-6",
className,
)}
>
<div className="flex flex-1 flex-col gap-0.5">
<div className="flex items-center gap-2">
<h2 className={cx("font-semibold text-primary", size === "sm" ? "text-md" : "text-lg")}>{title}</h2>
{badge ? (
isValidElement(badge) ? (
badge
) : (
<Badge color="brand" size="sm">
{badge}
</Badge>
)
) : null}
</div>
{description && <p className="text-sm text-tertiary">{description}</p>}
</div>
{contentTrailing}
</div>
);
};
interface TableRootProps extends AriaTableProps, Omit<ComponentPropsWithRef<"table">, "className" | "slot" | "style"> {
size?: "sm" | "md";
}
const TableRoot = ({ className, size = "md", ...props }: TableRootProps) => {
const context = useContext(TableContext);
return (
<TableContext.Provider value={{ size: context?.size ?? size }}>
<AriaResizableTableContainer className="flex-1 overflow-auto min-h-0">
<AriaTable className={(state) => cx("w-full", typeof className === "function" ? className(state) : className)} {...props} />
</AriaResizableTableContainer>
</TableContext.Provider>
);
};
TableRoot.displayName = "Table";
interface TableHeaderProps<T extends object>
extends AriaTableHeaderProps<T>,
Omit<ComponentPropsWithRef<"thead">, "children" | "className" | "slot" | "style"> {
bordered?: boolean;
}
const TableHeader = <T extends object>({ columns, children, bordered = true, className, ...props }: TableHeaderProps<T>) => {
const { size } = useContext(TableContext);
const { selectionBehavior, selectionMode } = useTableOptions();
return (
<AriaTableHeader
{...props}
className={(state) =>
cx(
"relative bg-secondary sticky top-0 z-10",
size === "sm" ? "h-9" : "h-11",
// Row border—using an "after" pseudo-element to avoid the border taking up space.
bordered &&
"[&>tr>th]:after:pointer-events-none [&>tr>th]:after:absolute [&>tr>th]:after:inset-x-0 [&>tr>th]:after:bottom-0 [&>tr>th]:after:h-px [&>tr>th]:after:bg-border-secondary [&>tr>th]:focus-visible:after:bg-transparent",
typeof className === "function" ? className(state) : className,
)
}
>
{selectionBehavior === "toggle" && (
<AriaColumn className={cx("relative py-2 pr-0 pl-4", size === "sm" ? "w-9 md:pl-5" : "w-11 md:pl-6")}>
{selectionMode === "multiple" && (
<div className="flex items-start">
<Checkbox slot="selection" size={size} />
</div>
)}
</AriaColumn>
)}
<AriaCollection items={columns}>{children}</AriaCollection>
</AriaTableHeader>
);
};
TableHeader.displayName = "TableHeader";
interface TableHeadProps extends AriaColumnProps, Omit<ThHTMLAttributes<HTMLTableCellElement>, "children" | "className" | "style" | "id"> {
label?: string;
tooltip?: string;
resizable?: boolean;
}
const TableHead = ({ className, tooltip, label, children, resizable = true, ...props }: TableHeadProps) => {
const { selectionBehavior } = useTableOptions();
return (
<AriaColumn
{...props}
className={(state) =>
cx(
"relative p-0 px-6 py-2 outline-hidden focus-visible:z-1 focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-bg-primary focus-visible:ring-inset",
selectionBehavior === "toggle" && "nth-2:pl-3",
state.allowsSorting && "cursor-pointer",
typeof className === "function" ? className(state) : className,
)
}
>
{(state) => (
<AriaGroup className="flex items-center gap-1" role="presentation">
<div className="flex flex-1 items-center gap-1 truncate">
{label && <span className="text-xs font-semibold whitespace-nowrap text-quaternary">{label}</span>}
{typeof children === "function" ? children(state) : children}
</div>
{tooltip && (
<Tooltip title={tooltip} placement="top">
<TooltipTrigger className="cursor-pointer text-fg-quaternary transition duration-100 ease-linear hover:text-fg-quaternary_hover focus:text-fg-quaternary_hover">
<FontAwesomeIcon icon={faCircleQuestion} className="size-4" />
</TooltipTrigger>
</Tooltip>
)}
{state.allowsSorting &&
(state.sortDirection ? (
<FontAwesomeIcon icon={faArrowDown} className={cx("size-3 text-fg-quaternary", state.sortDirection === "ascending" && "rotate-180")} />
) : (
<FontAwesomeIcon icon={faSort} className="text-fg-quaternary" />
))}
{resizable && (
<AriaColumnResizer
className="absolute right-0 top-1 bottom-1 w-[3px] rounded-full bg-tertiary cursor-col-resize touch-none hover:bg-brand-solid focus-visible:bg-brand-solid transition-colors duration-100"
/>
)}
</AriaGroup>
)}
</AriaColumn>
);
};
TableHead.displayName = "TableHead";
interface TableRowProps<T extends object>
extends AriaRowProps<T>,
Omit<ComponentPropsWithRef<"tr">, "children" | "className" | "onClick" | "slot" | "style" | "id"> {
highlightSelectedRow?: boolean;
}
const TableRow = <T extends object>({ columns, children, className, highlightSelectedRow = true, ...props }: TableRowProps<T>) => {
const { size } = useContext(TableContext);
const { selectionBehavior } = useTableOptions();
return (
<AriaRow
{...props}
className={(state) =>
cx(
"relative outline-focus-ring transition-colors after:pointer-events-none hover:bg-secondary focus-visible:outline-2 focus-visible:-outline-offset-2",
size === "sm" ? "h-14" : "h-18",
highlightSelectedRow && "selected:bg-secondary",
// Row border—using an "after" pseudo-element to avoid the border taking up space.
"[&>td]:after:absolute [&>td]:after:inset-x-0 [&>td]:after:bottom-0 [&>td]:after:h-px [&>td]:after:w-full [&>td]:after:bg-border-secondary last:[&>td]:after:hidden [&>td]:focus-visible:after:opacity-0 focus-visible:[&>td]:after:opacity-0",
typeof className === "function" ? className(state) : className,
)
}
>
{selectionBehavior === "toggle" && (
<AriaCell className={cx("relative py-2 pr-0 pl-4", size === "sm" ? "md:pl-5" : "md:pl-6")}>
<div className="flex items-end">
<Checkbox slot="selection" size={size} />
</div>
</AriaCell>
)}
<AriaCollection items={columns}>{children}</AriaCollection>
</AriaRow>
);
};
TableRow.displayName = "TableRow";
interface TableCellProps extends AriaCellProps, Omit<TdHTMLAttributes<HTMLTableCellElement>, "children" | "className" | "style" | "id"> {
ref?: Ref<HTMLTableCellElement>;
}
const TableCell = ({ className, children, ...props }: TableCellProps) => {
const { size } = useContext(TableContext);
const { selectionBehavior } = useTableOptions();
return (
<AriaCell
{...props}
className={(state) =>
cx(
"relative text-sm text-tertiary outline-focus-ring focus-visible:z-1 focus-visible:outline-2 focus-visible:-outline-offset-2",
size === "sm" && "px-5 py-3",
size === "md" && "px-6 py-4",
selectionBehavior === "toggle" && "nth-2:pl-3",
typeof className === "function" ? className(state) : className,
)
}
>
{children}
</AriaCell>
);
};
TableCell.displayName = "TableCell";
const TableCard = {
Root: TableCardRoot,
Header: TableCardHeader,
};
const Table = TableRoot as typeof TableRoot & {
Body: typeof AriaTableBody;
Cell: typeof TableCell;
Head: typeof TableHead;
Header: typeof TableHeader;
Row: typeof TableRow;
};
Table.Body = AriaTableBody;
Table.Cell = TableCell;
Table.Head = TableHead;
Table.Header = TableHeader;
Table.Row = TableRow;
export { Table, TableCard };