mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
- 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>
313 lines
13 KiB
TypeScript
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 };
|