mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-12 02:38:15 +00:00
feat: call desk redesign — 2-panel layout, collapsible sidebar, inline AI, ringtone
- Collapsible sidebar with Jotai atom (icon-only mode, persisted to localStorage) - 2-panel call desk: worklist (60%) + context panel (40%) with AI + Lead 360 tabs - Inline AI call prep card — known lead summary or unknown caller script - Active call card with compact Answer/Decline buttons - Worklist panel with human-readable labels, priority badges, click-to-select - Context panel auto-switches to Lead 360 when lead selected or call incoming - Browser ringtone via Web Audio API on incoming calls - Sonner + Untitled UI IconNotification for toast system - apiClient pattern: centralized post/get/graphql with auto-toast on errors - Remove duplicate avatar from top bar, hide floating widget on call desk - Fix Link routing in collapsed sidebar (was using <a> causing full page reload) - Fix GraphQL field names: adStatus→status, platformUrl needs subfield selection - Silent mode for DataProvider queries to prevent toast spam Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
208
src/components/application/notifications/notifications.tsx
Normal file
208
src/components/application/notifications/notifications.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import type { FC } from "react";
|
||||
import { AlertCircle, CheckCircle, InfoCircle } from "@untitledui/icons";
|
||||
import { Avatar } from "@/components/base/avatar/avatar";
|
||||
import { Button } from "@/components/base/buttons/button";
|
||||
import { CloseButton } from "@/components/base/buttons/close-button";
|
||||
import { ProgressBar } from "@/components/base/progress-indicators/progress-indicators";
|
||||
import { FeaturedIcon } from "@/components/foundations/featured-icon/featured-icon";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
const iconMap = {
|
||||
default: InfoCircle,
|
||||
brand: InfoCircle,
|
||||
gray: InfoCircle,
|
||||
error: AlertCircle,
|
||||
warning: AlertCircle,
|
||||
success: CheckCircle,
|
||||
};
|
||||
|
||||
interface IconNotificationProps {
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel?: string;
|
||||
dismissLabel?: string;
|
||||
hideDismissLabel?: boolean;
|
||||
icon?: FC<{ className?: string }>;
|
||||
color?: "default" | "brand" | "gray" | "error" | "warning" | "success";
|
||||
progress?: number;
|
||||
onClose?: () => void;
|
||||
onConfirm?: () => void;
|
||||
}
|
||||
|
||||
export const IconNotification = ({
|
||||
title,
|
||||
description,
|
||||
confirmLabel,
|
||||
dismissLabel = "Dismiss",
|
||||
hideDismissLabel,
|
||||
icon,
|
||||
progress,
|
||||
onClose,
|
||||
onConfirm,
|
||||
color = "default",
|
||||
}: IconNotificationProps) => {
|
||||
const showProgress = typeof progress === "number";
|
||||
|
||||
return (
|
||||
<div className="relative z-[var(--z-index)] flex max-w-full flex-col gap-4 rounded-xl bg-primary_alt p-4 shadow-lg ring ring-secondary_alt xs:w-[var(--width)] xs:flex-row">
|
||||
<FeaturedIcon
|
||||
icon={icon || iconMap[color]}
|
||||
color={color === "default" ? "gray" : color}
|
||||
theme={color === "default" ? "modern" : "outline"}
|
||||
size="md"
|
||||
/>
|
||||
|
||||
<div className={cx("flex flex-1 flex-col gap-3 md:pr-8", color !== "default" && "md:pt-0.5", showProgress && "gap-4")}>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-semibold text-fg-primary">{title}</p>
|
||||
<p className="text-sm text-fg-secondary">{description}</p>
|
||||
</div>
|
||||
|
||||
{showProgress && <ProgressBar labelPosition="bottom" value={progress} valueFormatter={(value) => `${value}% uploaded...`} />}
|
||||
|
||||
<div className="flex gap-3">
|
||||
{!hideDismissLabel && (
|
||||
<Button onClick={onClose} size="sm" color="link-gray">
|
||||
{dismissLabel}
|
||||
</Button>
|
||||
)}
|
||||
{confirmLabel && (
|
||||
<Button onClick={onConfirm} size="sm" color="link-color">
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-2 right-2 flex items-center justify-center">
|
||||
<CloseButton onClick={onClose} size="sm" label="Dismiss" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface AvatarNotificationProps {
|
||||
name: string;
|
||||
content: string;
|
||||
avatar: string;
|
||||
date: string;
|
||||
confirmLabel: string;
|
||||
dismissLabel?: string;
|
||||
hideDismissLabel?: boolean;
|
||||
icon?: FC<{ className?: string }>;
|
||||
color?: "default" | "brand" | "gray" | "error" | "warning" | "success";
|
||||
onClose?: () => void;
|
||||
onConfirm?: () => void;
|
||||
}
|
||||
|
||||
export const AvatarNotification = ({
|
||||
name,
|
||||
content,
|
||||
avatar,
|
||||
confirmLabel,
|
||||
dismissLabel = "Dismiss",
|
||||
hideDismissLabel,
|
||||
date,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: AvatarNotificationProps) => {
|
||||
return (
|
||||
<div className="relative z-[var(--z-index)] flex max-w-full flex-col items-start gap-4 rounded-xl bg-primary_alt p-4 shadow-lg ring ring-secondary_alt xs:w-[var(--width)] xs:flex-row">
|
||||
<Avatar size="md" src={avatar} alt={name} status="online" />
|
||||
|
||||
<div className="flex flex-col gap-3 pr-8">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-semibold text-fg-primary">{name}</p>
|
||||
<span className="text-sm text-fg-quaternary">{date}</span>
|
||||
</div>
|
||||
<p className="text-sm text-fg-secondary">{content}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{!hideDismissLabel && (
|
||||
<Button onClick={onClose} size="sm" color="link-gray">
|
||||
{dismissLabel}
|
||||
</Button>
|
||||
)}
|
||||
{confirmLabel && (
|
||||
<Button onClick={onConfirm} size="sm" color="link-color">
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-2 right-2 flex items-center justify-center">
|
||||
<CloseButton onClick={onClose} size="sm" label="Dismiss" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ImageNotificationProps {
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel: string;
|
||||
dismissLabel?: string;
|
||||
hideDismissLabel?: boolean;
|
||||
imageMobile: string;
|
||||
imageDesktop: string;
|
||||
onClose?: () => void;
|
||||
onConfirm?: () => void;
|
||||
}
|
||||
|
||||
export const ImageNotification = ({
|
||||
title,
|
||||
description,
|
||||
confirmLabel,
|
||||
dismissLabel = "Dismiss",
|
||||
hideDismissLabel,
|
||||
imageMobile,
|
||||
imageDesktop,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: ImageNotificationProps) => {
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--width": "496px",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className="relative z-[var(--z-index)] flex max-w-full flex-col gap-3 rounded-xl bg-primary_alt p-4 shadow-lg max-md:ring-1 max-md:ring-secondary_alt xs:w-[var(--width)] xs:flex-row xs:gap-0 md:p-0"
|
||||
>
|
||||
<div className="-my-px hidden w-40 shrink-0 overflow-hidden rounded-l-xl outline-1 -outline-offset-1 outline-black/10 md:block">
|
||||
<img aria-hidden="true" src={imageMobile} alt="Image Mobile" className="t size-full object-cover" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 rounded-r-xl bg-primary_alt md:gap-3 md:p-4 md:pl-5 md:ring-1 md:ring-secondary_alt">
|
||||
<div className="flex flex-col gap-1 pr-8">
|
||||
<p className="text-sm font-semibold text-fg-primary">{title}</p>
|
||||
<p className="text-sm text-fg-secondary">{description}</p>
|
||||
</div>
|
||||
|
||||
<div className="h-40 w-full overflow-hidden rounded-md bg-secondary md:hidden">
|
||||
<img src={imageDesktop} alt="Image Desktop" className="size-full object-cover" />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{!hideDismissLabel && (
|
||||
<Button onClick={onClose} size="sm" color="link-gray">
|
||||
{dismissLabel}
|
||||
</Button>
|
||||
)}
|
||||
{confirmLabel && (
|
||||
<Button onClick={onConfirm} size="sm" color="link-color">
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-2 right-2 flex items-center justify-center">
|
||||
<CloseButton onClick={onClose} size="sm" label="Dismiss" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
72
src/components/application/notifications/toaster.tsx
Normal file
72
src/components/application/notifications/toaster.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { ToasterProps } from "sonner";
|
||||
import { Toaster as SonnerToaster, useSonner } from "sonner";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
export const DEFAULT_TOAST_POSITION = "bottom-right";
|
||||
|
||||
export const ToastsOverlay = () => {
|
||||
const { toasts } = useSonner();
|
||||
|
||||
const styles = {
|
||||
"top-right": {
|
||||
className: "top-0 right-0",
|
||||
background: "linear-gradient(215deg, rgba(0, 0, 0, 0.10) 0%, rgba(0, 0, 0, 0.00) 50%)",
|
||||
},
|
||||
"top-left": {
|
||||
className: "top-0 left-0",
|
||||
background: "linear-gradient(139deg, rgba(0, 0, 0, 0.10) 0%, rgba(0, 0, 0, 0.00) 40.64%)",
|
||||
},
|
||||
"bottom-right": {
|
||||
className: "bottom-0 right-0",
|
||||
background: "linear-gradient(148deg, rgba(0, 0, 0, 0.00) 58.58%, rgba(0, 0, 0, 0.10) 97.86%)",
|
||||
},
|
||||
"bottom-left": {
|
||||
className: "bottom-0 left-0",
|
||||
background: "linear-gradient(214deg, rgba(0, 0, 0, 0.00) 54.54%, rgba(0, 0, 0, 0.10) 95.71%)",
|
||||
},
|
||||
};
|
||||
|
||||
// Deduplicated list of positions
|
||||
const positions = toasts.reduce<NonNullable<ToasterProps["position"]>[]>((acc, t) => {
|
||||
acc.push(t.position || DEFAULT_TOAST_POSITION);
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{Object.entries(styles).map(([position, style]) => (
|
||||
<div
|
||||
key={position}
|
||||
className={cx(
|
||||
"pointer-events-none fixed z-40 hidden h-72.5 w-130 transition duration-500 xs:block",
|
||||
style.className,
|
||||
positions.includes(position as keyof typeof styles) ? "visible opacity-100" : "invisible opacity-0",
|
||||
)}
|
||||
style={{
|
||||
background: style.background,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
className={cx(
|
||||
"pointer-events-none fixed right-0 bottom-0 left-0 z-40 h-67.5 w-full bg-linear-to-t from-black/10 to-transparent transition duration-500 xs:hidden",
|
||||
positions.length > 0 ? "visible opacity-100" : "invisible opacity-0",
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const Toaster = () => (
|
||||
<>
|
||||
<SonnerToaster
|
||||
position={DEFAULT_TOAST_POSITION}
|
||||
style={
|
||||
{
|
||||
"--width": "400px",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
<ToastsOverlay />
|
||||
</>
|
||||
);
|
||||
Reference in New Issue
Block a user