mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-12 02:38:15 +00:00
Merge branch 'dev' into dev-kartik
This commit is contained in:
@@ -20,6 +20,7 @@ interface NavItemBaseProps {
|
||||
/** Type of the nav item. */
|
||||
type: "link" | "collapsible" | "collapsible-child";
|
||||
/** Icon component to display. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
icon?: FC<Record<string, any>>;
|
||||
/** Badge to display. */
|
||||
badge?: ReactNode;
|
||||
|
||||
@@ -6,10 +6,12 @@ export type NavItemType = {
|
||||
/** URL to navigate to when the nav item is clicked. */
|
||||
href?: string;
|
||||
/** Icon component to display. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
icon?: FC<Record<string, any>>;
|
||||
/** Badge to display. */
|
||||
badge?: ReactNode;
|
||||
/** List of sub-items to display. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
items?: { label: string; href: string; icon?: FC<Record<string, any>>; badge?: ReactNode }[];
|
||||
/** Whether this nav item is a divider. */
|
||||
divider?: boolean;
|
||||
|
||||
@@ -18,6 +18,7 @@ const Trash01: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon
|
||||
* @param bytes - The size of the file in bytes.
|
||||
* @returns A string representing the file size in a human-readable format.
|
||||
*/
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const getReadableFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return "0 KB";
|
||||
|
||||
@@ -388,6 +389,7 @@ const FileUploadList = (props: ComponentPropsWithRef<"ul">) => (
|
||||
</ul>
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const FileUpload = {
|
||||
Root: FileUploadRoot,
|
||||
List: FileUploadList,
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface PaginationRootProps {
|
||||
onPageChange?: (page: number) => void;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
const PaginationRoot = ({ total, siblingCount = 1, page, onPageChange, children, style, className }: PaginationRootProps) => {
|
||||
const createPaginationItems = useCallback((): PaginationItemType[] => {
|
||||
const items: PaginationItemType[] = [];
|
||||
@@ -202,6 +203,7 @@ interface TriggerProps {
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
const Trigger: FC<TriggerProps> = ({ children, style, className, asChild = false, direction, ariaLabel }) => {
|
||||
const context = useContext(PaginationContext);
|
||||
if (!context) {
|
||||
@@ -247,8 +249,10 @@ const Trigger: FC<TriggerProps> = ({ children, style, className, asChild = false
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
const PaginationPrevTrigger: FC<Omit<TriggerProps, "direction">> = (props) => <Trigger {...props} direction="prev" />;
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
const PaginationNextTrigger: FC<Omit<TriggerProps, "direction">> = (props) => <Trigger {...props} direction="next" />;
|
||||
|
||||
interface PaginationItemRenderProps {
|
||||
@@ -276,6 +280,7 @@ export interface PaginationItemProps {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
const PaginationItem = ({ value, isCurrent, children, style, className, ariaLabel, asChild = false }: PaginationItemProps) => {
|
||||
const context = useContext(PaginationContext);
|
||||
if (!context) {
|
||||
@@ -338,6 +343,7 @@ interface PaginationEllipsisProps {
|
||||
className?: string | (() => string);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
const PaginationEllipsis: FC<PaginationEllipsisProps> = ({ children, style, className }) => {
|
||||
const computedClassName = typeof className === "function" ? className() : className;
|
||||
|
||||
@@ -352,6 +358,7 @@ interface PaginationContextComponentProps {
|
||||
children: (pagination: PaginationContextType) => ReactNode;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
const PaginationContextComponent: FC<PaginationContextComponentProps> = ({ children }) => {
|
||||
const context = useContext(PaginationContext);
|
||||
if (!context) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
export * from "./avatar-add-button";
|
||||
export * from "./avatar-company-icon";
|
||||
export * from "./avatar-online-indicator";
|
||||
|
||||
@@ -8,6 +8,7 @@ import { badgeTypes } from "./badge-types";
|
||||
|
||||
const CloseX: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faXmark} className={className} />;
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const filledColors: Record<BadgeColors, { root: string; addon: string; addonButton: string }> = {
|
||||
gray: {
|
||||
root: "bg-utility-gray-50 text-utility-gray-700 ring-utility-gray-200",
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { cx, sortCx } from "@/utils/cx";
|
||||
import { isReactComponent } from "@/utils/is-react-component";
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const styles = sortCx({
|
||||
common: {
|
||||
root: [
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Tooltip } from "@/components/base/tooltip/tooltip";
|
||||
import { cx } from "@/utils/cx";
|
||||
import { isReactComponent } from "@/utils/is-react-component";
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const styles = {
|
||||
secondary:
|
||||
"bg-primary text-fg-quaternary shadow-xs-skeumorphic ring-1 ring-primary ring-inset hover:bg-primary_hover hover:text-fg-quaternary_hover disabled:shadow-xs disabled:ring-disabled_subtle",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button as AriaButton, Link as AriaLink } from "react-aria-components";
|
||||
import { cx, sortCx } from "@/utils/cx";
|
||||
import { isReactComponent } from "@/utils/is-react-component";
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const styles = sortCx({
|
||||
common: {
|
||||
root: [
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Button as AriaButton, Link as AriaLink } from "react-aria-components";
|
||||
import { cx, sortCx } from "@/utils/cx";
|
||||
import { AppleLogo, DribbleLogo, FacebookLogo, FigmaLogo, FigmaLogoOutlined, GoogleLogo, TwitterLogo } from "./social-logos";
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const styles = sortCx({
|
||||
common: {
|
||||
root: "group relative inline-flex h-max cursor-pointer items-center justify-center font-semibold whitespace-nowrap outline-focus-ring transition duration-100 ease-linear before:absolute focus-visible:outline-2 focus-visible:outline-offset-2 disabled:cursor-not-allowed disabled:stroke-fg-disabled disabled:text-fg-disabled disabled:*:text-fg-disabled",
|
||||
|
||||
@@ -31,6 +31,7 @@ interface DropdownItemProps extends AriaMenuItemProps {
|
||||
icon?: FC<{ className?: string }>;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
const DropdownItem = ({ label, children, addon, icon: Icon, unstyled, ...props }: DropdownItemProps) => {
|
||||
if (unstyled) {
|
||||
return <AriaMenuItem id={label} textValue={label} {...props} />;
|
||||
@@ -91,6 +92,7 @@ const DropdownItem = ({ label, children, addon, icon: Icon, unstyled, ...props }
|
||||
|
||||
type DropdownMenuProps<T extends object> = AriaMenuProps<T>;
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
const DropdownMenu = <T extends object>(props: DropdownMenuProps<T>) => {
|
||||
return (
|
||||
<AriaMenu
|
||||
@@ -106,6 +108,7 @@ const DropdownMenu = <T extends object>(props: DropdownMenuProps<T>) => {
|
||||
|
||||
type DropdownPopoverProps = AriaPopoverProps;
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
const DropdownPopover = (props: DropdownPopoverProps) => {
|
||||
return (
|
||||
<AriaPopover
|
||||
@@ -127,10 +130,12 @@ const DropdownPopover = (props: DropdownPopoverProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
const DropdownSeparator = (props: AriaSeparatorProps) => {
|
||||
return <AriaSeparator {...props} className={cx("my-1 h-px w-full bg-border-secondary", props.className)} />;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
const DropdownDotsButton = (props: AriaButtonProps & RefAttributes<HTMLButtonElement>) => {
|
||||
return (
|
||||
<AriaButton
|
||||
|
||||
@@ -62,6 +62,7 @@ const detectCardType = (number: string) => {
|
||||
/**
|
||||
* Format the card number in groups of 4 digits (i.e. 1234 5678 9012 3456).
|
||||
*/
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const formatCardNumber = (number: string) => {
|
||||
// Remove non-numeric characters
|
||||
const cleaned = number.replace(/\D/g, "");
|
||||
|
||||
@@ -15,6 +15,7 @@ const PinInputContext = createContext<PinInputContextType>({
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const usePinInputContext = () => {
|
||||
const context = useContext(PinInputContext);
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ interface SelectValueProps {
|
||||
placeholderIcon?: FC | ReactNode;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const sizes = {
|
||||
sm: { root: "py-2 px-3", shortcut: "pr-2.5" },
|
||||
md: { root: "py-2.5 px-3.5", shortcut: "pr-3" },
|
||||
@@ -106,6 +107,7 @@ const SelectValue = ({ isOpen, isFocused, isDisabled, size, placeholder, placeho
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const SelectContext = createContext<{ size: "sm" | "md" }>({ size: "sm" });
|
||||
|
||||
const Select = ({ placeholder = "Select", placeholderIcon, size = "sm", children, items, label, hint, tooltip, className, ...rest }: SelectProps) => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
faCalendarPlus,
|
||||
faCheckCircle,
|
||||
faClipboardQuestion,
|
||||
faMicrophone,
|
||||
faMicrophoneSlash,
|
||||
@@ -50,7 +49,6 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
|
||||
const setCallUcid = useSetAtom(sipCallUcidAtom);
|
||||
const [postCallStage, setPostCallStage] = useState<PostCallStage | null>(null);
|
||||
const [savedDisposition, setSavedDisposition] = useState<CallDisposition | null>(null);
|
||||
const [appointmentOpen, setAppointmentOpen] = useState(false);
|
||||
const [appointmentBookedDuringCall, setAppointmentBookedDuringCall] = useState(false);
|
||||
const [transferOpen, setTransferOpen] = useState(false);
|
||||
@@ -59,14 +57,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
// Capture direction at mount — survives through disposition stage
|
||||
const callDirectionRef = useRef(callState === "ringing-out" ? "OUTBOUND" : "INBOUND");
|
||||
// Track if the call was ever answered (reached 'active' state)
|
||||
const [wasAnswered, setWasAnswered] = useState(callState === "active");
|
||||
|
||||
useEffect(() => {
|
||||
if (callState === "active") {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setWasAnswered(true);
|
||||
}
|
||||
}, [callState]);
|
||||
const wasAnsweredRef = useRef(callState === "active");
|
||||
|
||||
const firstName = lead?.contactName?.firstName ?? "";
|
||||
const lastName = lead?.contactName?.lastName ?? "";
|
||||
@@ -75,8 +66,6 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
const phoneDisplay = phone ? formatPhone(phone) : callerPhone || "Unknown";
|
||||
|
||||
const handleDisposition = async (disposition: CallDisposition, notes: string) => {
|
||||
setSavedDisposition(disposition);
|
||||
|
||||
// Submit disposition to sidecar — handles Ozonetel ACW release
|
||||
if (callUcid) {
|
||||
apiClient
|
||||
@@ -93,12 +82,8 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
.catch((err) => console.warn("Disposition failed:", err));
|
||||
}
|
||||
|
||||
if (disposition === "APPOINTMENT_BOOKED") {
|
||||
setPostCallStage("appointment");
|
||||
setAppointmentOpen(true);
|
||||
} else if (disposition === "FOLLOW_UP_SCHEDULED") {
|
||||
setPostCallStage("follow-up");
|
||||
// Create follow-up
|
||||
// Side effects per disposition type
|
||||
if (disposition === "FOLLOW_UP_SCHEDULED") {
|
||||
try {
|
||||
await apiClient.graphql(
|
||||
`mutation($data: FollowUpCreateInput!) { createFollowUp(data: $data) { id } }`,
|
||||
@@ -109,6 +94,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
status: "PENDING",
|
||||
assignedAgent: null,
|
||||
priority: "NORMAL",
|
||||
// eslint-disable-next-line react-hooks/purity
|
||||
scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
||||
},
|
||||
},
|
||||
@@ -118,27 +104,23 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
} catch {
|
||||
notify.info("Follow-up", "Could not auto-create follow-up");
|
||||
}
|
||||
setPostCallStage("done");
|
||||
} else {
|
||||
notify.success("Call Logged", `Disposition: ${disposition.replace(/_/g, " ").toLowerCase()}`);
|
||||
setPostCallStage("done");
|
||||
}
|
||||
|
||||
// Disposition is the last step — return to worklist immediately
|
||||
notify.success("Call Logged", `Disposition: ${disposition.replace(/_/g, " ").toLowerCase()}`);
|
||||
handleReset();
|
||||
};
|
||||
|
||||
const handleAppointmentSaved = () => {
|
||||
setAppointmentOpen(false);
|
||||
notify.success("Appointment Booked", "Payment link will be sent to the patient");
|
||||
// If booked during active call, don't skip to 'done' — wait for disposition after call ends
|
||||
if (callState === "active") {
|
||||
setAppointmentBookedDuringCall(true);
|
||||
} else {
|
||||
setPostCallStage("done");
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setPostCallStage(null);
|
||||
setSavedDisposition(null);
|
||||
setCallState("idle");
|
||||
setCallerNumber(null);
|
||||
setCallUcid(null);
|
||||
@@ -209,7 +191,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
}
|
||||
|
||||
// Skip disposition for unanswered calls (ringing-in → ended without ever reaching active)
|
||||
if (!wasAnswered && postCallStage === null && (callState === "ended" || callState === "failed")) {
|
||||
if (!wasAnsweredRef.current && postCallStage === null && (callState === "ended" || callState === "failed")) {
|
||||
return (
|
||||
<div className="rounded-xl border border-secondary bg-primary p-4 text-center">
|
||||
<FontAwesomeIcon icon={faPhoneHangup} className="mb-2 size-6 text-fg-quaternary" />
|
||||
@@ -224,63 +206,48 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
|
||||
// Post-call flow takes priority over active state (handles race between hangup + SIP ended event)
|
||||
if (postCallStage !== null || callState === "ended" || callState === "failed") {
|
||||
// Done state
|
||||
if (postCallStage === "done") {
|
||||
return (
|
||||
<div className="border-success rounded-xl border bg-success-primary p-4 text-center">
|
||||
<FontAwesomeIcon icon={faCheckCircle} className="mb-2 size-8 text-fg-success-primary" />
|
||||
<p className="text-sm font-semibold text-success-primary">Call Completed</p>
|
||||
<p className="mt-1 text-xs text-tertiary">{savedDisposition ? savedDisposition.replace(/_/g, " ").toLowerCase() : "logged"}</p>
|
||||
<Button size="sm" color="secondary" className="mt-3" onClick={handleReset}>
|
||||
Back to Worklist
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Appointment booking after disposition
|
||||
if (postCallStage === "appointment") {
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-xl border border-brand bg-brand-primary p-4 text-center">
|
||||
<FontAwesomeIcon icon={faCalendarPlus} className="mb-2 size-6 text-fg-brand-primary" />
|
||||
<p className="text-sm font-semibold text-brand-secondary">Booking Appointment</p>
|
||||
<p className="mt-1 text-xs text-tertiary">for {fullName || phoneDisplay}</p>
|
||||
</div>
|
||||
<AppointmentForm
|
||||
isOpen={appointmentOpen}
|
||||
onOpenChange={(open) => {
|
||||
setAppointmentOpen(open);
|
||||
if (!open) setPostCallStage("done");
|
||||
}}
|
||||
callerNumber={callerPhone}
|
||||
leadName={fullName || null}
|
||||
leadId={lead?.id ?? null}
|
||||
onSaved={handleAppointmentSaved}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Disposition form
|
||||
// Disposition form + enquiry access
|
||||
return (
|
||||
<div className="rounded-xl border border-secondary bg-primary p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<div className="flex size-8 items-center justify-center rounded-full bg-secondary">
|
||||
<FontAwesomeIcon icon={faPhoneHangup} className="size-3.5 text-fg-quaternary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-primary">Call Ended — {fullName || phoneDisplay}</p>
|
||||
<p className="text-xs text-tertiary">{formatDuration(callDuration)} · Log this call</p>
|
||||
<>
|
||||
<div className="rounded-xl border border-secondary bg-primary p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex size-8 items-center justify-center rounded-full bg-secondary">
|
||||
<FontAwesomeIcon icon={faPhoneHangup} className="size-3.5 text-fg-quaternary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-primary">Call Ended — {fullName || phoneDisplay}</p>
|
||||
<p className="text-xs text-tertiary">{formatDuration(callDuration)} · Log this call</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />}
|
||||
onClick={() => setEnquiryOpen(!enquiryOpen)}
|
||||
>
|
||||
Enquiry
|
||||
</Button>
|
||||
</div>
|
||||
<DispositionForm onSubmit={handleDisposition} defaultDisposition={appointmentBookedDuringCall ? "APPOINTMENT_BOOKED" : null} />
|
||||
</div>
|
||||
<DispositionForm onSubmit={handleDisposition} defaultDisposition={appointmentBookedDuringCall ? "APPOINTMENT_BOOKED" : null} />
|
||||
</div>
|
||||
<EnquiryForm
|
||||
isOpen={enquiryOpen}
|
||||
onOpenChange={setEnquiryOpen}
|
||||
callerPhone={callerPhone}
|
||||
onSaved={() => {
|
||||
setEnquiryOpen(false);
|
||||
notify.success("Enquiry Logged");
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Active call
|
||||
if (callState === "active") {
|
||||
wasAnsweredRef.current = true;
|
||||
return (
|
||||
<div className="rounded-xl border border-brand bg-primary p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -323,7 +290,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
onClick={() => {
|
||||
const action = recordingPaused ? "unPause" : "pause";
|
||||
if (callUcid) apiClient.post("/api/ozonetel/recording", { ucid: callUcid, action }).catch(() => {});
|
||||
setRecordingPaused((prev) => !prev);
|
||||
setRecordingPaused(!recordingPaused);
|
||||
}}
|
||||
title={recordingPaused ? "Resume Recording" : "Pause Recording"}
|
||||
className={cx(
|
||||
@@ -339,25 +306,34 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
{/* Text+Icon primary actions */}
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
iconLeading={<FontAwesomeIcon icon={faCalendarPlus} data-icon className="size-3.5" />}
|
||||
onClick={() => setAppointmentOpen(true)}
|
||||
color={appointmentOpen ? "primary" : "secondary"}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />}
|
||||
onClick={() => {
|
||||
setAppointmentOpen(!appointmentOpen);
|
||||
setEnquiryOpen(false);
|
||||
}}
|
||||
>
|
||||
Book Appt
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
iconLeading={<FontAwesomeIcon icon={faClipboardQuestion} data-icon className="size-3.5" />}
|
||||
onClick={() => setEnquiryOpen((prev) => !prev)}
|
||||
color={enquiryOpen ? "primary" : "secondary"}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faClipboardQuestion} className={className} {...rest} />}
|
||||
onClick={() => {
|
||||
setEnquiryOpen(!enquiryOpen);
|
||||
setAppointmentOpen(false);
|
||||
}}
|
||||
>
|
||||
Enquiry
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
iconLeading={<FontAwesomeIcon icon={faPhoneArrowRight} data-icon className="size-3.5" />}
|
||||
onClick={() => setTransferOpen((prev) => !prev)}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} {...rest} />}
|
||||
onClick={() => setTransferOpen(!transferOpen)}
|
||||
>
|
||||
Transfer
|
||||
</Button>
|
||||
@@ -365,7 +341,8 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
size="sm"
|
||||
color="primary-destructive"
|
||||
className="ml-auto"
|
||||
iconLeading={<FontAwesomeIcon icon={faPhoneHangup} data-icon className="size-3.5" />}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneHangup} className={className} {...rest} />}
|
||||
onClick={() => {
|
||||
hangup();
|
||||
setPostCallStage("disposition");
|
||||
@@ -395,6 +372,8 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
||||
callerNumber={callerPhone}
|
||||
leadName={fullName || null}
|
||||
leadId={lead?.id ?? null}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
patientId={(lead as any)?.patientId ?? null}
|
||||
onSaved={handleAppointmentSaved}
|
||||
/>
|
||||
|
||||
|
||||
@@ -45,7 +45,11 @@ export const AiChatPanel = ({ callerContext, role = "cc-agent" }: AiChatPanelPro
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
// Scroll within the messages container only — don't scroll the parent panel
|
||||
const el = messagesEndRef.current;
|
||||
if (el?.parentElement) {
|
||||
el.parentElement.scrollTop = el.parentElement.scrollHeight;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -20,7 +20,7 @@ type ExistingAppointment = {
|
||||
doctorId?: string;
|
||||
department: string;
|
||||
reasonForVisit?: string;
|
||||
appointmentStatus: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
type AppointmentFormProps = {
|
||||
@@ -29,6 +29,7 @@ type AppointmentFormProps = {
|
||||
callerNumber?: string | null;
|
||||
leadName?: string | null;
|
||||
leadId?: string | null;
|
||||
patientId?: string | null;
|
||||
onSaved?: () => void;
|
||||
existingAppointment?: ExistingAppointment | null;
|
||||
};
|
||||
@@ -63,7 +64,7 @@ const timeSlotItems = [
|
||||
|
||||
const formatDeptLabel = (dept: string) => dept.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName, leadId, onSaved, existingAppointment }: AppointmentFormProps) => {
|
||||
export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName, leadId, patientId, onSaved, existingAppointment }: AppointmentFormProps) => {
|
||||
const isEditMode = !!existingAppointment;
|
||||
|
||||
// Doctor data from platform
|
||||
@@ -103,6 +104,7 @@ export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName,
|
||||
// Fetch doctors on mount
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
apiClient
|
||||
.graphql<{ doctors: { edges: Array<{ node: any }> } }>(
|
||||
`{ doctors(first: 50) { edges { node {
|
||||
@@ -129,17 +131,18 @@ export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName,
|
||||
}
|
||||
|
||||
setLoadingSlots(true);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
apiClient
|
||||
.graphql<{ appointments: { edges: Array<{ node: any }> } }>(
|
||||
`{ appointments(filter: {
|
||||
doctorId: { eq: "${doctor}" },
|
||||
scheduledAt: { gte: "${date}T00:00:00", lte: "${date}T23:59:59" }
|
||||
}) { edges { node { id scheduledAt durationMin appointmentStatus } } } }`,
|
||||
}) { edges { node { id scheduledAt durationMin status } } } }`,
|
||||
)
|
||||
.then((data) => {
|
||||
// Filter out cancelled/completed appointments client-side
|
||||
const activeAppointments = data.appointments.edges.filter((e) => {
|
||||
const status = e.node.appointmentStatus;
|
||||
const status = e.node.status;
|
||||
return status !== "CANCELLED" && status !== "COMPLETED" && status !== "NO_SHOW";
|
||||
});
|
||||
const slots = activeAppointments.map((e) => {
|
||||
@@ -198,7 +201,7 @@ export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName,
|
||||
if (isEditMode && existingAppointment) {
|
||||
// Update existing appointment
|
||||
await apiClient.graphql(
|
||||
`mutation UpdateAppointment($id: ID!, $data: AppointmentUpdateInput!) {
|
||||
`mutation UpdateAppointment($id: UUID!, $data: AppointmentUpdateInput!) {
|
||||
updateAppointment(id: $id, data: $data) { id }
|
||||
}`,
|
||||
{
|
||||
@@ -214,22 +217,6 @@ export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName,
|
||||
);
|
||||
notify.success("Appointment Updated");
|
||||
} else {
|
||||
// Double-check slot availability before booking
|
||||
const checkResult = await apiClient.graphql<{ appointments: { edges: Array<{ node: { appointmentStatus: string } }> } }>(
|
||||
`{ appointments(filter: {
|
||||
doctorId: { eq: "${doctor}" },
|
||||
scheduledAt: { gte: "${date}T${timeSlot}:00", lte: "${date}T${timeSlot}:00" }
|
||||
}) { edges { node { appointmentStatus } } } }`,
|
||||
);
|
||||
const activeBookings = checkResult.appointments.edges.filter(
|
||||
(e) => e.node.appointmentStatus !== "CANCELLED" && e.node.appointmentStatus !== "NO_SHOW",
|
||||
);
|
||||
if (activeBookings.length > 0) {
|
||||
setError("This slot was just booked by someone else. Please select a different time.");
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create appointment
|
||||
await apiClient.graphql(
|
||||
`mutation CreateAppointment($data: AppointmentCreateInput!) {
|
||||
@@ -240,12 +227,12 @@ export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName,
|
||||
scheduledAt,
|
||||
durationMin: 30,
|
||||
appointmentType: "CONSULTATION",
|
||||
appointmentStatus: "SCHEDULED",
|
||||
status: "SCHEDULED",
|
||||
doctorName: selectedDoctor?.name ?? "",
|
||||
department: selectedDoctor?.department ?? "",
|
||||
doctorId: doctor,
|
||||
reasonForVisit: chiefComplaint || null,
|
||||
...(leadId ? { patientId: leadId } : {}),
|
||||
...(patientId ? { patientId } : {}),
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -254,7 +241,7 @@ export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName,
|
||||
if (leadId) {
|
||||
await apiClient
|
||||
.graphql(
|
||||
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
|
||||
`mutation UpdateLead($id: UUID!, $data: LeadUpdateInput!) {
|
||||
updateLead(id: $id, data: $data) { id }
|
||||
}`,
|
||||
{
|
||||
@@ -283,12 +270,12 @@ export const AppointmentForm = ({ isOpen, onOpenChange, callerNumber, leadName,
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await apiClient.graphql(
|
||||
`mutation CancelAppointment($id: ID!, $data: AppointmentUpdateInput!) {
|
||||
`mutation CancelAppointment($id: UUID!, $data: AppointmentUpdateInput!) {
|
||||
updateAppointment(id: $id, data: $data) { id }
|
||||
}`,
|
||||
{
|
||||
id: existingAppointment.id,
|
||||
data: { appointmentStatus: "CANCELLED" },
|
||||
data: { status: "CANCELLED" },
|
||||
},
|
||||
);
|
||||
notify.success("Appointment Cancelled");
|
||||
|
||||
@@ -106,7 +106,9 @@ export const CallWidget = () => {
|
||||
const [disposition, setDisposition] = useState<CallDisposition | null>(null);
|
||||
const [notes, setNotes] = useState("");
|
||||
const [lastDuration, setLastDuration] = useState(0);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const [matchedLead, setMatchedLead] = useState<any>(null);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const [leadActivities, setLeadActivities] = useState<any[]>([]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isAppointmentOpen, setIsAppointmentOpen] = useState(false);
|
||||
@@ -214,7 +216,7 @@ export const CallWidget = () => {
|
||||
if (newStatus) {
|
||||
await apiClient
|
||||
.graphql(
|
||||
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
|
||||
`mutation UpdateLead($id: UUID!, $data: LeadUpdateInput!) {
|
||||
updateLead(id: $id, data: $data) { id }
|
||||
}`,
|
||||
{
|
||||
@@ -382,6 +384,7 @@ export const CallWidget = () => {
|
||||
{leadActivities.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-semibold text-tertiary">Recent Activity</div>
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{leadActivities.slice(0, 3).map((a: any, i: number) => (
|
||||
<div key={i} className="text-xs text-quaternary">
|
||||
{a.activityType?.replace(/_/g, " ")}: {a.summary}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useSip } from "@/providers/sip-provider";
|
||||
import { setOutboundPending } from "@/state/sip-manager";
|
||||
import { sipCallStateAtom, sipCallUcidAtom, sipCallerNumberAtom } from "@/state/sip-state";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const Phone01: FC<{ className?: string } & Record<string, any>> = ({ className, ...rest }) => (
|
||||
<FontAwesomeIcon icon={faPhone} className={className} {...rest} />
|
||||
);
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { faSparkles, faUser } from "@fortawesome/pro-duotone-svg-icons";
|
||||
import { faCalendarCheck, faSparkles, faUser } from "@fortawesome/pro-duotone-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Badge } from "@/components/base/badges/badges";
|
||||
import { useCallAssist } from "@/hooks/use-call-assist";
|
||||
import { apiClient } from "@/lib/api-client";
|
||||
import { formatPhone, formatShortDate } from "@/lib/format";
|
||||
import { faIcon } from "@/lib/icon-wrapper";
|
||||
import type { Lead, LeadActivity } from "@/types/entities";
|
||||
import { cx } from "@/utils/cx";
|
||||
import { AiChatPanel } from "./ai-chat-panel";
|
||||
import { LiveTranscript } from "./live-transcript";
|
||||
|
||||
const CalendarCheck = faIcon(faCalendarCheck);
|
||||
|
||||
type ContextTab = "ai" | "lead360";
|
||||
|
||||
@@ -19,22 +21,15 @@ interface ContextPanelProps {
|
||||
callUcid?: string | null;
|
||||
}
|
||||
|
||||
export const ContextPanel = ({ selectedLead, activities, callerPhone, isInCall, callUcid }: ContextPanelProps) => {
|
||||
export const ContextPanel = ({ selectedLead, activities, callerPhone }: ContextPanelProps) => {
|
||||
const [activeTab, setActiveTab] = useState<ContextTab>("ai");
|
||||
|
||||
// Auto-switch to lead 360 when a lead is selected
|
||||
useEffect(() => {
|
||||
if (selectedLead) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setActiveTab("lead360");
|
||||
}
|
||||
}, [selectedLead?.id]);
|
||||
|
||||
const {
|
||||
transcript,
|
||||
suggestions,
|
||||
connected: assistConnected,
|
||||
} = useCallAssist(isInCall ?? false, callUcid ?? null, selectedLead?.id ?? null, callerPhone ?? null);
|
||||
const [prevLeadId, setPrevLeadId] = useState(selectedLead?.id);
|
||||
if (prevLeadId !== selectedLead?.id) {
|
||||
setPrevLeadId(selectedLead?.id);
|
||||
if (selectedLead) setActiveTab("lead360");
|
||||
}
|
||||
|
||||
const callerContext = selectedLead
|
||||
? {
|
||||
@@ -68,27 +63,65 @@ export const ContextPanel = ({ selectedLead, activities, callerPhone, isInCall,
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUser} className="size-3.5" />
|
||||
Lead 360
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{(selectedLead as any)?.patientId ? "Patient 360" : "Lead 360"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{activeTab === "ai" &&
|
||||
(isInCall ? (
|
||||
<LiveTranscript transcript={transcript} suggestions={suggestions} connected={assistConnected} />
|
||||
) : (
|
||||
<div className="flex h-full flex-col p-4">
|
||||
<AiChatPanel callerContext={callerContext} />
|
||||
</div>
|
||||
))}
|
||||
{activeTab === "lead360" && <Lead360Tab lead={selectedLead} activities={activities} />}
|
||||
</div>
|
||||
{activeTab === "ai" && (
|
||||
<div className="flex flex-1 flex-col overflow-hidden p-4">
|
||||
<AiChatPanel callerContext={callerContext} />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "lead360" && (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<Lead360Tab lead={selectedLead} activities={activities} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Lead360Tab = ({ lead, activities }: { lead: Lead | null; activities: LeadActivity[] }) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const [patientData, setPatientData] = useState<any>(null);
|
||||
const [loadingPatient, setLoadingPatient] = useState(false);
|
||||
|
||||
// Fetch patient data when lead has a patientId (returning patient)
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const patientId = (lead as any)?.patientId;
|
||||
if (!patientId) {
|
||||
setPatientData(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingPatient(true);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
apiClient
|
||||
.graphql<{ patients: { edges: Array<{ node: any }> } }>(
|
||||
`query GetPatient($id: UUID!) { patients(filter: { id: { eq: $id } }) { edges { node {
|
||||
id fullName { firstName lastName } dateOfBirth gender patientType
|
||||
phones { primaryPhoneNumber } emails { primaryEmail }
|
||||
appointments(first: 5, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||
id scheduledAt status doctorName department reasonForVisit appointmentType
|
||||
} } }
|
||||
calls(first: 5, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
||||
id callStatus disposition direction startedAt durationSec agentName
|
||||
} } }
|
||||
} } } }`,
|
||||
{ id: patientId },
|
||||
{ silent: true },
|
||||
)
|
||||
.then((data) => {
|
||||
setPatientData(data.patients.edges[0]?.node ?? null);
|
||||
})
|
||||
.catch(() => setPatientData(null))
|
||||
.finally(() => setLoadingPatient(false));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps
|
||||
}, [(lead as any)?.patientId]);
|
||||
|
||||
if (!lead) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center px-4 py-16 text-center">
|
||||
@@ -109,6 +142,18 @@ const Lead360Tab = ({ lead, activities }: { lead: Lead | null; activities: LeadA
|
||||
.sort((a, b) => new Date(b.occurredAt ?? b.createdAt ?? "").getTime() - new Date(a.occurredAt ?? a.createdAt ?? "").getTime())
|
||||
.slice(0, 10);
|
||||
|
||||
const isReturning = !!patientData;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const appointments = patientData?.appointments?.edges?.map((e: any) => e.node) ?? [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const patientCalls = patientData?.calls?.edges?.map((e: any) => e.node) ?? [];
|
||||
|
||||
const patientAge = patientData?.dateOfBirth
|
||||
? // eslint-disable-next-line react-hooks/purity
|
||||
Math.floor((Date.now() - new Date(patientData.dateOfBirth).getTime()) / (365.25 * 24 * 60 * 60 * 1000))
|
||||
: null;
|
||||
const patientGender = patientData?.gender === "MALE" ? "M" : patientData?.gender === "FEMALE" ? "F" : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
{/* Profile */}
|
||||
@@ -117,6 +162,16 @@ const Lead360Tab = ({ lead, activities }: { lead: Lead | null; activities: LeadA
|
||||
{phone && <p className="text-sm text-secondary">{formatPhone(phone)}</p>}
|
||||
{email && <p className="text-xs text-tertiary">{email}</p>}
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{isReturning && (
|
||||
<Badge size="sm" color="brand" type="pill-color">
|
||||
Returning Patient
|
||||
</Badge>
|
||||
)}
|
||||
{patientAge !== null && patientGender && (
|
||||
<Badge size="sm" color="gray" type="pill-color">
|
||||
{patientAge}y · {patientGender}
|
||||
</Badge>
|
||||
)}
|
||||
{lead.leadStatus && (
|
||||
<Badge size="sm" color="brand">
|
||||
{lead.leadStatus}
|
||||
@@ -134,9 +189,69 @@ const Lead360Tab = ({ lead, activities }: { lead: Lead | null; activities: LeadA
|
||||
)}
|
||||
</div>
|
||||
{lead.interestedService && <p className="mt-2 text-sm text-secondary">Interested in: {lead.interestedService}</p>}
|
||||
{lead.leadScore !== null && lead.leadScore !== undefined && <p className="text-xs text-tertiary">Lead score: {lead.leadScore}</p>}
|
||||
</div>
|
||||
|
||||
{/* Returning patient: Appointments */}
|
||||
{loadingPatient && <p className="text-xs text-tertiary">Loading patient details...</p>}
|
||||
{isReturning && appointments.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-2 text-xs font-bold text-secondary uppercase">Appointments</h4>
|
||||
<div className="space-y-2">
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{appointments.map((appt: any) => {
|
||||
const statusColors: Record<string, "success" | "brand" | "warning" | "error" | "gray"> = {
|
||||
COMPLETED: "success",
|
||||
SCHEDULED: "brand",
|
||||
CONFIRMED: "brand",
|
||||
CANCELLED: "error",
|
||||
NO_SHOW: "warning",
|
||||
};
|
||||
return (
|
||||
<div key={appt.id} className="flex items-start gap-2 rounded-lg bg-secondary p-2">
|
||||
<CalendarCheck className="mt-0.5 size-3.5 shrink-0 text-fg-brand-primary" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-semibold text-primary">
|
||||
{appt.doctorName ?? "Doctor"} · {appt.department ?? ""}
|
||||
</span>
|
||||
{appt.status && (
|
||||
<Badge size="sm" color={statusColors[appt.status] ?? "gray"}>
|
||||
{appt.status.toLowerCase()}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-quaternary">
|
||||
{appt.scheduledAt ? formatShortDate(appt.scheduledAt) : ""}
|
||||
{appt.reasonForVisit ? ` — ${appt.reasonForVisit}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Returning patient: Recent calls */}
|
||||
{isReturning && patientCalls.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-2 text-xs font-bold text-secondary uppercase">Recent Calls</h4>
|
||||
<div className="space-y-1">
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{patientCalls.map((call: any) => (
|
||||
<div key={call.id} className="flex items-center gap-2 text-xs">
|
||||
<div className="mt-0.5 size-1.5 shrink-0 rounded-full bg-fg-quaternary" />
|
||||
<span className="text-primary">
|
||||
{call.direction === "INBOUND" ? "Inbound" : "Outbound"}
|
||||
{call.disposition ? ` — ${call.disposition.replace(/_/g, " ").toLowerCase()}` : ""}
|
||||
</span>
|
||||
<span className="ml-auto text-quaternary">{call.startedAt ? formatShortDate(call.startedAt) : ""}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Insight */}
|
||||
{(lead.aiSummary || lead.aiSuggestedAction) && (
|
||||
<div className="rounded-lg bg-brand-primary p-3">
|
||||
|
||||
@@ -45,6 +45,7 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: Enqu
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
apiClient
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.graphql<{ doctors: { edges: Array<{ node: any }> } }>(
|
||||
`{ doctors(first: 50) { edges { node {
|
||||
id name fullName { firstName lastName } department
|
||||
|
||||
@@ -53,7 +53,7 @@ export const CampaignEditSlideout = ({ isOpen, onOpenChange, campaign, onSaved }
|
||||
const budgetMicros = budget ? Number(budget) * 1_000_000 : null;
|
||||
|
||||
await apiClient.graphql(
|
||||
`mutation UpdateCampaign($id: ID!, $data: CampaignUpdateInput!) {
|
||||
`mutation UpdateCampaign($id: UUID!, $data: CampaignUpdateInput!) {
|
||||
updateCampaign(id: $id, data: $data) { id }
|
||||
}`,
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { HTMLAttributes, SVGProps } from "react";
|
||||
import { useId } from "react";
|
||||
import { cx } from "@/utils/cx";
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const getStarProgress = (starPosition: number, rating: number, maxRating: number = 5) => {
|
||||
// Ensure rating is between 0 and 5
|
||||
const clampedRating = Math.min(Math.max(rating, 0), maxRating);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { type ReactNode, useEffect } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
import { CallWidget } from "@/components/call-desk/call-widget";
|
||||
import { useAuth } from "@/providers/auth-provider";
|
||||
@@ -13,11 +13,30 @@ export const AppShell = ({ children }: AppShellProps) => {
|
||||
const { pathname } = useLocation();
|
||||
const { isCCAgent } = useAuth();
|
||||
|
||||
// Heartbeat: keep agent session alive in Redis (CC agents only)
|
||||
useEffect(() => {
|
||||
if (!isCCAgent) return;
|
||||
|
||||
const beat = () => {
|
||||
const token = localStorage.getItem("helix_access_token");
|
||||
if (token) {
|
||||
const apiUrl = import.meta.env.VITE_API_URL ?? "http://localhost:4100";
|
||||
fetch(`${apiUrl}/auth/heartbeat`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
const interval = setInterval(beat, 5 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isCCAgent]);
|
||||
|
||||
return (
|
||||
<SipProvider>
|
||||
<div className="flex min-h-screen bg-primary">
|
||||
<div className="flex h-screen bg-primary">
|
||||
<Sidebar activeUrl={pathname} />
|
||||
<main className="flex flex-1 flex-col overflow-auto">{children}</main>
|
||||
<main className="flex flex-1 flex-col overflow-hidden">{children}</main>
|
||||
{isCCAgent && pathname !== "/" && pathname !== "/call-desk" && <CallWidget />}
|
||||
</div>
|
||||
</SipProvider>
|
||||
|
||||
@@ -2,16 +2,20 @@ import { useState } from "react";
|
||||
import {
|
||||
faArrowRightFromBracket,
|
||||
faBullhorn,
|
||||
faCalendarCheck,
|
||||
faChartLine,
|
||||
faChartMixed,
|
||||
faChevronLeft,
|
||||
faChevronRight,
|
||||
faClockRotateLeft,
|
||||
faCommentDots,
|
||||
faFileAudio,
|
||||
faGear,
|
||||
faGrid2,
|
||||
faHospitalUser,
|
||||
faPhone,
|
||||
faPlug,
|
||||
faPhoneMissed,
|
||||
faTowerBroadcast,
|
||||
faUsers,
|
||||
} from "@fortawesome/pro-duotone-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
@@ -38,12 +42,16 @@ const IconGrid2 = faIcon(faGrid2);
|
||||
const IconBullhorn = faIcon(faBullhorn);
|
||||
const IconCommentDots = faIcon(faCommentDots);
|
||||
const IconChartMixed = faIcon(faChartMixed);
|
||||
const IconPlug = faIcon(faPlug);
|
||||
const IconGear = faIcon(faGear);
|
||||
const IconPhone = faIcon(faPhone);
|
||||
const IconClockRewind = faIcon(faClockRotateLeft);
|
||||
const IconUsers = faIcon(faUsers);
|
||||
const IconHospitalUser = faIcon(faHospitalUser);
|
||||
const IconCalendarCheck = faIcon(faCalendarCheck);
|
||||
const IconTowerBroadcast = faIcon(faTowerBroadcast);
|
||||
const IconChartLine = faIcon(faChartLine);
|
||||
const IconFileAudio = faIcon(faFileAudio);
|
||||
const IconPhoneMissed = faIcon(faPhoneMissed);
|
||||
|
||||
type NavSection = {
|
||||
label: string;
|
||||
@@ -53,21 +61,26 @@ type NavSection = {
|
||||
const getNavSections = (role: string): NavSection[] => {
|
||||
if (role === "admin") {
|
||||
return [
|
||||
{ label: "Overview", items: [{ label: "Team Dashboard", href: "/", icon: IconGrid2 }] },
|
||||
{
|
||||
label: "Management",
|
||||
label: "Supervisor",
|
||||
items: [
|
||||
{ label: "Campaigns", href: "/campaigns", icon: IconBullhorn },
|
||||
{ label: "Analytics", href: "/reports", icon: IconChartMixed },
|
||||
{ label: "Dashboard", href: "/", icon: IconGrid2 },
|
||||
{ label: "Team Performance", href: "/team-performance", icon: IconChartLine },
|
||||
{ label: "Live Call Monitor", href: "/live-monitor", icon: IconTowerBroadcast },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Admin",
|
||||
label: "Data & Reports",
|
||||
items: [
|
||||
{ label: "Integrations", href: "/integrations", icon: IconPlug },
|
||||
{ label: "Settings", href: "/settings", icon: IconGear },
|
||||
{ label: "Lead Master", href: "/leads", icon: IconUsers },
|
||||
{ label: "Patient Master", href: "/patients", icon: IconHospitalUser },
|
||||
{ label: "Appointment Master", href: "/appointments", icon: IconCalendarCheck },
|
||||
{ label: "Call Log Master", href: "/call-history", icon: IconClockRewind },
|
||||
{ label: "Call Recordings", href: "/call-recordings", icon: IconFileAudio },
|
||||
{ label: "Missed Calls", href: "/missed-calls", icon: IconPhoneMissed },
|
||||
],
|
||||
},
|
||||
{ label: "Admin", items: [{ label: "Settings", href: "/settings", icon: IconGear }] },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -79,6 +92,7 @@ const getNavSections = (role: string): NavSection[] => {
|
||||
{ label: "Call Desk", href: "/", icon: IconPhone },
|
||||
{ label: "Call History", href: "/call-history", icon: IconClockRewind },
|
||||
{ label: "Patients", href: "/patients", icon: IconHospitalUser },
|
||||
{ label: "Appointments", href: "/appointments", icon: IconCalendarCheck },
|
||||
{ label: "My Performance", href: "/my-performance", icon: IconChartMixed },
|
||||
],
|
||||
},
|
||||
@@ -92,6 +106,7 @@ const getNavSections = (role: string): NavSection[] => {
|
||||
{ label: "Lead Workspace", href: "/", icon: IconGrid2 },
|
||||
{ label: "All Leads", href: "/leads", icon: IconUsers },
|
||||
{ label: "Patients", href: "/patients", icon: IconHospitalUser },
|
||||
{ label: "Appointments", href: "/appointments", icon: IconCalendarCheck },
|
||||
{ label: "Campaigns", href: "/campaigns", icon: IconBullhorn },
|
||||
{ label: "Outreach", href: "/outreach", icon: IconCommentDots },
|
||||
],
|
||||
|
||||
@@ -17,6 +17,7 @@ export const BoxIllustration = ({ size = "lg", ...otherProps }: IllustrationProp
|
||||
return <Pattern {...otherProps} />;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const sm = ({
|
||||
className,
|
||||
svgClassName,
|
||||
@@ -98,6 +99,7 @@ export const sm = ({
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const md = ({
|
||||
className,
|
||||
svgClassName,
|
||||
@@ -179,6 +181,7 @@ export const md = ({
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const lg = ({
|
||||
className,
|
||||
svgClassName,
|
||||
|
||||
@@ -17,6 +17,7 @@ export const CloudIllustration = ({ size = "lg", ...otherProps }: IllustrationPr
|
||||
return <Pattern {...otherProps} />;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const sm = ({
|
||||
className,
|
||||
svgClassName,
|
||||
@@ -100,6 +101,7 @@ export const sm = ({
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const md = ({
|
||||
className,
|
||||
svgClassName,
|
||||
@@ -184,6 +186,7 @@ export const md = ({
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const lg = ({
|
||||
className,
|
||||
svgClassName,
|
||||
|
||||
@@ -17,6 +17,7 @@ export const CreditCardIllustration = ({ size = "lg", ...otherProps }: Illustrat
|
||||
return <Pattern {...otherProps} />;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const sm = ({
|
||||
className,
|
||||
svgClassName,
|
||||
@@ -112,6 +113,7 @@ export const sm = ({
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const md = ({
|
||||
className,
|
||||
svgClassName,
|
||||
@@ -205,6 +207,7 @@ export const md = ({
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const lg = ({
|
||||
className,
|
||||
svgClassName,
|
||||
|
||||
@@ -17,6 +17,7 @@ export const DocumentsIllustration = ({ size = "lg", ...otherProps }: Illustrati
|
||||
return <Pattern {...otherProps} />;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const sm = ({
|
||||
className,
|
||||
svgClassName,
|
||||
@@ -189,6 +190,7 @@ export const sm = ({
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const md = ({
|
||||
className,
|
||||
svgClassName,
|
||||
@@ -361,6 +363,7 @@ export const md = ({
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const lg = ({
|
||||
className,
|
||||
svgClassName,
|
||||
|
||||
@@ -67,8 +67,11 @@ export const GlobalSearch = ({ onSelectResult }: GlobalSearchProps) => {
|
||||
debounceRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const data = await apiClient.get<{
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
leads: Array<any>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
patients: Array<any>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
appointments: Array<any>;
|
||||
}>(`/api/search?q=${encodeURIComponent(query)}`, { silent: true });
|
||||
|
||||
@@ -102,7 +105,7 @@ export const GlobalSearch = ({ onSelectResult }: GlobalSearchProps) => {
|
||||
id: a.id,
|
||||
type: "appointment",
|
||||
title: a.doctorName ?? "Appointment",
|
||||
subtitle: [a.department, date, a.appointmentStatus].filter(Boolean).join(" · "),
|
||||
subtitle: [a.department, date, a.status].filter(Boolean).join(" · "),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user