Merge branch 'dev' into dev-kartik

This commit is contained in:
Kartik Datrika
2026-03-24 15:41:25 +05:30
55 changed files with 3413 additions and 168 deletions

View File

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

View File

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

View File

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

View File

@@ -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) {

View File

@@ -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";

View File

@@ -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",

View File

@@ -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: [

View File

@@ -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",

View File

@@ -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: [

View File

@@ -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",

View File

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

View File

@@ -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, "");

View File

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

View File

@@ -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) => {

View File

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

View File

@@ -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(() => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 }
}`,
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(" · "),
});
}