diff --git a/package-lock.json b/package-lock.json index a5fe3b1..d472371 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "jotai": "^2.18.1", "jssip": "^3.13.6", "motion": "^12.29.0", + "pptxgenjs": "^4.0.1", "qr-code-styling": "^1.9.2", "react": "^19.2.3", "react-aria": "^3.46.0", @@ -4115,6 +4116,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "http://localhost:4873/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "http://localhost:4873/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4646,6 +4653,12 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/https": { + "version": "1.0.0", + "resolved": "http://localhost:4873/https/-/https-1.0.0.tgz", + "integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==", + "license": "ISC" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "http://localhost:4873/ignore/-/ignore-5.3.2.tgz", @@ -4657,6 +4670,27 @@ "node": ">= 4" } }, + "node_modules/image-size": { + "version": "1.2.1", + "resolved": "http://localhost:4873/image-size/-/image-size-1.2.1.tgz", + "integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==", + "license": "MIT", + "dependencies": { + "queue": "6.0.2" + }, + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=16.x" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "http://localhost:4873/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "http://localhost:4873/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -4668,6 +4702,12 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "http://localhost:4873/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/input-otp": { "version": "1.4.2", "resolved": "http://localhost:4873/input-otp/-/input-otp-1.4.2.tgz", @@ -4715,6 +4755,12 @@ "node": ">=0.10.0" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "http://localhost:4873/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "http://localhost:4873/isexe/-/isexe-2.0.0.tgz", @@ -4823,6 +4869,18 @@ "sdp-transform": "^2.14.1" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "http://localhost:4873/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "http://localhost:4873/keyv/-/keyv-4.5.4.tgz", @@ -4849,6 +4907,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "http://localhost:4873/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.31.1", "resolved": "http://localhost:4873/lightningcss/-/lightningcss-1.31.1.tgz", @@ -5272,6 +5339,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "http://localhost:4873/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "http://localhost:4873/path-exists/-/path-exists-4.0.0.tgz", @@ -5353,6 +5426,33 @@ "node": ">=4" } }, + "node_modules/pptxgenjs": { + "version": "4.0.1", + "resolved": "http://localhost:4873/pptxgenjs/-/pptxgenjs-4.0.1.tgz", + "integrity": "sha512-TeJISr8wouAuXw4C1F/mC33xbZs/FuEG6nH9FG1Zj+nuPcGMP5YRHl6X+j3HSUnS1f3at6k75ZZXPMZlA5Lj9A==", + "license": "MIT", + "dependencies": { + "@types/node": "^22.8.1", + "https": "^1.0.0", + "image-size": "^1.2.1", + "jszip": "^3.10.1" + } + }, + "node_modules/pptxgenjs/node_modules/@types/node": { + "version": "22.19.15", + "resolved": "http://localhost:4873/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/pptxgenjs/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "http://localhost:4873/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "http://localhost:4873/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -5467,6 +5567,12 @@ } } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "http://localhost:4873/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "http://localhost:4873/punycode/-/punycode-2.3.1.tgz", @@ -5496,6 +5602,15 @@ "integrity": "sha512-pItrW0Z9HnDBnFmgiNrY1uxRdri32Uh9EjNYLPVC2zZ3ZRIIEqBoDgm4DkvDwNNDHTK7FNkmr8zAa77BYc9xNw==", "license": "MIT" }, + "node_modules/queue": { + "version": "6.0.2", + "resolved": "http://localhost:4873/queue/-/queue-6.0.2.tgz", + "integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==", + "license": "MIT", + "dependencies": { + "inherits": "~2.0.3" + } + }, "node_modules/react": { "version": "19.2.4", "resolved": "http://localhost:4873/react/-/react-19.2.4.tgz", @@ -5681,6 +5796,21 @@ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "http://localhost:4873/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, "node_modules/rollup": { "version": "4.59.0", "resolved": "http://localhost:4873/rollup/-/rollup-4.59.0.tgz", @@ -5725,6 +5855,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "http://localhost:4873/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "http://localhost:4873/scheduler/-/scheduler-0.27.0.tgz", @@ -5759,6 +5895,12 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "http://localhost:4873/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "http://localhost:4873/shebang-command/-/shebang-command-2.0.0.tgz", @@ -5837,6 +5979,15 @@ "node": ">=0.10.0" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "http://localhost:4873/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/tailwind-merge": { "version": "3.5.0", "resolved": "http://localhost:4873/tailwind-merge/-/tailwind-merge-3.5.0.tgz", diff --git a/package.json b/package.json index d9177c4..f4daec1 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "jotai": "^2.18.1", "jssip": "^3.13.6", "motion": "^12.29.0", + "pptxgenjs": "^4.0.1", "qr-code-styling": "^1.9.2", "react": "^19.2.3", "react-aria": "^3.46.0", diff --git a/src/components/application/app-navigation/base-components/nav-account-card.tsx b/src/components/application/app-navigation/base-components/nav-account-card.tsx index 9f84595..cbfb5a7 100644 --- a/src/components/application/app-navigation/base-components/nav-account-card.tsx +++ b/src/components/application/app-navigation/base-components/nav-account-card.tsx @@ -2,12 +2,11 @@ import type { FC, HTMLAttributes } from "react"; import { useCallback, useEffect, useRef } from "react"; import type { Placement } from "@react-types/overlays"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faUser, faGear, faArrowRightFromBracket, faPhoneVolume, faSort } from "@fortawesome/pro-duotone-svg-icons"; +import { faArrowRightFromBracket, faSort, faUser, faGear } from "@fortawesome/pro-duotone-svg-icons"; const IconUser: FC<{ className?: string }> = ({ className }) => ; const IconSettings: FC<{ className?: string }> = ({ className }) => ; const IconLogout: FC<{ className?: string }> = ({ className }) => ; -const IconForceReady: FC<{ className?: string }> = ({ className }) => ; import { useFocusManager } from "react-aria"; import type { DialogProps as AriaDialogProps } from "react-aria-components"; import { Button as AriaButton, Dialog as AriaDialog, DialogTrigger as AriaDialogTrigger, Popover as AriaPopover } from "react-aria-components"; @@ -32,9 +31,10 @@ type NavAccountType = { export const NavAccountMenu = ({ className, onSignOut, - onForceReady, + onViewProfile, + onAccountSettings, ...dialogProps -}: AriaDialogProps & { className?: string; accounts?: NavAccountType[]; selectedAccountId?: string; onSignOut?: () => void; onForceReady?: () => void }) => { +}: AriaDialogProps & { className?: string; accounts?: NavAccountType[]; selectedAccountId?: string; onSignOut?: () => void; onViewProfile?: () => void; onAccountSettings?: () => void }) => { const focusManager = useFocusManager(); const dialogRef = useRef(null); @@ -75,12 +75,10 @@ export const NavAccountMenu = ({ <>
- - - { close(); onForceReady?.(); }} /> + { close(); onViewProfile?.(); }} /> + { close(); onAccountSettings?.(); }} />
-
{ close(); onSignOut?.(); }} />
@@ -126,13 +124,15 @@ export const NavAccountCard = ({ selectedAccountId, items = [], onSignOut, - onForceReady, + onViewProfile, + onAccountSettings, }: { popoverPlacement?: Placement; selectedAccountId?: string; items?: NavAccountType[]; onSignOut?: () => void; - onForceReady?: () => void; + onViewProfile?: () => void; + onAccountSettings?: () => void; }) => { const triggerRef = useRef(null); const isDesktop = useBreakpoint("lg"); @@ -173,7 +173,7 @@ export const NavAccountCard = ({ ) } > - + diff --git a/src/components/application/pagination/pagination.tsx b/src/components/application/pagination/pagination.tsx index d7dcd84..561bf78 100644 --- a/src/components/application/pagination/pagination.tsx +++ b/src/components/application/pagination/pagination.tsx @@ -1,11 +1,10 @@ import type { FC } from "react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faArrowLeft, faArrowRight } from "@fortawesome/pro-duotone-svg-icons"; +import { Button } from "@/components/base/buttons/button"; const ArrowLeft: FC<{ className?: string }> = ({ className }) => ; const ArrowRight: FC<{ className?: string }> = ({ className }) => ; -import { ButtonGroup, ButtonGroupItem } from "@/components/base/button-group/button-group"; -import { Button } from "@/components/base/buttons/button"; import { useBreakpoint } from "@/hooks/use-breakpoint"; import { cx } from "@/utils/cx"; import type { PaginationRootProps } from "./pagination-base"; @@ -23,7 +22,7 @@ const PaginationItem = ({ value, rounded, isCurrent }: { value: number; rounded? isCurrent={isCurrent} className={({ isSelected }) => cx( - "flex size-10 cursor-pointer items-center justify-center p-3 text-sm font-medium text-quaternary outline-focus-ring transition duration-100 ease-linear hover:bg-primary_hover hover:text-secondary focus-visible:z-10 focus-visible:bg-primary_hover focus-visible:outline-2 focus-visible:outline-offset-2", + "flex size-9 cursor-pointer items-center justify-center p-3 text-sm font-medium text-quaternary outline-focus-ring transition duration-100 ease-linear hover:bg-primary_hover hover:text-secondary focus-visible:z-10 focus-visible:bg-primary_hover focus-visible:outline-2 focus-visible:outline-offset-2", rounded ? "rounded-full" : "rounded-lg", isSelected && "bg-primary_hover text-secondary", ) @@ -34,43 +33,6 @@ const PaginationItem = ({ value, rounded, isCurrent }: { value: number; rounded? ); }; -interface MobilePaginationProps { - /** The current page. */ - page?: number; - /** The total number of pages. */ - total?: number; - /** The class name of the pagination component. */ - className?: string; - /** The function to call when the page changes. */ - onPageChange?: (page: number) => void; -} - -const MobilePagination = ({ page = 1, total = 10, className, onPageChange }: MobilePaginationProps) => { - return ( - - ); -}; - export const PaginationPageDefault = ({ rounded, page = 1, total = 10, className, ...props }: PaginationProps) => { const isDesktop = useBreakpoint("md"); @@ -84,7 +46,7 @@ export const PaginationPageDefault = ({ rounded, page = 1, total = 10, className
@@ -103,7 +65,7 @@ export const PaginationPageDefault = ({ rounded, page = 1, total = 10, className page.type === "page" ? ( ) : ( - + ), @@ -159,7 +121,7 @@ export const PaginationPageMinimalCenter = ({ rounded, page = 1, total = 10, cla page.type === "page" ? ( ) : ( - + ), @@ -210,7 +172,7 @@ export const PaginationCardDefault = ({ rounded, page = 1, total = 10, ...props page.type === "page" ? ( ) : ( - + ), @@ -235,99 +197,3 @@ export const PaginationCardDefault = ({ rounded, page = 1, total = 10, ...props ); }; -interface PaginationCardMinimalProps { - /** The current page. */ - page?: number; - /** The total number of pages. */ - total?: number; - /** The alignment of the pagination. */ - align?: "left" | "center" | "right"; - /** The class name of the pagination component. */ - className?: string; - /** The function to call when the page changes. */ - onPageChange?: (page: number) => void; -} - -export const PaginationCardMinimal = ({ page = 1, total = 10, align = "left", onPageChange, className }: PaginationCardMinimalProps) => { - return ( -
- - - -
- ); -}; - -interface PaginationButtonGroupProps extends Partial> { - /** The alignment of the pagination. */ - align?: "left" | "center" | "right"; -} - -export const PaginationButtonGroup = ({ align = "left", page = 1, total = 10, ...props }: PaginationButtonGroupProps) => { - const isDesktop = useBreakpoint("md"); - - return ( -
- - - {({ pages }) => ( - - - {isDesktop ? "Previous" : undefined} - - - {pages.map((page, index) => - page.type === "page" ? ( - - - {page.value} - - - ) : ( - - - … - - - ), - )} - - - {isDesktop ? "Next" : undefined} - - - )} - - -
- ); -}; diff --git a/src/components/application/table/table.tsx b/src/components/application/table/table.tsx index ed63d01..5177684 100644 --- a/src/components/application/table/table.tsx +++ b/src/components/application/table/table.tsx @@ -55,7 +55,7 @@ const TableContext = createContext<{ size: "sm" | "md" }>({ size: "md" }); const TableCardRoot = ({ children, className, size = "md", ...props }: HTMLAttributes & { size?: "sm" | "md" }) => { return ( -
+
{children}
@@ -81,7 +81,7 @@ const TableCardHeader = ({ title, badge, description, contentTrailing, className return (
{ return ( -
- cx("w-full overflow-x-hidden", typeof className === "function" ? className(state) : className)} {...props} /> +
+ cx("w-full", typeof className === "function" ? className(state) : className)} {...props} />
); @@ -138,7 +138,7 @@ const TableHeader = ({ columns, children, bordered = true, cla {...props} className={(state) => cx( - "relative bg-secondary", + "relative bg-secondary sticky top-0 z-10", size === "sm" ? "h-9" : "h-11", // Row border—using an "after" pseudo-element to avoid the border taking up space. diff --git a/src/components/call-desk/active-call-card.tsx b/src/components/call-desk/active-call-card.tsx index cfb3f42..5d88df2 100644 --- a/src/components/call-desk/active-call-card.tsx +++ b/src/components/call-desk/active-call-card.tsx @@ -11,7 +11,7 @@ import { useSetAtom } from 'jotai'; import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom } from '@/state/sip-state'; import { setOutboundPending } from '@/state/sip-manager'; import { useSip } from '@/providers/sip-provider'; -import { DispositionForm } from './disposition-form'; +import { DispositionModal } from './disposition-modal'; import { AppointmentForm } from './appointment-form'; import { TransferDialog } from './transfer-dialog'; import { EnquiryForm } from './enquiry-form'; @@ -21,8 +21,6 @@ import { cx } from '@/utils/cx'; import { notify } from '@/lib/toast'; import type { Lead, CallDisposition } from '@/types/entities'; -type PostCallStage = 'disposition' | 'appointment' | 'follow-up' | 'done'; - interface ActiveCallCardProps { lead: Lead | null; callerPhone: string; @@ -41,22 +39,28 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete const setCallState = useSetAtom(sipCallStateAtom); const setCallerNumber = useSetAtom(sipCallerNumberAtom); const setCallUcid = useSetAtom(sipCallUcidAtom); - const [postCallStage, setPostCallStage] = useState(null); const [appointmentOpen, setAppointmentOpen] = useState(false); - const [appointmentBookedDuringCall, setAppointmentBookedDuringCall] = useState(false); const [transferOpen, setTransferOpen] = useState(false); const [recordingPaused, setRecordingPaused] = useState(false); const [enquiryOpen, setEnquiryOpen] = useState(false); - // Capture direction at mount — survives through disposition stage + const [dispositionOpen, setDispositionOpen] = useState(false); + const [callerDisconnected, setCallerDisconnected] = useState(false); + const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND'); - // Track if the call was ever answered (reached 'active' state) const wasAnsweredRef = useRef(callState === 'active'); - // Log mount so we can tell which component handled the call useEffect(() => { console.log(`[ACTIVE-CALL-CARD] Mounted: state=${callState} direction=${callDirectionRef.current} ucid=${callUcid ?? 'none'} lead=${lead?.id ?? 'none'} phone=${callerPhone}`); }, []); // eslint-disable-line react-hooks/exhaustive-deps + // Detect caller disconnect: call was active and ended without agent pressing End + useEffect(() => { + if (wasAnsweredRef.current && !dispositionOpen && (callState === 'ended' || callState === 'failed')) { + setCallerDisconnected(true); + setDispositionOpen(true); + } + }, [callState, dispositionOpen]); + const firstName = lead?.contactName?.firstName ?? ''; const lastName = lead?.contactName?.lastName ?? ''; const fullName = `${firstName} ${lastName}`.trim(); @@ -64,8 +68,12 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete const phoneDisplay = phone ? formatPhone(phone) : callerPhone || 'Unknown'; const handleDisposition = async (disposition: CallDisposition, notes: string) => { + // Hangup if still connected + if (callState === 'active' || callState === 'ringing-out' || callState === 'ringing-in') { + hangup(); + } - // Submit disposition to sidecar — handles Ozonetel ACW release + // Submit disposition to sidecar if (callUcid) { const disposePayload = { ucid: callUcid, @@ -85,7 +93,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete console.warn('[DISPOSE] No callUcid — skipping disposition'); } - // Side effects per disposition type + // Side effects if (disposition === 'FOLLOW_UP_SCHEDULED') { try { await apiClient.graphql(`mutation($data: FollowUpCreateInput!) { createFollowUp(data: $data) { id } }`, { @@ -104,7 +112,6 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete } } - // Disposition is the last step — return to worklist immediately notify.success('Call Logged', `Disposition: ${disposition.replace(/_/g, ' ').toLowerCase()}`); handleReset(); }; @@ -112,13 +119,11 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete const handleAppointmentSaved = () => { setAppointmentOpen(false); notify.success('Appointment Booked', 'Payment link will be sent to the patient'); - if (callState === 'active') { - setAppointmentBookedDuringCall(true); - } }; const handleReset = () => { - setPostCallStage(null); + setDispositionOpen(false); + setCallerDisconnected(false); setCallState('idle'); setCallerNumber(null); setCallUcid(null); @@ -126,7 +131,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete onCallComplete?.(); }; - // Outbound ringing — agent initiated the call + // Outbound ringing if (callState === 'ringing-out') { return (
@@ -145,7 +150,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
@@ -177,8 +182,8 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete ); } - // Skip disposition for unanswered calls (ringing-in → ended without ever reaching active) - if (!wasAnsweredRef.current && postCallStage === null && (callState === 'ended' || callState === 'failed')) { + // Unanswered call (ringing → ended without ever reaching active) + if (!wasAnsweredRef.current && (callState === 'ended' || callState === 'failed')) { return (
@@ -191,149 +196,133 @@ 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') { - // Disposition form + enquiry access + // Active call + if (callState === 'active' || dispositionOpen) { + wasAnsweredRef.current = true; return ( <> -
-
-
-
- +
+
+
+
+
-

Call Ended — {fullName || phoneDisplay}

-

{formatDuration(callDuration)} · Log this call

+

{fullName || phoneDisplay}

+ {fullName &&

{phoneDisplay}

}
- + {formatDuration(callDuration)}
- + + {/* Call controls */} +
+ + + + +
+ + + + + + +
+ + {/* Transfer dialog */} + {transferOpen && callUcid && ( + setTransferOpen(false)} + onTransferred={() => { + setTransferOpen(false); + setDispositionOpen(true); + }} + /> + )} + + {/* Appointment form */} + + + {/* Enquiry form */} + { + setEnquiryOpen(false); + notify.success('Enquiry Logged'); + }} + />
- { - setEnquiryOpen(false); - notify.success('Enquiry Logged'); + + {/* Disposition Modal — the ONLY path to end a call */} + { + // Agent wants to continue the call — close modal, call stays active + if (!callerDisconnected) { + setDispositionOpen(false); + } else { + // Caller already disconnected — dismiss goes to worklist + handleReset(); + } }} /> ); } - // Active call - if (callState === 'active') { - wasAnsweredRef.current = true; - return ( -
-
-
-
- -
-
-

{fullName || phoneDisplay}

- {fullName &&

{phoneDisplay}

} -
-
- {formatDuration(callDuration)} -
-
- {/* Icon-only toggles */} - - - - -
- - {/* Text+Icon primary actions */} - - - - -
- - {/* Transfer dialog */} - {transferOpen && callUcid && ( - setTransferOpen(false)} - onTransferred={() => { - setTransferOpen(false); - hangup(); - setPostCallStage('disposition'); - }} - /> - )} - - {/* Appointment form accessible during call */} - - - {/* Enquiry form */} - { - setEnquiryOpen(false); - notify.success('Enquiry Logged'); - }} - /> -
- ); - } - return null; }; diff --git a/src/components/call-desk/agent-status-toggle.tsx b/src/components/call-desk/agent-status-toggle.tsx index ced5b15..a18420a 100644 --- a/src/components/call-desk/agent-status-toggle.tsx +++ b/src/components/call-desk/agent-status-toggle.tsx @@ -74,7 +74,7 @@ export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatu } const current = displayConfig[ozonetelState] ?? displayConfig.offline; - const canToggle = ozonetelState === 'ready' || ozonetelState === 'break' || ozonetelState === 'training'; + const canToggle = ozonetelState === 'ready' || ozonetelState === 'break' || ozonetelState === 'training' || ozonetelState === 'offline'; return (
diff --git a/src/components/call-desk/call-widget.tsx b/src/components/call-desk/call-widget.tsx index 29f7174..51df7b7 100644 --- a/src/components/call-desk/call-widget.tsx +++ b/src/components/call-desk/call-widget.tsx @@ -1,174 +1,41 @@ -import { useState, useEffect, useRef } from 'react'; -import { - faPhone, - faPhoneArrowDown, - faPhoneArrowUp, - faPhoneHangup, - faPhoneXmark, - faMicrophoneSlash, - faMicrophone, - faPause, - faCircleCheck, - faFloppyDisk, - faCalendarPlus, -} from '@fortawesome/pro-duotone-svg-icons'; +import { useEffect } from 'react'; +import { useNavigate, useLocation } from 'react-router'; +import { faPhone, faPhoneArrowDown, faPhoneXmark, faCircleCheck } from '@fortawesome/pro-duotone-svg-icons'; import { faIcon } from '@/lib/icon-wrapper'; const Phone01 = faIcon(faPhone); const PhoneIncoming01 = faIcon(faPhoneArrowDown); -const PhoneOutgoing01 = faIcon(faPhoneArrowUp); -const PhoneHangUp = faIcon(faPhoneHangup); const PhoneX = faIcon(faPhoneXmark); -const MicrophoneOff01 = faIcon(faMicrophoneSlash); -const Microphone01 = faIcon(faMicrophone); -const PauseCircle = faIcon(faPause); const CheckCircle = faIcon(faCircleCheck); -const Save01 = faIcon(faFloppyDisk); -const CalendarPlus02 = faIcon(faCalendarPlus); import { Button } from '@/components/base/buttons/button'; -import { TextArea } from '@/components/base/textarea/textarea'; -import { AppointmentForm } from '@/components/call-desk/appointment-form'; import { useSetAtom } from 'jotai'; import { sipCallStateAtom } from '@/state/sip-state'; import { useSip } from '@/providers/sip-provider'; -import { useAuth } from '@/providers/auth-provider'; import { cx } from '@/utils/cx'; -import type { CallDisposition } from '@/types/entities'; const formatDuration = (seconds: number): string => { - const m = Math.floor(seconds / 60) - .toString() - .padStart(2, '0'); + const m = Math.floor(seconds / 60).toString().padStart(2, '0'); const s = (seconds % 60).toString().padStart(2, '0'); return `${m}:${s}`; }; -const dispositionOptions: Array<{ - value: CallDisposition; - label: string; - activeClass: string; - defaultClass: string; -}> = [ - { - value: 'APPOINTMENT_BOOKED', - label: 'Appt Booked', - activeClass: 'bg-success-solid text-white ring-transparent', - defaultClass: 'bg-success-primary text-success-primary border-success', - }, - { - value: 'FOLLOW_UP_SCHEDULED', - label: 'Follow-up', - activeClass: 'bg-brand-solid text-white ring-transparent', - defaultClass: 'bg-brand-primary text-brand-secondary border-brand', - }, - { - value: 'INFO_PROVIDED', - label: 'Info Given', - activeClass: 'bg-utility-blue-light-600 text-white ring-transparent', - defaultClass: 'bg-utility-blue-light-50 text-utility-blue-light-700 border-utility-blue-light-200', - }, - { - value: 'NO_ANSWER', - label: 'No Answer', - activeClass: 'bg-warning-solid text-white ring-transparent', - defaultClass: 'bg-warning-primary text-warning-primary border-warning', - }, - { - value: 'WRONG_NUMBER', - label: 'Wrong #', - activeClass: 'bg-secondary-solid text-white ring-transparent', - defaultClass: 'bg-secondary text-secondary border-secondary', - }, - { - value: 'CALLBACK_REQUESTED', - label: 'Not Interested', - activeClass: 'bg-error-solid text-white ring-transparent', - defaultClass: 'bg-error-primary text-error-primary border-error', - }, -]; - +// CallWidget is a lightweight floating notification for calls outside the Call Desk. +// It only handles: ringing (answer/decline) + auto-redirect to Call Desk. +// All active call management (mute, hold, end, disposition) happens on the Call Desk via ActiveCallCard. export const CallWidget = () => { - const { - callState, - callerNumber, - isMuted, - isOnHold, - callDuration, - answer, - reject, - hangup, - toggleMute, - toggleHold, - } = useSip(); - const { user } = useAuth(); + const { callState, callerNumber, callDuration, answer, reject } = useSip(); const setCallState = useSetAtom(sipCallStateAtom); + const navigate = useNavigate(); + const { pathname } = useLocation(); - const [disposition, setDisposition] = useState(null); - const [notes, setNotes] = useState(''); - const [lastDuration, setLastDuration] = useState(0); - const [matchedLead, setMatchedLead] = useState(null); - const [leadActivities, setLeadActivities] = useState([]); - const [isSaving, setIsSaving] = useState(false); - const [isAppointmentOpen, setIsAppointmentOpen] = useState(false); - const callStartTimeRef = useRef(null); - - // Capture duration right before call ends + // Auto-navigate to Call Desk when a call becomes active or outbound ringing starts useEffect(() => { - if (callState === 'active' && callDuration > 0) { - setLastDuration(callDuration); + if (pathname === '/call-desk') return; + if (callState === 'active' || callState === 'ringing-out') { + console.log(`[CALL-WIDGET] Redirecting to Call Desk (state=${callState})`); + navigate('/call-desk'); } - }, [callState, callDuration]); - - // Track call start time - useEffect(() => { - if (callState === 'active' && !callStartTimeRef.current) { - callStartTimeRef.current = new Date().toISOString(); - } - if (callState === 'idle') { - callStartTimeRef.current = null; - } - }, [callState]); - - // Look up caller when call becomes active - useEffect(() => { - if (callState === 'ringing-in' && callerNumber && callerNumber !== 'Unknown') { - const lookup = async () => { - try { - const { apiClient } = await import('@/lib/api-client'); - const token = apiClient.getStoredToken(); - if (!token) return; - - const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100'; - const res = await fetch(`${API_URL}/api/call/lookup`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - body: JSON.stringify({ phoneNumber: callerNumber }), - }); - const data = await res.json(); - if (data.matched && data.lead) { - setMatchedLead(data.lead); - setLeadActivities(data.activities ?? []); - } - } catch (err) { - console.warn('Lead lookup failed:', err); - } - }; - lookup(); - } - }, [callState, callerNumber]); - - // Reset state when returning to idle - useEffect(() => { - if (callState === 'idle') { - setDisposition(null); - setNotes(''); - setMatchedLead(null); - setLeadActivities([]); - } - }, [callState]); + }, [callState, pathname, navigate]); // Auto-dismiss ended/failed state after 3 seconds useEffect(() => { @@ -181,127 +48,35 @@ export const CallWidget = () => { } }, [callState, setCallState]); - const handleSaveAndClose = async () => { - if (!disposition) return; - console.log(`[CALL-WIDGET] Save & Close: disposition=${disposition} lead=${matchedLead?.id ?? 'none'}`); - setIsSaving(true); - - try { - const { apiClient } = await import('@/lib/api-client'); - - // 1. Create Call record on platform - await apiClient.graphql( - `mutation CreateCall($data: CallCreateInput!) { - createCall(data: $data) { id } - }`, - { - data: { - callDirection: 'INBOUND', - callStatus: 'COMPLETED', - agentName: user.name, - startedAt: callStartTimeRef.current, - endedAt: new Date().toISOString(), - durationSeconds: callDuration, - disposition, - callNotes: notes || null, - leadId: matchedLead?.id ?? null, - }, - }, - ).catch(err => console.warn('Failed to create call record:', err)); - - // 2. Update lead status if matched - if (matchedLead?.id) { - const statusMap: Partial> = { - APPOINTMENT_BOOKED: 'APPOINTMENT_SET', - FOLLOW_UP_SCHEDULED: 'CONTACTED', - INFO_PROVIDED: 'CONTACTED', - NO_ANSWER: 'CONTACTED', - WRONG_NUMBER: 'LOST', - CALLBACK_REQUESTED: 'CONTACTED', - NOT_INTERESTED: 'LOST', - }; - const newStatus = statusMap[disposition]; - if (newStatus) { - await apiClient.graphql( - `mutation UpdateLead($id: UUID!, $data: LeadUpdateInput!) { - updateLead(id: $id, data: $data) { id } - }`, - { - id: matchedLead.id, - data: { - leadStatus: newStatus, - lastContactedAt: new Date().toISOString(), - }, - }, - ).catch(err => console.warn('Failed to update lead:', err)); - } - - // 3. Create lead activity - await apiClient.graphql( - `mutation CreateLeadActivity($data: LeadActivityCreateInput!) { - createLeadActivity(data: $data) { id } - }`, - { - data: { - activityType: 'CALL_RECEIVED', - summary: `Inbound call — ${disposition.replace(/_/g, ' ')}`, - occurredAt: new Date().toISOString(), - performedBy: user.name, - channel: 'PHONE', - durationSeconds: callDuration, - leadId: matchedLead.id, - }, - }, - ).catch(err => console.warn('Failed to create activity:', err)); - } - } catch (err) { - console.error('Save failed:', err); - } - - setIsSaving(false); - hangup(); - setDisposition(null); - setNotes(''); - }; - - // Log state changes for observability + // Log state changes useEffect(() => { if (callState !== 'idle') { console.log(`[CALL-WIDGET] State: ${callState} | caller=${callerNumber ?? 'none'}`); } }, [callState, callerNumber]); - // Idle: nothing to show — call desk has its own status toggle - if (callState === 'idle') { - return null; - } + if (callState === 'idle') return null; - // Ringing inbound + // Ringing inbound — answer redirects to Call Desk if (callState === 'ringing-in') { return ( -
+
-
- - Incoming Call - + Incoming Call {callerNumber ?? 'Unknown'}
-
- -
- ); - } - - // Active call (full widget) - if (callState === 'active') { - return ( -
- {/* Header */} -
-
- - Active Call -
- - {formatDuration(callDuration)} - -
- - {/* Caller info */} -
- - {matchedLead?.contactName - ? `${matchedLead.contactName.firstName ?? ''} ${matchedLead.contactName.lastName ?? ''}`.trim() - : callerNumber ?? 'Unknown'} - - {matchedLead && ( - {callerNumber} - )} -
- - {/* AI Summary */} - {matchedLead?.aiSummary && ( -
-
AI Insight
-

{matchedLead.aiSummary}

- {matchedLead.aiSuggestedAction && ( - - {matchedLead.aiSuggestedAction} - - )} -
- )} - - {/* Recent activity */} - {leadActivities.length > 0 && ( -
-
Recent Activity
- {leadActivities.slice(0, 3).map((a: any, i: number) => ( -
- {a.activityType?.replace(/_/g, ' ')}: {a.summary} -
- ))} -
- )} - - {/* Call controls */} -
- - - -
- - {/* Book Appointment */} - - - { - setIsAppointmentOpen(false); - setDisposition('APPOINTMENT_BOOKED'); - }} - /> - - {/* Divider */} -
- - {/* Disposition */} -
- Disposition -
- {dispositionOptions.map((opt) => { - const isSelected = disposition === opt.value; - return ( - - ); - })} -
- -