mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
feat: disposition modal, persistent top bar, pagination, QA fixes
- DispositionModal: single modal for all call endings. Dismissable (agent can resume call). Agent clicks End → modal → select reason → hangup + dispose. Caller disconnects → same modal. - One call screen: CallWidget stripped to ringing notification + auto-redirect to Call Desk. - Persistent top bar in AppShell: agent status toggle + network indicator on all pages. - Network indicator always visible (Connected/Unstable/No connection). - Pagination: Untitled UI PaginationCardDefault on Call History + Appointments (20/page). - Pinned table headers/footers: sticky column headers, scrollable body, pinned pagination. Applied to Call Desk worklist, Call History, Appointments, Call Recordings, Missed Calls. - "Patient" → "Caller" column label in Call History. - Offline → Ready toggle enabled. - Profile status dot reflects Ozonetel state. - NavAccountCard: popover placement top, View Profile + Account Settings restored. - WIP pages for /profile and /account-settings. - Enquiry form PHONE_INQUIRY → PHONE enum fix. - Force Ready / View Profile / Account Settings removed then restored properly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
151
package-lock.json
generated
151
package-lock.json
generated
@@ -25,6 +25,7 @@
|
|||||||
"jotai": "^2.18.1",
|
"jotai": "^2.18.1",
|
||||||
"jssip": "^3.13.6",
|
"jssip": "^3.13.6",
|
||||||
"motion": "^12.29.0",
|
"motion": "^12.29.0",
|
||||||
|
"pptxgenjs": "^4.0.1",
|
||||||
"qr-code-styling": "^1.9.2",
|
"qr-code-styling": "^1.9.2",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-aria": "^3.46.0",
|
"react-aria": "^3.46.0",
|
||||||
@@ -4115,6 +4116,12 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "http://localhost:4873/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"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==",
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "http://localhost:4873/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "http://localhost:4873/ignore/-/ignore-5.3.2.tgz",
|
||||||
@@ -4657,6 +4670,27 @@
|
|||||||
"node": ">= 4"
|
"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": {
|
"node_modules/imurmurhash": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "http://localhost:4873/imurmurhash/-/imurmurhash-0.1.4.tgz",
|
"resolved": "http://localhost:4873/imurmurhash/-/imurmurhash-0.1.4.tgz",
|
||||||
@@ -4668,6 +4702,12 @@
|
|||||||
"node": ">=0.8.19"
|
"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": {
|
"node_modules/input-otp": {
|
||||||
"version": "1.4.2",
|
"version": "1.4.2",
|
||||||
"resolved": "http://localhost:4873/input-otp/-/input-otp-1.4.2.tgz",
|
"resolved": "http://localhost:4873/input-otp/-/input-otp-1.4.2.tgz",
|
||||||
@@ -4715,6 +4755,12 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/isexe": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "http://localhost:4873/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "http://localhost:4873/isexe/-/isexe-2.0.0.tgz",
|
||||||
@@ -4823,6 +4869,18 @@
|
|||||||
"sdp-transform": "^2.14.1"
|
"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": {
|
"node_modules/keyv": {
|
||||||
"version": "4.5.4",
|
"version": "4.5.4",
|
||||||
"resolved": "http://localhost:4873/keyv/-/keyv-4.5.4.tgz",
|
"resolved": "http://localhost:4873/keyv/-/keyv-4.5.4.tgz",
|
||||||
@@ -4849,6 +4907,15 @@
|
|||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.31.1",
|
"version": "1.31.1",
|
||||||
"resolved": "http://localhost:4873/lightningcss/-/lightningcss-1.31.1.tgz",
|
"resolved": "http://localhost:4873/lightningcss/-/lightningcss-1.31.1.tgz",
|
||||||
@@ -5272,6 +5339,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/path-exists": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "http://localhost:4873/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "http://localhost:4873/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
@@ -5353,6 +5426,33 @@
|
|||||||
"node": ">=4"
|
"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": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "http://localhost:4873/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"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": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "http://localhost:4873/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "http://localhost:4873/punycode/-/punycode-2.3.1.tgz",
|
||||||
@@ -5496,6 +5602,15 @@
|
|||||||
"integrity": "sha512-pItrW0Z9HnDBnFmgiNrY1uxRdri32Uh9EjNYLPVC2zZ3ZRIIEqBoDgm4DkvDwNNDHTK7FNkmr8zAa77BYc9xNw==",
|
"integrity": "sha512-pItrW0Z9HnDBnFmgiNrY1uxRdri32Uh9EjNYLPVC2zZ3ZRIIEqBoDgm4DkvDwNNDHTK7FNkmr8zAa77BYc9xNw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/react": {
|
||||||
"version": "19.2.4",
|
"version": "19.2.4",
|
||||||
"resolved": "http://localhost:4873/react/-/react-19.2.4.tgz",
|
"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"
|
"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": {
|
"node_modules/rollup": {
|
||||||
"version": "4.59.0",
|
"version": "4.59.0",
|
||||||
"resolved": "http://localhost:4873/rollup/-/rollup-4.59.0.tgz",
|
"resolved": "http://localhost:4873/rollup/-/rollup-4.59.0.tgz",
|
||||||
@@ -5725,6 +5855,12 @@
|
|||||||
"fsevents": "~2.3.2"
|
"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": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.27.0",
|
"version": "0.27.0",
|
||||||
"resolved": "http://localhost:4873/scheduler/-/scheduler-0.27.0.tgz",
|
"resolved": "http://localhost:4873/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
@@ -5759,6 +5895,12 @@
|
|||||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "http://localhost:4873/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "http://localhost:4873/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
@@ -5837,6 +5979,15 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/tailwind-merge": {
|
||||||
"version": "3.5.0",
|
"version": "3.5.0",
|
||||||
"resolved": "http://localhost:4873/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
|
"resolved": "http://localhost:4873/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"jotai": "^2.18.1",
|
"jotai": "^2.18.1",
|
||||||
"jssip": "^3.13.6",
|
"jssip": "^3.13.6",
|
||||||
"motion": "^12.29.0",
|
"motion": "^12.29.0",
|
||||||
|
"pptxgenjs": "^4.0.1",
|
||||||
"qr-code-styling": "^1.9.2",
|
"qr-code-styling": "^1.9.2",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-aria": "^3.46.0",
|
"react-aria": "^3.46.0",
|
||||||
|
|||||||
@@ -2,12 +2,11 @@ import type { FC, HTMLAttributes } from "react";
|
|||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import type { Placement } from "@react-types/overlays";
|
import type { Placement } from "@react-types/overlays";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
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 }) => <FontAwesomeIcon icon={faUser} className={className} />;
|
const IconUser: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faUser} className={className} />;
|
||||||
const IconSettings: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faGear} className={className} />;
|
const IconSettings: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faGear} className={className} />;
|
||||||
const IconLogout: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRightFromBracket} className={className} />;
|
const IconLogout: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRightFromBracket} className={className} />;
|
||||||
const IconForceReady: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faPhoneVolume} className={className} />;
|
|
||||||
import { useFocusManager } from "react-aria";
|
import { useFocusManager } from "react-aria";
|
||||||
import type { DialogProps as AriaDialogProps } from "react-aria-components";
|
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";
|
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 = ({
|
export const NavAccountMenu = ({
|
||||||
className,
|
className,
|
||||||
onSignOut,
|
onSignOut,
|
||||||
onForceReady,
|
onViewProfile,
|
||||||
|
onAccountSettings,
|
||||||
...dialogProps
|
...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 focusManager = useFocusManager();
|
||||||
const dialogRef = useRef<HTMLDivElement>(null);
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -75,12 +75,10 @@ export const NavAccountMenu = ({
|
|||||||
<>
|
<>
|
||||||
<div className="rounded-xl bg-primary ring-1 ring-secondary">
|
<div className="rounded-xl bg-primary ring-1 ring-secondary">
|
||||||
<div className="flex flex-col gap-0.5 py-1.5">
|
<div className="flex flex-col gap-0.5 py-1.5">
|
||||||
<NavAccountCardMenuItem label="View profile" icon={IconUser} shortcut="⌘K->P" />
|
<NavAccountCardMenuItem label="View profile" icon={IconUser} onClick={() => { close(); onViewProfile?.(); }} />
|
||||||
<NavAccountCardMenuItem label="Account settings" icon={IconSettings} shortcut="⌘S" />
|
<NavAccountCardMenuItem label="Account settings" icon={IconSettings} onClick={() => { close(); onAccountSettings?.(); }} />
|
||||||
<NavAccountCardMenuItem label="Force Ready" icon={IconForceReady} onClick={() => { close(); onForceReady?.(); }} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-1 pb-1.5">
|
<div className="pt-1 pb-1.5">
|
||||||
<NavAccountCardMenuItem label="Sign out" icon={IconLogout} shortcut="⌥⇧Q" onClick={() => { close(); onSignOut?.(); }} />
|
<NavAccountCardMenuItem label="Sign out" icon={IconLogout} shortcut="⌥⇧Q" onClick={() => { close(); onSignOut?.(); }} />
|
||||||
</div>
|
</div>
|
||||||
@@ -126,13 +124,15 @@ export const NavAccountCard = ({
|
|||||||
selectedAccountId,
|
selectedAccountId,
|
||||||
items = [],
|
items = [],
|
||||||
onSignOut,
|
onSignOut,
|
||||||
onForceReady,
|
onViewProfile,
|
||||||
|
onAccountSettings,
|
||||||
}: {
|
}: {
|
||||||
popoverPlacement?: Placement;
|
popoverPlacement?: Placement;
|
||||||
selectedAccountId?: string;
|
selectedAccountId?: string;
|
||||||
items?: NavAccountType[];
|
items?: NavAccountType[];
|
||||||
onSignOut?: () => void;
|
onSignOut?: () => void;
|
||||||
onForceReady?: () => void;
|
onViewProfile?: () => void;
|
||||||
|
onAccountSettings?: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const triggerRef = useRef<HTMLDivElement>(null);
|
const triggerRef = useRef<HTMLDivElement>(null);
|
||||||
const isDesktop = useBreakpoint("lg");
|
const isDesktop = useBreakpoint("lg");
|
||||||
@@ -173,7 +173,7 @@ export const NavAccountCard = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<NavAccountMenu selectedAccountId={selectedAccountId} accounts={items} onSignOut={onSignOut} onForceReady={onForceReady} />
|
<NavAccountMenu selectedAccountId={selectedAccountId} accounts={items} onSignOut={onSignOut} onViewProfile={onViewProfile} onAccountSettings={onAccountSettings} />
|
||||||
</AriaPopover>
|
</AriaPopover>
|
||||||
</AriaDialogTrigger>
|
</AriaDialogTrigger>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faArrowLeft, faArrowRight } from "@fortawesome/pro-duotone-svg-icons";
|
import { faArrowLeft, faArrowRight } from "@fortawesome/pro-duotone-svg-icons";
|
||||||
|
import { Button } from "@/components/base/buttons/button";
|
||||||
|
|
||||||
const ArrowLeft: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowLeft} className={className} />;
|
const ArrowLeft: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowLeft} className={className} />;
|
||||||
const ArrowRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRight} className={className} />;
|
const ArrowRight: FC<{ className?: string }> = ({ className }) => <FontAwesomeIcon icon={faArrowRight} className={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 { useBreakpoint } from "@/hooks/use-breakpoint";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
import type { PaginationRootProps } from "./pagination-base";
|
import type { PaginationRootProps } from "./pagination-base";
|
||||||
@@ -23,7 +22,7 @@ const PaginationItem = ({ value, rounded, isCurrent }: { value: number; rounded?
|
|||||||
isCurrent={isCurrent}
|
isCurrent={isCurrent}
|
||||||
className={({ isSelected }) =>
|
className={({ isSelected }) =>
|
||||||
cx(
|
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",
|
rounded ? "rounded-full" : "rounded-lg",
|
||||||
isSelected && "bg-primary_hover text-secondary",
|
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 (
|
|
||||||
<nav aria-label="Pagination" className={cx("flex items-center justify-between md:hidden", className)}>
|
|
||||||
<Button
|
|
||||||
aria-label="Go to previous page"
|
|
||||||
iconLeading={ArrowLeft}
|
|
||||||
color="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onPageChange?.(Math.max(0, page - 1))}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<span className="text-sm text-fg-secondary">
|
|
||||||
Page <span className="font-medium">{page}</span> of <span className="font-medium">{total}</span>
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
aria-label="Go to next page"
|
|
||||||
iconLeading={ArrowRight}
|
|
||||||
color="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onPageChange?.(Math.min(total, page + 1))}
|
|
||||||
/>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PaginationPageDefault = ({ rounded, page = 1, total = 10, className, ...props }: PaginationProps) => {
|
export const PaginationPageDefault = ({ rounded, page = 1, total = 10, className, ...props }: PaginationProps) => {
|
||||||
const isDesktop = useBreakpoint("md");
|
const isDesktop = useBreakpoint("md");
|
||||||
|
|
||||||
@@ -84,7 +46,7 @@ export const PaginationPageDefault = ({ rounded, page = 1, total = 10, className
|
|||||||
<div className="hidden flex-1 justify-start md:flex">
|
<div className="hidden flex-1 justify-start md:flex">
|
||||||
<Pagination.PrevTrigger asChild>
|
<Pagination.PrevTrigger asChild>
|
||||||
<Button iconLeading={ArrowLeft} color="link-gray" size="sm">
|
<Button iconLeading={ArrowLeft} color="link-gray" size="sm">
|
||||||
{isDesktop ? "Previous" : undefined}{" "}
|
{isDesktop ? "Previous" : undefined}
|
||||||
</Button>
|
</Button>
|
||||||
</Pagination.PrevTrigger>
|
</Pagination.PrevTrigger>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,7 +65,7 @@ export const PaginationPageDefault = ({ rounded, page = 1, total = 10, className
|
|||||||
page.type === "page" ? (
|
page.type === "page" ? (
|
||||||
<PaginationItem key={index} rounded={rounded} {...page} />
|
<PaginationItem key={index} rounded={rounded} {...page} />
|
||||||
) : (
|
) : (
|
||||||
<Pagination.Ellipsis key={index} className="flex size-10 shrink-0 items-center justify-center text-tertiary">
|
<Pagination.Ellipsis key={index} className="flex size-9 shrink-0 items-center justify-center text-tertiary">
|
||||||
…
|
…
|
||||||
</Pagination.Ellipsis>
|
</Pagination.Ellipsis>
|
||||||
),
|
),
|
||||||
@@ -159,7 +121,7 @@ export const PaginationPageMinimalCenter = ({ rounded, page = 1, total = 10, cla
|
|||||||
page.type === "page" ? (
|
page.type === "page" ? (
|
||||||
<PaginationItem key={index} rounded={rounded} {...page} />
|
<PaginationItem key={index} rounded={rounded} {...page} />
|
||||||
) : (
|
) : (
|
||||||
<Pagination.Ellipsis key={index} className="flex size-10 shrink-0 items-center justify-center text-tertiary">
|
<Pagination.Ellipsis key={index} className="flex size-9 shrink-0 items-center justify-center text-tertiary">
|
||||||
…
|
…
|
||||||
</Pagination.Ellipsis>
|
</Pagination.Ellipsis>
|
||||||
),
|
),
|
||||||
@@ -210,7 +172,7 @@ export const PaginationCardDefault = ({ rounded, page = 1, total = 10, ...props
|
|||||||
page.type === "page" ? (
|
page.type === "page" ? (
|
||||||
<PaginationItem key={index} rounded={rounded} {...page} />
|
<PaginationItem key={index} rounded={rounded} {...page} />
|
||||||
) : (
|
) : (
|
||||||
<Pagination.Ellipsis key={index} className="flex size-10 shrink-0 items-center justify-center text-tertiary">
|
<Pagination.Ellipsis key={index} className="flex size-9 shrink-0 items-center justify-center text-tertiary">
|
||||||
…
|
…
|
||||||
</Pagination.Ellipsis>
|
</Pagination.Ellipsis>
|
||||||
),
|
),
|
||||||
@@ -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 (
|
|
||||||
<div className={cx("border-t border-secondary px-4 py-3 md:px-6 md:pt-3 md:pb-4", className)}>
|
|
||||||
<MobilePagination page={page} total={total} onPageChange={onPageChange} />
|
|
||||||
|
|
||||||
<nav aria-label="Pagination" className={cx("hidden items-center gap-3 md:flex", align === "center" && "justify-between")}>
|
|
||||||
<div className={cx(align === "center" && "flex flex-1 justify-start")}>
|
|
||||||
<Button isDisabled={page === 1} color="secondary" size="sm" onClick={() => onPageChange?.(Math.max(0, page - 1))}>
|
|
||||||
Previous
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span
|
|
||||||
className={cx(
|
|
||||||
"text-sm font-medium text-fg-secondary",
|
|
||||||
align === "right" && "order-first mr-auto",
|
|
||||||
align === "left" && "order-last ml-auto",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Page {page} of {total}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div className={cx(align === "center" && "flex flex-1 justify-end")}>
|
|
||||||
<Button isDisabled={page === total} color="secondary" size="sm" onClick={() => onPageChange?.(Math.min(total, page + 1))}>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface PaginationButtonGroupProps extends Partial<Omit<PaginationRootProps, "children">> {
|
|
||||||
/** 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 (
|
|
||||||
<div
|
|
||||||
className={cx(
|
|
||||||
"flex border-t border-secondary px-4 py-3 md:px-6 md:pt-3 md:pb-4",
|
|
||||||
align === "left" && "justify-start",
|
|
||||||
align === "center" && "justify-center",
|
|
||||||
align === "right" && "justify-end",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Pagination.Root {...props} page={page} total={total}>
|
|
||||||
<Pagination.Context>
|
|
||||||
{({ pages }) => (
|
|
||||||
<ButtonGroup size="md">
|
|
||||||
<Pagination.PrevTrigger asChild>
|
|
||||||
<ButtonGroupItem iconLeading={ArrowLeft}>{isDesktop ? "Previous" : undefined}</ButtonGroupItem>
|
|
||||||
</Pagination.PrevTrigger>
|
|
||||||
|
|
||||||
{pages.map((page, index) =>
|
|
||||||
page.type === "page" ? (
|
|
||||||
<Pagination.Item key={index} {...page} asChild>
|
|
||||||
<ButtonGroupItem isSelected={page.isCurrent} className="size-10 items-center justify-center">
|
|
||||||
{page.value}
|
|
||||||
</ButtonGroupItem>
|
|
||||||
</Pagination.Item>
|
|
||||||
) : (
|
|
||||||
<Pagination.Ellipsis key={index}>
|
|
||||||
<ButtonGroupItem className="pointer-events-none size-10 items-center justify-center rounded-none!">
|
|
||||||
…
|
|
||||||
</ButtonGroupItem>
|
|
||||||
</Pagination.Ellipsis>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Pagination.NextTrigger asChild>
|
|
||||||
<ButtonGroupItem iconTrailing={ArrowRight}>{isDesktop ? "Next" : undefined}</ButtonGroupItem>
|
|
||||||
</Pagination.NextTrigger>
|
|
||||||
</ButtonGroup>
|
|
||||||
)}
|
|
||||||
</Pagination.Context>
|
|
||||||
</Pagination.Root>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ const TableContext = createContext<{ size: "sm" | "md" }>({ size: "md" });
|
|||||||
const TableCardRoot = ({ children, className, size = "md", ...props }: HTMLAttributes<HTMLDivElement> & { size?: "sm" | "md" }) => {
|
const TableCardRoot = ({ children, className, size = "md", ...props }: HTMLAttributes<HTMLDivElement> & { size?: "sm" | "md" }) => {
|
||||||
return (
|
return (
|
||||||
<TableContext.Provider value={{ size }}>
|
<TableContext.Provider value={{ size }}>
|
||||||
<div {...props} className={cx("overflow-hidden rounded-xl bg-primary shadow-xs ring-1 ring-secondary", className)}>
|
<div {...props} className={cx("flex flex-col overflow-hidden rounded-xl bg-primary shadow-xs ring-1 ring-secondary", className)}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</TableContext.Provider>
|
</TableContext.Provider>
|
||||||
@@ -81,7 +81,7 @@ const TableCardHeader = ({ title, badge, description, contentTrailing, className
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className={cx(
|
||||||
"relative flex flex-col items-start gap-4 border-b border-secondary bg-primary px-4 md:flex-row",
|
"relative shrink-0 flex flex-col items-start gap-4 border-b border-secondary bg-primary px-4 md:flex-row",
|
||||||
size === "sm" ? "py-4 md:px-5" : "py-5 md:px-6",
|
size === "sm" ? "py-4 md:px-5" : "py-5 md:px-6",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@@ -115,8 +115,8 @@ const TableRoot = ({ className, size = "md", ...props }: TableRootProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TableContext.Provider value={{ size: context?.size ?? size }}>
|
<TableContext.Provider value={{ size: context?.size ?? size }}>
|
||||||
<div className="overflow-x-auto">
|
<div className="flex-1 overflow-auto min-h-0">
|
||||||
<AriaTable className={(state) => cx("w-full overflow-x-hidden", typeof className === "function" ? className(state) : className)} {...props} />
|
<AriaTable className={(state) => cx("w-full", typeof className === "function" ? className(state) : className)} {...props} />
|
||||||
</div>
|
</div>
|
||||||
</TableContext.Provider>
|
</TableContext.Provider>
|
||||||
);
|
);
|
||||||
@@ -138,7 +138,7 @@ const TableHeader = <T extends object>({ columns, children, bordered = true, cla
|
|||||||
{...props}
|
{...props}
|
||||||
className={(state) =>
|
className={(state) =>
|
||||||
cx(
|
cx(
|
||||||
"relative bg-secondary",
|
"relative bg-secondary sticky top-0 z-10",
|
||||||
size === "sm" ? "h-9" : "h-11",
|
size === "sm" ? "h-9" : "h-11",
|
||||||
|
|
||||||
// Row border—using an "after" pseudo-element to avoid the border taking up space.
|
// Row border—using an "after" pseudo-element to avoid the border taking up space.
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { useSetAtom } from 'jotai';
|
|||||||
import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom } from '@/state/sip-state';
|
import { sipCallStateAtom, sipCallerNumberAtom, sipCallUcidAtom } from '@/state/sip-state';
|
||||||
import { setOutboundPending } from '@/state/sip-manager';
|
import { setOutboundPending } from '@/state/sip-manager';
|
||||||
import { useSip } from '@/providers/sip-provider';
|
import { useSip } from '@/providers/sip-provider';
|
||||||
import { DispositionForm } from './disposition-form';
|
import { DispositionModal } from './disposition-modal';
|
||||||
import { AppointmentForm } from './appointment-form';
|
import { AppointmentForm } from './appointment-form';
|
||||||
import { TransferDialog } from './transfer-dialog';
|
import { TransferDialog } from './transfer-dialog';
|
||||||
import { EnquiryForm } from './enquiry-form';
|
import { EnquiryForm } from './enquiry-form';
|
||||||
@@ -21,8 +21,6 @@ import { cx } from '@/utils/cx';
|
|||||||
import { notify } from '@/lib/toast';
|
import { notify } from '@/lib/toast';
|
||||||
import type { Lead, CallDisposition } from '@/types/entities';
|
import type { Lead, CallDisposition } from '@/types/entities';
|
||||||
|
|
||||||
type PostCallStage = 'disposition' | 'appointment' | 'follow-up' | 'done';
|
|
||||||
|
|
||||||
interface ActiveCallCardProps {
|
interface ActiveCallCardProps {
|
||||||
lead: Lead | null;
|
lead: Lead | null;
|
||||||
callerPhone: string;
|
callerPhone: string;
|
||||||
@@ -41,22 +39,28 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
const setCallState = useSetAtom(sipCallStateAtom);
|
const setCallState = useSetAtom(sipCallStateAtom);
|
||||||
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
|
const setCallerNumber = useSetAtom(sipCallerNumberAtom);
|
||||||
const setCallUcid = useSetAtom(sipCallUcidAtom);
|
const setCallUcid = useSetAtom(sipCallUcidAtom);
|
||||||
const [postCallStage, setPostCallStage] = useState<PostCallStage | null>(null);
|
|
||||||
const [appointmentOpen, setAppointmentOpen] = useState(false);
|
const [appointmentOpen, setAppointmentOpen] = useState(false);
|
||||||
const [appointmentBookedDuringCall, setAppointmentBookedDuringCall] = useState(false);
|
|
||||||
const [transferOpen, setTransferOpen] = useState(false);
|
const [transferOpen, setTransferOpen] = useState(false);
|
||||||
const [recordingPaused, setRecordingPaused] = useState(false);
|
const [recordingPaused, setRecordingPaused] = useState(false);
|
||||||
const [enquiryOpen, setEnquiryOpen] = 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');
|
const callDirectionRef = useRef(callState === 'ringing-out' ? 'OUTBOUND' : 'INBOUND');
|
||||||
// Track if the call was ever answered (reached 'active' state)
|
|
||||||
const wasAnsweredRef = useRef(callState === 'active');
|
const wasAnsweredRef = useRef(callState === 'active');
|
||||||
|
|
||||||
// Log mount so we can tell which component handled the call
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log(`[ACTIVE-CALL-CARD] Mounted: state=${callState} direction=${callDirectionRef.current} ucid=${callUcid ?? 'none'} lead=${lead?.id ?? 'none'} phone=${callerPhone}`);
|
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
|
}, []); // 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 firstName = lead?.contactName?.firstName ?? '';
|
||||||
const lastName = lead?.contactName?.lastName ?? '';
|
const lastName = lead?.contactName?.lastName ?? '';
|
||||||
const fullName = `${firstName} ${lastName}`.trim();
|
const fullName = `${firstName} ${lastName}`.trim();
|
||||||
@@ -64,8 +68,12 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
const phoneDisplay = phone ? formatPhone(phone) : callerPhone || 'Unknown';
|
const phoneDisplay = phone ? formatPhone(phone) : callerPhone || 'Unknown';
|
||||||
|
|
||||||
const handleDisposition = async (disposition: CallDisposition, notes: string) => {
|
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) {
|
if (callUcid) {
|
||||||
const disposePayload = {
|
const disposePayload = {
|
||||||
ucid: callUcid,
|
ucid: callUcid,
|
||||||
@@ -85,7 +93,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
console.warn('[DISPOSE] No callUcid — skipping disposition');
|
console.warn('[DISPOSE] No callUcid — skipping disposition');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Side effects per disposition type
|
// Side effects
|
||||||
if (disposition === 'FOLLOW_UP_SCHEDULED') {
|
if (disposition === 'FOLLOW_UP_SCHEDULED') {
|
||||||
try {
|
try {
|
||||||
await apiClient.graphql(`mutation($data: FollowUpCreateInput!) { createFollowUp(data: $data) { id } }`, {
|
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()}`);
|
notify.success('Call Logged', `Disposition: ${disposition.replace(/_/g, ' ').toLowerCase()}`);
|
||||||
handleReset();
|
handleReset();
|
||||||
};
|
};
|
||||||
@@ -112,13 +119,11 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
const handleAppointmentSaved = () => {
|
const handleAppointmentSaved = () => {
|
||||||
setAppointmentOpen(false);
|
setAppointmentOpen(false);
|
||||||
notify.success('Appointment Booked', 'Payment link will be sent to the patient');
|
notify.success('Appointment Booked', 'Payment link will be sent to the patient');
|
||||||
if (callState === 'active') {
|
|
||||||
setAppointmentBookedDuringCall(true);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
setPostCallStage(null);
|
setDispositionOpen(false);
|
||||||
|
setCallerDisconnected(false);
|
||||||
setCallState('idle');
|
setCallState('idle');
|
||||||
setCallerNumber(null);
|
setCallerNumber(null);
|
||||||
setCallUcid(null);
|
setCallUcid(null);
|
||||||
@@ -126,7 +131,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
onCallComplete?.();
|
onCallComplete?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Outbound ringing — agent initiated the call
|
// Outbound ringing
|
||||||
if (callState === 'ringing-out') {
|
if (callState === 'ringing-out') {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl bg-brand-primary p-4">
|
<div className="rounded-xl bg-brand-primary p-4">
|
||||||
@@ -145,7 +150,7 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex gap-2">
|
<div className="mt-3 flex gap-2">
|
||||||
<Button size="sm" color="primary-destructive" onClick={() => { hangup(); handleReset(); }}>
|
<Button size="sm" color="primary-destructive" onClick={() => { hangup(); handleReset(); }}>
|
||||||
End Call
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -177,8 +182,8 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip disposition for unanswered calls (ringing-in → ended without ever reaching active)
|
// Unanswered call (ringing → ended without ever reaching active)
|
||||||
if (!wasAnsweredRef.current && postCallStage === null && (callState === 'ended' || callState === 'failed')) {
|
if (!wasAnsweredRef.current && (callState === 'ended' || callState === 'failed')) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-secondary bg-primary p-4 text-center">
|
<div className="rounded-xl border border-secondary bg-primary p-4 text-center">
|
||||||
<FontAwesomeIcon icon={faPhoneHangup} className="size-6 text-fg-quaternary mb-2" />
|
<FontAwesomeIcon icon={faPhoneHangup} className="size-6 text-fg-quaternary mb-2" />
|
||||||
@@ -191,45 +196,11 @@ 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
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="rounded-xl border border-secondary bg-primary p-4">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<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"
|
|
||||||
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>
|
|
||||||
<EnquiryForm
|
|
||||||
isOpen={enquiryOpen}
|
|
||||||
onOpenChange={setEnquiryOpen}
|
|
||||||
callerPhone={callerPhone}
|
|
||||||
onSaved={() => {
|
|
||||||
setEnquiryOpen(false);
|
|
||||||
notify.success('Enquiry Logged');
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Active call
|
// Active call
|
||||||
if (callState === 'active') {
|
if (callState === 'active' || dispositionOpen) {
|
||||||
wasAnsweredRef.current = true;
|
wasAnsweredRef.current = true;
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="rounded-xl border border-brand bg-primary p-4">
|
<div className="rounded-xl border border-brand bg-primary p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -243,8 +214,9 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
</div>
|
</div>
|
||||||
<Badge size="md" color="success" type="pill-color">{formatDuration(callDuration)}</Badge>
|
<Badge size="md" color="success" type="pill-color">{formatDuration(callDuration)}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex items-center gap-1.5">
|
|
||||||
{/* Icon-only toggles */}
|
{/* Call controls */}
|
||||||
|
<div className="mt-3 flex items-center gap-1.5 flex-wrap">
|
||||||
<button
|
<button
|
||||||
onClick={toggleMute}
|
onClick={toggleMute}
|
||||||
title={isMuted ? 'Unmute' : 'Mute'}
|
title={isMuted ? 'Unmute' : 'Mute'}
|
||||||
@@ -282,7 +254,6 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
|
|
||||||
<div className="w-px h-6 bg-secondary mx-0.5" />
|
<div className="w-px h-6 bg-secondary mx-0.5" />
|
||||||
|
|
||||||
{/* Text+Icon primary actions */}
|
|
||||||
<Button size="sm" color={appointmentOpen ? 'primary' : 'secondary'}
|
<Button size="sm" color={appointmentOpen ? 'primary' : 'secondary'}
|
||||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />}
|
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faCalendarPlus} className={className} {...rest} />}
|
||||||
onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); }}>Book Appt</Button>
|
onClick={() => { setAppointmentOpen(!appointmentOpen); setEnquiryOpen(false); }}>Book Appt</Button>
|
||||||
@@ -292,9 +263,10 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
<Button size="sm" color="secondary"
|
<Button size="sm" color="secondary"
|
||||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} {...rest} />}
|
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} {...rest} />}
|
||||||
onClick={() => setTransferOpen(!transferOpen)}>Transfer</Button>
|
onClick={() => setTransferOpen(!transferOpen)}>Transfer</Button>
|
||||||
|
|
||||||
<Button size="sm" color="primary-destructive" className="ml-auto"
|
<Button size="sm" color="primary-destructive" className="ml-auto"
|
||||||
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneHangup} className={className} {...rest} />}
|
iconLeading={({ className, ...rest }: any) => <FontAwesomeIcon icon={faPhoneHangup} className={className} {...rest} />}
|
||||||
onClick={() => { hangup(); setPostCallStage('disposition'); }}>End</Button>
|
onClick={() => setDispositionOpen(true)}>End Call</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Transfer dialog */}
|
{/* Transfer dialog */}
|
||||||
@@ -304,13 +276,12 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
onClose={() => setTransferOpen(false)}
|
onClose={() => setTransferOpen(false)}
|
||||||
onTransferred={() => {
|
onTransferred={() => {
|
||||||
setTransferOpen(false);
|
setTransferOpen(false);
|
||||||
hangup();
|
setDispositionOpen(true);
|
||||||
setPostCallStage('disposition');
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Appointment form accessible during call */}
|
{/* Appointment form */}
|
||||||
<AppointmentForm
|
<AppointmentForm
|
||||||
isOpen={appointmentOpen}
|
isOpen={appointmentOpen}
|
||||||
onOpenChange={setAppointmentOpen}
|
onOpenChange={setAppointmentOpen}
|
||||||
@@ -332,6 +303,24 @@ export const ActiveCallCard = ({ lead, callerPhone, missedCallId, onCallComplete
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Disposition Modal — the ONLY path to end a call */}
|
||||||
|
<DispositionModal
|
||||||
|
isOpen={dispositionOpen}
|
||||||
|
callerName={fullName || phoneDisplay}
|
||||||
|
callerDisconnected={callerDisconnected}
|
||||||
|
onSubmit={handleDisposition}
|
||||||
|
onDismiss={() => {
|
||||||
|
// Agent wants to continue the call — close modal, call stays active
|
||||||
|
if (!callerDisconnected) {
|
||||||
|
setDispositionOpen(false);
|
||||||
|
} else {
|
||||||
|
// Caller already disconnected — dismiss goes to worklist
|
||||||
|
handleReset();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export const AgentStatusToggle = ({ isRegistered, connectionStatus }: AgentStatu
|
|||||||
}
|
}
|
||||||
|
|
||||||
const current = displayConfig[ozonetelState] ?? displayConfig.offline;
|
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 (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|||||||
@@ -1,174 +1,41 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useEffect } from 'react';
|
||||||
import {
|
import { useNavigate, useLocation } from 'react-router';
|
||||||
faPhone,
|
import { faPhone, faPhoneArrowDown, faPhoneXmark, faCircleCheck } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
faPhoneArrowDown,
|
|
||||||
faPhoneArrowUp,
|
|
||||||
faPhoneHangup,
|
|
||||||
faPhoneXmark,
|
|
||||||
faMicrophoneSlash,
|
|
||||||
faMicrophone,
|
|
||||||
faPause,
|
|
||||||
faCircleCheck,
|
|
||||||
faFloppyDisk,
|
|
||||||
faCalendarPlus,
|
|
||||||
} from '@fortawesome/pro-duotone-svg-icons';
|
|
||||||
import { faIcon } from '@/lib/icon-wrapper';
|
import { faIcon } from '@/lib/icon-wrapper';
|
||||||
|
|
||||||
const Phone01 = faIcon(faPhone);
|
const Phone01 = faIcon(faPhone);
|
||||||
const PhoneIncoming01 = faIcon(faPhoneArrowDown);
|
const PhoneIncoming01 = faIcon(faPhoneArrowDown);
|
||||||
const PhoneOutgoing01 = faIcon(faPhoneArrowUp);
|
|
||||||
const PhoneHangUp = faIcon(faPhoneHangup);
|
|
||||||
const PhoneX = faIcon(faPhoneXmark);
|
const PhoneX = faIcon(faPhoneXmark);
|
||||||
const MicrophoneOff01 = faIcon(faMicrophoneSlash);
|
|
||||||
const Microphone01 = faIcon(faMicrophone);
|
|
||||||
const PauseCircle = faIcon(faPause);
|
|
||||||
const CheckCircle = faIcon(faCircleCheck);
|
const CheckCircle = faIcon(faCircleCheck);
|
||||||
const Save01 = faIcon(faFloppyDisk);
|
|
||||||
const CalendarPlus02 = faIcon(faCalendarPlus);
|
|
||||||
import { Button } from '@/components/base/buttons/button';
|
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 { useSetAtom } from 'jotai';
|
||||||
import { sipCallStateAtom } from '@/state/sip-state';
|
import { sipCallStateAtom } from '@/state/sip-state';
|
||||||
import { useSip } from '@/providers/sip-provider';
|
import { useSip } from '@/providers/sip-provider';
|
||||||
import { useAuth } from '@/providers/auth-provider';
|
|
||||||
import { cx } from '@/utils/cx';
|
import { cx } from '@/utils/cx';
|
||||||
import type { CallDisposition } from '@/types/entities';
|
|
||||||
|
|
||||||
const formatDuration = (seconds: number): string => {
|
const formatDuration = (seconds: number): string => {
|
||||||
const m = Math.floor(seconds / 60)
|
const m = Math.floor(seconds / 60).toString().padStart(2, '0');
|
||||||
.toString()
|
|
||||||
.padStart(2, '0');
|
|
||||||
const s = (seconds % 60).toString().padStart(2, '0');
|
const s = (seconds % 60).toString().padStart(2, '0');
|
||||||
return `${m}:${s}`;
|
return `${m}:${s}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const dispositionOptions: Array<{
|
// CallWidget is a lightweight floating notification for calls outside the Call Desk.
|
||||||
value: CallDisposition;
|
// It only handles: ringing (answer/decline) + auto-redirect to Call Desk.
|
||||||
label: string;
|
// All active call management (mute, hold, end, disposition) happens on the Call Desk via ActiveCallCard.
|
||||||
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',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const CallWidget = () => {
|
export const CallWidget = () => {
|
||||||
const {
|
const { callState, callerNumber, callDuration, answer, reject } = useSip();
|
||||||
callState,
|
|
||||||
callerNumber,
|
|
||||||
isMuted,
|
|
||||||
isOnHold,
|
|
||||||
callDuration,
|
|
||||||
answer,
|
|
||||||
reject,
|
|
||||||
hangup,
|
|
||||||
toggleMute,
|
|
||||||
toggleHold,
|
|
||||||
} = useSip();
|
|
||||||
const { user } = useAuth();
|
|
||||||
const setCallState = useSetAtom(sipCallStateAtom);
|
const setCallState = useSetAtom(sipCallStateAtom);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
const [disposition, setDisposition] = useState<CallDisposition | null>(null);
|
// Auto-navigate to Call Desk when a call becomes active or outbound ringing starts
|
||||||
const [notes, setNotes] = useState('');
|
|
||||||
const [lastDuration, setLastDuration] = useState(0);
|
|
||||||
const [matchedLead, setMatchedLead] = useState<any>(null);
|
|
||||||
const [leadActivities, setLeadActivities] = useState<any[]>([]);
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
const [isAppointmentOpen, setIsAppointmentOpen] = useState(false);
|
|
||||||
const callStartTimeRef = useRef<string | null>(null);
|
|
||||||
|
|
||||||
// Capture duration right before call ends
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (callState === 'active' && callDuration > 0) {
|
if (pathname === '/call-desk') return;
|
||||||
setLastDuration(callDuration);
|
if (callState === 'active' || callState === 'ringing-out') {
|
||||||
|
console.log(`[CALL-WIDGET] Redirecting to Call Desk (state=${callState})`);
|
||||||
|
navigate('/call-desk');
|
||||||
}
|
}
|
||||||
}, [callState, callDuration]);
|
}, [callState, pathname, navigate]);
|
||||||
|
|
||||||
// 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]);
|
|
||||||
|
|
||||||
// Auto-dismiss ended/failed state after 3 seconds
|
// Auto-dismiss ended/failed state after 3 seconds
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -181,127 +48,35 @@ export const CallWidget = () => {
|
|||||||
}
|
}
|
||||||
}, [callState, setCallState]);
|
}, [callState, setCallState]);
|
||||||
|
|
||||||
const handleSaveAndClose = async () => {
|
// Log state changes
|
||||||
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<Record<string, string>> = {
|
|
||||||
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
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (callState !== 'idle') {
|
if (callState !== 'idle') {
|
||||||
console.log(`[CALL-WIDGET] State: ${callState} | caller=${callerNumber ?? 'none'}`);
|
console.log(`[CALL-WIDGET] State: ${callState} | caller=${callerNumber ?? 'none'}`);
|
||||||
}
|
}
|
||||||
}, [callState, callerNumber]);
|
}, [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') {
|
if (callState === 'ringing-in') {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={cx(
|
||||||
className={cx(
|
|
||||||
'fixed bottom-6 right-6 z-50 w-80',
|
'fixed bottom-6 right-6 z-50 w-80',
|
||||||
'flex flex-col items-center gap-5 rounded-2xl border border-secondary bg-primary p-6 shadow-2xl',
|
'flex flex-col items-center gap-5 rounded-2xl border border-secondary bg-primary p-6 shadow-2xl',
|
||||||
'transition-all duration-300',
|
'transition-all duration-300',
|
||||||
)}
|
)}>
|
||||||
>
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<div className="absolute inset-0 animate-ping rounded-full bg-brand-solid opacity-20" />
|
<div className="absolute inset-0 animate-ping rounded-full bg-brand-solid opacity-20" />
|
||||||
<div className="relative animate-bounce">
|
<div className="relative animate-bounce">
|
||||||
<PhoneIncoming01 className="size-10 text-fg-brand-primary" />
|
<PhoneIncoming01 className="size-10 text-fg-brand-primary" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-1">
|
<div className="flex flex-col items-center gap-1">
|
||||||
<span className="text-xs font-bold uppercase tracking-wider text-brand-secondary">
|
<span className="text-xs font-bold uppercase tracking-wider text-brand-secondary">Incoming Call</span>
|
||||||
Incoming Call
|
|
||||||
</span>
|
|
||||||
<span className="text-lg font-bold text-primary">{callerNumber ?? 'Unknown'}</span>
|
<span className="text-lg font-bold text-primary">{callerNumber ?? 'Unknown'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button size="md" color="primary" iconLeading={Phone01} onClick={answer}>
|
<Button size="md" color="primary" iconLeading={Phone01} onClick={() => { answer(); navigate('/call-desk'); }}>
|
||||||
Answer
|
Answer
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="md" color="primary-destructive" iconLeading={PhoneX} onClick={reject}>
|
<Button size="md" color="primary-destructive" iconLeading={PhoneX} onClick={reject}>
|
||||||
@@ -312,208 +87,25 @@ export const CallWidget = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ringing outbound
|
// Ended / Failed — brief notification
|
||||||
if (callState === 'ringing-out') {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cx(
|
|
||||||
'fixed bottom-6 right-6 z-50 w-80',
|
|
||||||
'flex flex-col items-center gap-5 rounded-2xl border border-secondary bg-primary p-6 shadow-2xl',
|
|
||||||
'transition-all duration-300',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<PhoneOutgoing01 className="size-10 animate-pulse text-fg-brand-primary" />
|
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-1">
|
|
||||||
<span className="text-xs font-bold uppercase tracking-wider text-brand-secondary">
|
|
||||||
Calling...
|
|
||||||
</span>
|
|
||||||
<span className="text-lg font-bold text-primary">{callerNumber ?? 'Unknown'}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button size="md" color="primary-destructive" iconLeading={PhoneHangUp} onClick={hangup}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Active call (full widget)
|
|
||||||
if (callState === 'active') {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cx(
|
|
||||||
'fixed bottom-6 right-6 z-50 w-80',
|
|
||||||
'flex flex-col gap-4 rounded-2xl border border-secondary bg-primary p-5 shadow-2xl',
|
|
||||||
'transition-all duration-300',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Phone01 className="size-4 text-fg-success-primary" />
|
|
||||||
<span className="text-sm font-semibold text-primary">Active Call</span>
|
|
||||||
</div>
|
|
||||||
<span className="font-mono text-sm font-bold tabular-nums text-brand-secondary">
|
|
||||||
{formatDuration(callDuration)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Caller info */}
|
|
||||||
<div>
|
|
||||||
<span className="text-lg font-bold text-primary">
|
|
||||||
{matchedLead?.contactName
|
|
||||||
? `${matchedLead.contactName.firstName ?? ''} ${matchedLead.contactName.lastName ?? ''}`.trim()
|
|
||||||
: callerNumber ?? 'Unknown'}
|
|
||||||
</span>
|
|
||||||
{matchedLead && (
|
|
||||||
<span className="ml-2 text-sm text-tertiary">{callerNumber}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* AI Summary */}
|
|
||||||
{matchedLead?.aiSummary && (
|
|
||||||
<div className="rounded-xl bg-brand-primary p-3">
|
|
||||||
<div className="mb-1 text-xs font-bold uppercase tracking-wider text-brand-secondary">AI Insight</div>
|
|
||||||
<p className="text-sm text-primary">{matchedLead.aiSummary}</p>
|
|
||||||
{matchedLead.aiSuggestedAction && (
|
|
||||||
<span className="mt-2 inline-block rounded-lg bg-brand-solid px-3 py-1 text-xs font-semibold text-white">
|
|
||||||
{matchedLead.aiSuggestedAction}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Recent activity */}
|
|
||||||
{leadActivities.length > 0 && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="text-xs font-semibold text-tertiary">Recent Activity</div>
|
|
||||||
{leadActivities.slice(0, 3).map((a: any, i: number) => (
|
|
||||||
<div key={i} className="text-xs text-quaternary">
|
|
||||||
{a.activityType?.replace(/_/g, ' ')}: {a.summary}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Call controls */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
color={isMuted ? 'primary' : 'secondary'}
|
|
||||||
iconLeading={isMuted ? MicrophoneOff01 : Microphone01}
|
|
||||||
onClick={toggleMute}
|
|
||||||
>
|
|
||||||
{isMuted ? 'Unmute' : 'Mute'}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
color={isOnHold ? 'primary' : 'secondary'}
|
|
||||||
iconLeading={PauseCircle}
|
|
||||||
onClick={toggleHold}
|
|
||||||
>
|
|
||||||
{isOnHold ? 'Resume' : 'Hold'}
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" color="primary-destructive" iconLeading={PhoneHangUp} onClick={hangup}>
|
|
||||||
End
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Book Appointment */}
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
color="primary"
|
|
||||||
iconLeading={CalendarPlus02}
|
|
||||||
onClick={() => setIsAppointmentOpen(true)}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
Book Appointment
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<AppointmentForm
|
|
||||||
isOpen={isAppointmentOpen}
|
|
||||||
onOpenChange={setIsAppointmentOpen}
|
|
||||||
callerNumber={callerNumber}
|
|
||||||
leadName={matchedLead ? `${matchedLead.contactName?.firstName ?? ''} ${matchedLead.contactName?.lastName ?? ''}`.trim() : null}
|
|
||||||
leadId={matchedLead?.id}
|
|
||||||
patientId={matchedLead?.patientId}
|
|
||||||
onSaved={() => {
|
|
||||||
setIsAppointmentOpen(false);
|
|
||||||
setDisposition('APPOINTMENT_BOOKED');
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="border-t border-secondary" />
|
|
||||||
|
|
||||||
{/* Disposition */}
|
|
||||||
<div className="flex flex-col gap-2.5">
|
|
||||||
<span className="text-xs font-bold uppercase tracking-wider text-secondary">Disposition</span>
|
|
||||||
<div className="grid grid-cols-2 gap-1.5">
|
|
||||||
{dispositionOptions.map((opt) => {
|
|
||||||
const isSelected = disposition === opt.value;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={opt.value}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setDisposition(opt.value)}
|
|
||||||
className={cx(
|
|
||||||
'cursor-pointer rounded-lg border px-2.5 py-1.5 text-xs font-semibold transition duration-100 ease-linear',
|
|
||||||
isSelected ? cx(opt.activeClass, 'ring-2 ring-brand') : opt.defaultClass,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TextArea
|
|
||||||
placeholder="Add notes..."
|
|
||||||
value={notes}
|
|
||||||
onChange={(value) => setNotes(value)}
|
|
||||||
rows={2}
|
|
||||||
textAreaClassName="text-xs"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
color="primary"
|
|
||||||
iconLeading={Save01}
|
|
||||||
isDisabled={disposition === null || isSaving}
|
|
||||||
isLoading={isSaving}
|
|
||||||
onClick={handleSaveAndClose}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{isSaving ? 'Saving...' : 'Save & Close'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ended / Failed
|
|
||||||
if (callState === 'ended' || callState === 'failed') {
|
if (callState === 'ended' || callState === 'failed') {
|
||||||
const isEnded = callState === 'ended';
|
const isEnded = callState === 'ended';
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={cx(
|
||||||
className={cx(
|
|
||||||
'fixed bottom-6 right-6 z-50 w-80',
|
'fixed bottom-6 right-6 z-50 w-80',
|
||||||
'flex flex-col items-center gap-2 rounded-2xl border border-secondary bg-primary p-5 shadow-2xl',
|
'flex flex-col items-center gap-2 rounded-2xl border border-secondary bg-primary p-5 shadow-2xl',
|
||||||
'transition-all duration-300',
|
'transition-all duration-300',
|
||||||
)}
|
)}>
|
||||||
>
|
<CheckCircle className={cx('size-8', isEnded ? 'text-fg-success-primary' : 'text-fg-error-primary')} />
|
||||||
<CheckCircle
|
|
||||||
className={cx('size-8', isEnded ? 'text-fg-success-primary' : 'text-fg-error-primary')}
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-semibold text-primary">
|
<span className="text-sm font-semibold text-primary">
|
||||||
{isEnded ? 'Call Ended' : 'Call Failed'}
|
{isEnded ? 'Call Ended' : 'Call Failed'}
|
||||||
{lastDuration > 0 && ` \u00B7 ${formatDuration(lastDuration)}`}
|
{callDuration > 0 && ` \u00B7 ${formatDuration(callDuration)}`}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-tertiary">auto-closing...</span>
|
<span className="text-xs text-tertiary">auto-closing...</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Any other state (active, ringing-out) on a non-call-desk page — redirect handled by useEffect above
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|||||||
160
src/components/call-desk/disposition-modal.tsx
Normal file
160
src/components/call-desk/disposition-modal.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faPhoneHangup } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
|
import { Dialog, Modal, ModalOverlay } from '@/components/application/modals/modal';
|
||||||
|
import { FeaturedIcon } from '@/components/foundations/featured-icon/featured-icon';
|
||||||
|
import { TextArea } from '@/components/base/textarea/textarea';
|
||||||
|
import type { FC } from 'react';
|
||||||
|
import type { CallDisposition } from '@/types/entities';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
|
const PhoneHangUpIcon: FC<{ className?: string }> = ({ className }) => (
|
||||||
|
<FontAwesomeIcon icon={faPhoneHangup} className={className} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const dispositionOptions: Array<{
|
||||||
|
value: CallDisposition;
|
||||||
|
label: string;
|
||||||
|
activeClass: string;
|
||||||
|
defaultClass: string;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
value: 'APPOINTMENT_BOOKED',
|
||||||
|
label: 'Appointment Booked',
|
||||||
|
activeClass: 'bg-success-solid text-white border-transparent',
|
||||||
|
defaultClass: 'bg-success-primary text-success-primary border-success',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'FOLLOW_UP_SCHEDULED',
|
||||||
|
label: 'Follow-up Needed',
|
||||||
|
activeClass: 'bg-brand-solid text-white border-transparent',
|
||||||
|
defaultClass: 'bg-brand-primary text-brand-secondary border-brand',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'INFO_PROVIDED',
|
||||||
|
label: 'Info Provided',
|
||||||
|
activeClass: 'bg-utility-blue-light-600 text-white border-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 border-transparent',
|
||||||
|
defaultClass: 'bg-warning-primary text-warning-primary border-warning',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'WRONG_NUMBER',
|
||||||
|
label: 'Wrong Number',
|
||||||
|
activeClass: 'bg-secondary-solid text-white border-transparent',
|
||||||
|
defaultClass: 'bg-secondary text-secondary border-secondary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'CALLBACK_REQUESTED',
|
||||||
|
label: 'Not Interested',
|
||||||
|
activeClass: 'bg-error-solid text-white border-transparent',
|
||||||
|
defaultClass: 'bg-error-primary text-error-primary border-error',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
type DispositionModalProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
callerName: string;
|
||||||
|
callerDisconnected: boolean;
|
||||||
|
onSubmit: (disposition: CallDisposition, notes: string) => void;
|
||||||
|
onDismiss?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DispositionModal = ({ isOpen, callerName, callerDisconnected, onSubmit, onDismiss }: DispositionModalProps) => {
|
||||||
|
const [selected, setSelected] = useState<CallDisposition | null>(null);
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (selected === null) return;
|
||||||
|
onSubmit(selected, notes);
|
||||||
|
setSelected(null);
|
||||||
|
setNotes('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalOverlay isOpen={isOpen} isDismissable onOpenChange={(open) => { if (!open && onDismiss) onDismiss(); }}>
|
||||||
|
<Modal className="sm:max-w-md">
|
||||||
|
<Dialog>
|
||||||
|
{() => (
|
||||||
|
<div className="flex w-full flex-col rounded-xl bg-primary shadow-xl ring-1 ring-secondary overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col items-center gap-3 px-6 pt-6 pb-4">
|
||||||
|
<FeaturedIcon icon={PhoneHangUpIcon} color={callerDisconnected ? 'warning' : 'error'} theme="light" size="md" />
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-lg font-semibold text-primary">
|
||||||
|
{callerDisconnected ? 'Call Disconnected' : 'End Call'}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-tertiary">
|
||||||
|
{callerDisconnected
|
||||||
|
? `${callerName} disconnected. What was the outcome?`
|
||||||
|
: `Select a reason to end the call with ${callerName}.`
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Disposition options */}
|
||||||
|
<div className="px-6 pb-4">
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{dispositionOptions.map((option) => {
|
||||||
|
const isSelected = selected === option.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelected(option.value)}
|
||||||
|
className={cx(
|
||||||
|
'cursor-pointer rounded-xl border-2 p-3 text-sm font-semibold transition duration-100 ease-linear',
|
||||||
|
isSelected
|
||||||
|
? cx(option.activeClass, 'ring-2 ring-brand')
|
||||||
|
: option.defaultClass,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
<TextArea
|
||||||
|
label="Notes (optional)"
|
||||||
|
placeholder="Add any notes about this call..."
|
||||||
|
value={notes}
|
||||||
|
onChange={(value) => setNotes(value)}
|
||||||
|
rows={2}
|
||||||
|
textAreaClassName="text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="border-t border-secondary px-6 py-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={selected === null}
|
||||||
|
className={cx(
|
||||||
|
'w-full rounded-xl px-6 py-2.5 text-sm font-semibold transition duration-100 ease-linear',
|
||||||
|
selected !== null
|
||||||
|
? 'cursor-pointer bg-error-solid text-white hover:bg-error-solid_hover'
|
||||||
|
: 'cursor-not-allowed bg-disabled text-disabled',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{callerDisconnected
|
||||||
|
? (selected ? 'Submit & Close' : 'Select a reason')
|
||||||
|
: (selected ? 'End Call & Submit' : 'Select a reason to end call')
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
</Modal>
|
||||||
|
</ModalOverlay>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -81,7 +81,7 @@ export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, onSaved }: Enqu
|
|||||||
name: `Enquiry — ${patientName}`,
|
name: `Enquiry — ${patientName}`,
|
||||||
contactName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' },
|
contactName: { firstName: patientName.split(' ')[0], lastName: patientName.split(' ').slice(1).join(' ') || '' },
|
||||||
contactPhone: registeredPhone ? { primaryPhoneNumber: registeredPhone } : undefined,
|
contactPhone: registeredPhone ? { primaryPhoneNumber: registeredPhone } : undefined,
|
||||||
source: 'PHONE_INQUIRY',
|
source: 'PHONE',
|
||||||
status: disposition === 'CONVERTED' ? 'CONVERTED' : 'NEW',
|
status: disposition === 'CONVERTED' ? 'CONVERTED' : 'NEW',
|
||||||
interestedService: queryAsked.substring(0, 100),
|
interestedService: queryAsked.substring(0, 100),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -320,9 +320,9 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col min-h-0 overflow-hidden">
|
||||||
{/* Filter tabs + search */}
|
{/* Filter tabs + search */}
|
||||||
<div className="flex items-end justify-between border-b border-secondary px-5 pt-3 pb-0.5">
|
<div className="flex shrink-0 items-end justify-between border-b border-secondary px-5 pt-3 pb-0.5">
|
||||||
<Tabs selectedKey={tab} onSelectionChange={(key) => handleTabChange(key as TabKey)}>
|
<Tabs selectedKey={tab} onSelectionChange={(key) => handleTabChange(key as TabKey)}>
|
||||||
<TabList items={tabItems} type="underline" size="sm">
|
<TabList items={tabItems} type="underline" size="sm">
|
||||||
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
|
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
|
||||||
@@ -342,7 +342,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
|||||||
|
|
||||||
{/* Missed call status sub-tabs */}
|
{/* Missed call status sub-tabs */}
|
||||||
{tab === 'missed' && (
|
{tab === 'missed' && (
|
||||||
<div className="flex gap-1 px-5 py-2 border-b border-secondary">
|
<div className="flex shrink-0 gap-1 px-5 py-2 border-b border-secondary">
|
||||||
{(['pending', 'attempted', 'completed', 'invalid'] as MissedSubTab[]).map(sub => (
|
{(['pending', 'attempted', 'completed', 'invalid'] as MissedSubTab[]).map(sub => (
|
||||||
<button
|
<button
|
||||||
key={sub}
|
key={sub}
|
||||||
@@ -372,7 +372,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="px-2 pt-3">
|
<div className="flex flex-1 flex-col min-h-0 overflow-hidden px-2 pt-3">
|
||||||
<Table size="sm">
|
<Table size="sm">
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Head label="PRIORITY" className="w-20" isRowHeader />
|
<Table.Head label="PRIORITY" className="w-20" isRowHeader />
|
||||||
@@ -457,7 +457,7 @@ export const WorklistPanel = ({ missedCalls, followUps, leads, loading, onSelect
|
|||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table>
|
</Table>
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="flex items-center justify-between border-t border-secondary px-5 py-3">
|
<div className="flex shrink-0 items-center justify-between border-t border-secondary px-5 py-3">
|
||||||
<span className="text-xs text-tertiary">
|
<span className="text-xs text-tertiary">
|
||||||
Showing {(page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, filteredRows.length)} of {filteredRows.length}
|
Showing {(page - 1) * PAGE_SIZE + 1}–{Math.min(page * PAGE_SIZE, filteredRows.length)} of {filteredRows.length}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { useEffect, type ReactNode } from 'react';
|
import { useEffect, type ReactNode } from 'react';
|
||||||
import { useLocation } from 'react-router';
|
import { useLocation } from 'react-router';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faWifi, faWifiSlash } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { Sidebar } from './sidebar';
|
import { Sidebar } from './sidebar';
|
||||||
import { SipProvider } from '@/providers/sip-provider';
|
import { SipProvider } from '@/providers/sip-provider';
|
||||||
|
import { useSip } from '@/providers/sip-provider';
|
||||||
import { CallWidget } from '@/components/call-desk/call-widget';
|
import { CallWidget } from '@/components/call-desk/call-widget';
|
||||||
import { MaintOtpModal } from '@/components/modals/maint-otp-modal';
|
import { MaintOtpModal } from '@/components/modals/maint-otp-modal';
|
||||||
|
import { AgentStatusToggle } from '@/components/call-desk/agent-status-toggle';
|
||||||
import { useAuth } from '@/providers/auth-provider';
|
import { useAuth } from '@/providers/auth-provider';
|
||||||
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
|
import { useMaintShortcuts } from '@/hooks/use-maint-shortcuts';
|
||||||
|
import { useNetworkStatus } from '@/hooks/use-network-status';
|
||||||
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
interface AppShellProps {
|
interface AppShellProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -15,6 +21,9 @@ export const AppShell = ({ children }: AppShellProps) => {
|
|||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const { isCCAgent } = useAuth();
|
const { isCCAgent } = useAuth();
|
||||||
const { isOpen, activeAction, close } = useMaintShortcuts();
|
const { isOpen, activeAction, close } = useMaintShortcuts();
|
||||||
|
const { connectionStatus, isRegistered } = useSip();
|
||||||
|
const networkQuality = useNetworkStatus();
|
||||||
|
const hasAgentConfig = !!localStorage.getItem('helix_agent_config');
|
||||||
|
|
||||||
// Heartbeat: keep agent session alive in Redis (CC agents only)
|
// Heartbeat: keep agent session alive in Redis (CC agents only)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -39,7 +48,29 @@ export const AppShell = ({ children }: AppShellProps) => {
|
|||||||
<SipProvider>
|
<SipProvider>
|
||||||
<div className="flex h-screen bg-primary">
|
<div className="flex h-screen bg-primary">
|
||||||
<Sidebar activeUrl={pathname} />
|
<Sidebar activeUrl={pathname} />
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
{/* Persistent top bar — visible on all pages */}
|
||||||
|
{hasAgentConfig && (
|
||||||
|
<div className="flex shrink-0 items-center justify-end gap-2 border-b border-secondary px-4 py-2">
|
||||||
|
<div className={cx(
|
||||||
|
'flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium',
|
||||||
|
networkQuality === 'good'
|
||||||
|
? 'bg-success-primary text-success-primary'
|
||||||
|
: networkQuality === 'offline'
|
||||||
|
? 'bg-error-secondary text-error-primary'
|
||||||
|
: 'bg-warning-secondary text-warning-primary',
|
||||||
|
)}>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={networkQuality === 'offline' ? faWifiSlash : faWifi}
|
||||||
|
className="size-3"
|
||||||
|
/>
|
||||||
|
{networkQuality === 'good' ? 'Connected' : networkQuality === 'offline' ? 'No connection' : 'Unstable'}
|
||||||
|
</div>
|
||||||
|
<AgentStatusToggle isRegistered={isRegistered} connectionStatus={connectionStatus} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<main className="flex flex-1 flex-col overflow-hidden">{children}</main>
|
<main className="flex flex-1 flex-col overflow-hidden">{children}</main>
|
||||||
|
</div>
|
||||||
{isCCAgent && pathname !== '/' && pathname !== '/call-desk' && <CallWidget />}
|
{isCCAgent && pathname !== '/' && pathname !== '/call-desk' && <CallWidget />}
|
||||||
</div>
|
</div>
|
||||||
<MaintOtpModal isOpen={isOpen} onOpenChange={(open) => !open && close()} action={activeAction} />
|
<MaintOtpModal isOpen={isOpen} onOpenChange={(open) => !open && close()} action={activeAction} />
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ import {
|
|||||||
faCalendarCheck,
|
faCalendarCheck,
|
||||||
faPhone,
|
faPhone,
|
||||||
faUsers,
|
faUsers,
|
||||||
faWifi,
|
|
||||||
faWifiSlash,
|
|
||||||
faArrowRightFromBracket,
|
faArrowRightFromBracket,
|
||||||
faTowerBroadcast,
|
faTowerBroadcast,
|
||||||
faChartLine,
|
faChartLine,
|
||||||
@@ -31,10 +29,8 @@ import { NavAccountCard } from "@/components/application/app-navigation/base-com
|
|||||||
import { NavItemBase } from "@/components/application/app-navigation/base-components/nav-item";
|
import { NavItemBase } from "@/components/application/app-navigation/base-components/nav-item";
|
||||||
import type { NavItemType } from "@/components/application/app-navigation/config";
|
import type { NavItemType } from "@/components/application/app-navigation/config";
|
||||||
import { Avatar } from "@/components/base/avatar/avatar";
|
import { Avatar } from "@/components/base/avatar/avatar";
|
||||||
import { apiClient } from "@/lib/api-client";
|
|
||||||
import { notify } from "@/lib/toast";
|
|
||||||
import { useAuth } from "@/providers/auth-provider";
|
import { useAuth } from "@/providers/auth-provider";
|
||||||
import { useNetworkStatus } from "@/hooks/use-network-status";
|
import { useAgentState } from "@/hooks/use-agent-state";
|
||||||
import { sidebarCollapsedAtom } from "@/state/sidebar-state";
|
import { sidebarCollapsedAtom } from "@/state/sidebar-state";
|
||||||
import { cx } from "@/utils/cx";
|
import { cx } from "@/utils/cx";
|
||||||
|
|
||||||
@@ -126,7 +122,10 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
|||||||
const { logout, user } = useAuth();
|
const { logout, user } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [collapsed, setCollapsed] = useAtom(sidebarCollapsedAtom);
|
const [collapsed, setCollapsed] = useAtom(sidebarCollapsedAtom);
|
||||||
const networkQuality = useNetworkStatus();
|
const agentConfig = typeof window !== 'undefined' ? localStorage.getItem('helix_agent_config') : null;
|
||||||
|
const agentId = agentConfig ? (() => { try { return JSON.parse(agentConfig).ozonetelAgentId; } catch { return null; } })() : null;
|
||||||
|
const ozonetelState = useAgentState(agentId);
|
||||||
|
const avatarStatus: 'online' | 'offline' = ozonetelState === 'ready' ? 'online' : 'offline';
|
||||||
|
|
||||||
const width = collapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH;
|
const width = collapsed ? COLLAPSED_WIDTH : EXPANDED_WIDTH;
|
||||||
|
|
||||||
@@ -142,15 +141,6 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
|||||||
navigate('/login');
|
navigate('/login');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleForceReady = async () => {
|
|
||||||
try {
|
|
||||||
await apiClient.post('/api/ozonetel/agent-ready', {});
|
|
||||||
notify.success('Agent Ready', 'Agent state has been reset to Ready');
|
|
||||||
} catch {
|
|
||||||
notify.error('Force Ready Failed', 'Could not reset agent state');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const navSections = getNavSections(user.role);
|
const navSections = getNavSections(user.role);
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
@@ -222,25 +212,6 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{/* Network indicator — only shows when network is degraded */}
|
|
||||||
{networkQuality !== 'good' && (
|
|
||||||
<div className={cx(
|
|
||||||
"mx-3 mb-2 flex items-center gap-2 rounded-lg px-3 py-2 text-xs font-medium",
|
|
||||||
networkQuality === 'offline'
|
|
||||||
? "bg-error-secondary text-error-primary"
|
|
||||||
: "bg-warning-secondary text-warning-primary",
|
|
||||||
collapsed && "justify-center mx-2 px-2",
|
|
||||||
)}>
|
|
||||||
<FontAwesomeIcon
|
|
||||||
icon={networkQuality === 'offline' ? faWifiSlash : faWifi}
|
|
||||||
className="size-3.5 shrink-0"
|
|
||||||
/>
|
|
||||||
{!collapsed && (
|
|
||||||
<span>{networkQuality === 'offline' ? 'No connection' : 'Unstable network'}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Account card */}
|
{/* Account card */}
|
||||||
<div className={cx("mt-auto py-4", collapsed ? "flex justify-center px-2" : "px-2 lg:px-4")}>
|
<div className={cx("mt-auto py-4", collapsed ? "flex justify-center px-2" : "px-2 lg:px-4")}>
|
||||||
{collapsed ? (
|
{collapsed ? (
|
||||||
@@ -249,7 +220,7 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
|||||||
title={`${user.name}\nSign out`}
|
title={`${user.name}\nSign out`}
|
||||||
className="rounded-lg p-1 hover:bg-primary_hover transition duration-100 ease-linear"
|
className="rounded-lg p-1 hover:bg-primary_hover transition duration-100 ease-linear"
|
||||||
>
|
>
|
||||||
<Avatar size="sm" initials={user.initials} status="online" />
|
<Avatar size="sm" initials={user.initials} status={avatarStatus} />
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<NavAccountCard
|
<NavAccountCard
|
||||||
@@ -258,11 +229,13 @@ export const Sidebar = ({ activeUrl = "/" }: SidebarProps) => {
|
|||||||
name: user.name,
|
name: user.name,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
avatar: '',
|
avatar: '',
|
||||||
status: 'online' as const,
|
status: avatarStatus,
|
||||||
}]}
|
}]}
|
||||||
selectedAccountId="current"
|
selectedAccountId="current"
|
||||||
|
popoverPlacement="top"
|
||||||
onSignOut={handleSignOut}
|
onSignOut={handleSignOut}
|
||||||
onForceReady={handleForceReady}
|
onViewProfile={() => navigate('/profile')}
|
||||||
|
onAccountSettings={() => navigate('/account-settings')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import { TeamPerformancePage } from "@/pages/team-performance";
|
|||||||
import { LiveMonitorPage } from "@/pages/live-monitor";
|
import { LiveMonitorPage } from "@/pages/live-monitor";
|
||||||
import { CallRecordingsPage } from "@/pages/call-recordings";
|
import { CallRecordingsPage } from "@/pages/call-recordings";
|
||||||
import { MissedCallsPage } from "@/pages/missed-calls";
|
import { MissedCallsPage } from "@/pages/missed-calls";
|
||||||
|
import { ProfilePage } from "@/pages/profile";
|
||||||
|
import { AccountSettingsPage } from "@/pages/account-settings";
|
||||||
import { AuthProvider } from "@/providers/auth-provider";
|
import { AuthProvider } from "@/providers/auth-provider";
|
||||||
import { DataProvider } from "@/providers/data-provider";
|
import { DataProvider } from "@/providers/data-provider";
|
||||||
import { RouteProvider } from "@/providers/router-provider";
|
import { RouteProvider } from "@/providers/router-provider";
|
||||||
@@ -71,6 +73,8 @@ createRoot(document.getElementById("root")!).render(
|
|||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/agent/:id" element={<AgentDetailPage />} />
|
<Route path="/agent/:id" element={<AgentDetailPage />} />
|
||||||
<Route path="/patient/:id" element={<Patient360Page />} />
|
<Route path="/patient/:id" element={<Patient360Page />} />
|
||||||
|
<Route path="/profile" element={<ProfilePage />} />
|
||||||
|
<Route path="/account-settings" element={<AccountSettingsPage />} />
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
14
src/pages/account-settings.tsx
Normal file
14
src/pages/account-settings.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { TopBar } from '@/components/layout/top-bar';
|
||||||
|
|
||||||
|
export const AccountSettingsPage = () => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<TopBar title="Account Settings" />
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-8">
|
||||||
|
<div className="rounded-lg bg-secondary px-4 py-3 text-sm text-tertiary">
|
||||||
|
Account settings are coming soon.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -6,6 +6,7 @@ const SearchLg = faIcon(faMagnifyingGlass);
|
|||||||
import { Badge } from '@/components/base/badges/badges';
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
import { Input } from '@/components/base/input/input';
|
import { Input } from '@/components/base/input/input';
|
||||||
import { Table } from '@/components/application/table/table';
|
import { Table } from '@/components/application/table/table';
|
||||||
|
import { PaginationCardDefault } from '@/components/application/pagination/pagination';
|
||||||
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
|
import { Tabs, TabList, Tab } from '@/components/application/tabs/tabs';
|
||||||
import { TopBar } from '@/components/layout/top-bar';
|
import { TopBar } from '@/components/layout/top-bar';
|
||||||
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
import { PhoneActionCell } from '@/components/call-desk/phone-action-cell';
|
||||||
@@ -69,6 +70,8 @@ export const AppointmentsPage = () => {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [tab, setTab] = useState<StatusTab>('all');
|
const [tab, setTab] = useState<StatusTab>('all');
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiClient.graphql<{ appointments: { edges: Array<{ node: AppointmentRecord }> } }>(QUERY, undefined, { silent: true })
|
apiClient.graphql<{ appointments: { edges: Array<{ node: AppointmentRecord }> } }>(QUERY, undefined, { silent: true })
|
||||||
@@ -108,6 +111,12 @@ export const AppointmentsPage = () => {
|
|||||||
return rows;
|
return rows;
|
||||||
}, [appointments, tab, search]);
|
}, [appointments, tab, search]);
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||||
|
const pagedRows = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||||
|
|
||||||
|
// Reset page on filter/search change
|
||||||
|
useEffect(() => { setPage(1); }, [tab, search]);
|
||||||
|
|
||||||
const tabItems = [
|
const tabItems = [
|
||||||
{ id: 'all' as const, label: 'All', badge: appointments.length > 0 ? String(appointments.length) : undefined },
|
{ id: 'all' as const, label: 'All', badge: appointments.length > 0 ? String(appointments.length) : undefined },
|
||||||
{ id: 'SCHEDULED' as const, label: 'Booked', badge: statusCounts.SCHEDULED ? String(statusCounts.SCHEDULED) : undefined },
|
{ id: 'SCHEDULED' as const, label: 'Booked', badge: statusCounts.SCHEDULED ? String(statusCounts.SCHEDULED) : undefined },
|
||||||
@@ -122,7 +131,7 @@ export const AppointmentsPage = () => {
|
|||||||
|
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
{/* Tabs + search */}
|
{/* Tabs + search */}
|
||||||
<div className="flex items-end justify-between border-b border-secondary px-6 pt-3 pb-0.5">
|
<div className="flex shrink-0 items-end justify-between border-b border-secondary px-6 pt-3 pb-0.5">
|
||||||
<Tabs selectedKey={tab} onSelectionChange={(key) => setTab(key as StatusTab)}>
|
<Tabs selectedKey={tab} onSelectionChange={(key) => setTab(key as StatusTab)}>
|
||||||
<TabList items={tabItems} type="underline" size="sm">
|
<TabList items={tabItems} type="underline" size="sm">
|
||||||
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
|
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
|
||||||
@@ -141,7 +150,7 @@ export const AppointmentsPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="flex-1 overflow-y-auto px-4 pt-3">
|
<div className="flex flex-1 flex-col overflow-hidden px-4 pt-3">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<p className="text-sm text-tertiary">Loading appointments...</p>
|
<p className="text-sm text-tertiary">Loading appointments...</p>
|
||||||
@@ -162,7 +171,7 @@ export const AppointmentsPage = () => {
|
|||||||
<Table.Head label="Status" className="w-28" />
|
<Table.Head label="Status" className="w-28" />
|
||||||
<Table.Head label="Chief Complaint" />
|
<Table.Head label="Chief Complaint" />
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body items={filtered}>
|
<Table.Body items={pagedRows}>
|
||||||
{(appt) => {
|
{(appt) => {
|
||||||
const patientName = appt.patient
|
const patientName = appt.patient
|
||||||
? `${appt.patient.fullName?.firstName ?? ''} ${appt.patient.fullName?.lastName ?? ''}`.trim() || 'Unknown'
|
? `${appt.patient.fullName?.firstName ?? ''} ${appt.patient.fullName?.lastName ?? ''}`.trim() || 'Unknown'
|
||||||
@@ -222,6 +231,13 @@ export const AppointmentsPage = () => {
|
|||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table>
|
</Table>
|
||||||
)}
|
)}
|
||||||
|
<div className="shrink-0">
|
||||||
|
<PaginationCardDefault
|
||||||
|
page={page}
|
||||||
|
total={totalPages}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -11,14 +11,13 @@ import { ContextPanel } from '@/components/call-desk/context-panel';
|
|||||||
import { ActiveCallCard } from '@/components/call-desk/active-call-card';
|
import { ActiveCallCard } from '@/components/call-desk/active-call-card';
|
||||||
|
|
||||||
import { Badge } from '@/components/base/badges/badges';
|
import { Badge } from '@/components/base/badges/badges';
|
||||||
import { AgentStatusToggle } from '@/components/call-desk/agent-status-toggle';
|
|
||||||
import { notify } from '@/lib/toast';
|
import { notify } from '@/lib/toast';
|
||||||
import { cx } from '@/utils/cx';
|
import { cx } from '@/utils/cx';
|
||||||
|
|
||||||
export const CallDeskPage = () => {
|
export const CallDeskPage = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { leadActivities } = useData();
|
const { leadActivities } = useData();
|
||||||
const { connectionStatus, isRegistered, callState, callerNumber, callUcid, dialOutbound } = useSip();
|
const { callState, callerNumber, callUcid, dialOutbound } = useSip();
|
||||||
const { missedCalls, followUps, marketingLeads, totalPending, loading } = useWorklist();
|
const { missedCalls, followUps, marketingLeads, totalPending, loading } = useWorklist();
|
||||||
const [selectedLead, setSelectedLead] = useState<WorklistLead | null>(null);
|
const [selectedLead, setSelectedLead] = useState<WorklistLead | null>(null);
|
||||||
const [contextOpen, setContextOpen] = useState(true);
|
const [contextOpen, setContextOpen] = useState(true);
|
||||||
@@ -132,7 +131,6 @@ export const CallDeskPage = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<AgentStatusToggle isRegistered={isRegistered} connectionStatus={connectionStatus} />
|
|
||||||
{totalPending > 0 && (
|
{totalPending > 0 && (
|
||||||
<Badge size="sm" color="brand" type="pill-color">{totalPending} pending</Badge>
|
<Badge size="sm" color="brand" type="pill-color">{totalPending} pending</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -149,7 +147,7 @@ export const CallDeskPage = () => {
|
|||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* Main panel */}
|
{/* Main panel */}
|
||||||
<div className="flex flex-1 flex-col overflow-y-auto">
|
<div className="flex flex-1 flex-col min-h-0">
|
||||||
{/* Active call */}
|
{/* Active call */}
|
||||||
{isInCall && (
|
{isInCall && (
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { FC } from 'react';
|
import type { FC } from 'react';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +20,7 @@ import { TopBar } from '@/components/layout/top-bar';
|
|||||||
import { ClickToCallButton } from '@/components/call-desk/click-to-call-button';
|
import { ClickToCallButton } from '@/components/call-desk/click-to-call-button';
|
||||||
import { formatShortDate, formatPhone } from '@/lib/format';
|
import { formatShortDate, formatPhone } from '@/lib/format';
|
||||||
import { useData } from '@/providers/data-provider';
|
import { useData } from '@/providers/data-provider';
|
||||||
|
import { PaginationCardDefault } from '@/components/application/pagination/pagination';
|
||||||
import type { Call, CallDirection, CallDisposition } from '@/types/entities';
|
import type { Call, CallDirection, CallDisposition } from '@/types/entities';
|
||||||
|
|
||||||
type FilterKey = 'all' | 'inbound' | 'outbound' | 'missed';
|
type FilterKey = 'all' | 'inbound' | 'outbound' | 'missed';
|
||||||
@@ -103,17 +104,13 @@ const RecordingPlayer: FC<{ url: string }> = ({ url }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20;
|
||||||
|
|
||||||
export const CallHistoryPage = () => {
|
export const CallHistoryPage = () => {
|
||||||
const { calls, leads } = useData();
|
const { calls, leads } = useData();
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [filter, setFilter] = useState<FilterKey>('all');
|
const [filter, setFilter] = useState<FilterKey>('all');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
// Debug: log first call's raw timestamp to diagnose timezone issue
|
|
||||||
if (calls.length > 0 && !(window as any).__callTimestampLogged) {
|
|
||||||
const c = calls[0];
|
|
||||||
console.log(`[DEBUG-TIME] Raw startedAt="${c.startedAt}" → parsed=${new Date(c.startedAt!)} → formatted="${c.startedAt ? new Intl.DateTimeFormat('en-IN', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true }).format(new Date(c.startedAt)) : 'n/a'}" | direction=${c.callDirection} status=${c.callStatus}`);
|
|
||||||
(window as any).__callTimestampLogged = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build a map of lead names by ID for enrichment
|
// Build a map of lead names by ID for enrichment
|
||||||
const leadNameMap = useMemo(() => {
|
const leadNameMap = useMemo(() => {
|
||||||
@@ -161,12 +158,18 @@ export const CallHistoryPage = () => {
|
|||||||
const completedCount = filteredCalls.filter((c) => c.callStatus !== 'MISSED').length;
|
const completedCount = filteredCalls.filter((c) => c.callStatus !== 'MISSED').length;
|
||||||
const missedCount = filteredCalls.filter((c) => c.callStatus === 'MISSED').length;
|
const missedCount = filteredCalls.filter((c) => c.callStatus === 'MISSED').length;
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filteredCalls.length / PAGE_SIZE));
|
||||||
|
const pagedCalls = filteredCalls.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
||||||
|
|
||||||
|
// Reset page when filter/search changes
|
||||||
|
useEffect(() => { setPage(1); }, [filter, search]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
<TopBar title="Call History" subtitle={`${filteredCalls.length} total calls`} />
|
<TopBar title="Call History" subtitle={`${filteredCalls.length} total calls`} />
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-7">
|
<div className="flex flex-1 flex-col overflow-hidden p-7">
|
||||||
<TableCard.Root size="md">
|
<TableCard.Root size="md" className="flex-1 min-h-0">
|
||||||
<TableCard.Header
|
<TableCard.Header
|
||||||
title="Call History"
|
title="Call History"
|
||||||
badge={String(filteredCalls.length)}
|
badge={String(filteredCalls.length)}
|
||||||
@@ -214,7 +217,7 @@ export const CallHistoryPage = () => {
|
|||||||
<Table>
|
<Table>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Head label="TYPE" className="w-14" isRowHeader />
|
<Table.Head label="TYPE" className="w-14" isRowHeader />
|
||||||
<Table.Head label="PATIENT" />
|
<Table.Head label="CALLER" />
|
||||||
<Table.Head label="PHONE" />
|
<Table.Head label="PHONE" />
|
||||||
<Table.Head label="DURATION" className="w-24" />
|
<Table.Head label="DURATION" className="w-24" />
|
||||||
<Table.Head label="OUTCOME" />
|
<Table.Head label="OUTCOME" />
|
||||||
@@ -223,7 +226,7 @@ export const CallHistoryPage = () => {
|
|||||||
<Table.Head label="TIME" />
|
<Table.Head label="TIME" />
|
||||||
<Table.Head label="ACTIONS" className="w-24" />
|
<Table.Head label="ACTIONS" className="w-24" />
|
||||||
</Table.Header>
|
</Table.Header>
|
||||||
<Table.Body items={filteredCalls}>
|
<Table.Body items={pagedCalls}>
|
||||||
{(call) => {
|
{(call) => {
|
||||||
const patientName = call.leadName ?? leadNameMap.get(call.leadId ?? '') ?? 'Unknown';
|
const patientName = call.leadName ?? leadNameMap.get(call.leadId ?? '') ?? 'Unknown';
|
||||||
const phoneDisplay = formatPhoneDisplay(call);
|
const phoneDisplay = formatPhoneDisplay(call);
|
||||||
@@ -294,6 +297,13 @@ export const CallHistoryPage = () => {
|
|||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table>
|
</Table>
|
||||||
)}
|
)}
|
||||||
|
<div className="shrink-0">
|
||||||
|
<PaginationCardDefault
|
||||||
|
page={page}
|
||||||
|
total={totalPages}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</TableCard.Root>
|
</TableCard.Root>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -91,14 +91,14 @@ export const CallRecordingsPage = () => {
|
|||||||
<>
|
<>
|
||||||
<TopBar title="Call Recordings" />
|
<TopBar title="Call Recordings" />
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
<div className="flex items-center justify-between border-b border-secondary px-6 py-3">
|
<div className="flex shrink-0 items-center justify-between border-b border-secondary px-6 py-3">
|
||||||
<span className="text-sm text-tertiary">{filtered.length} recordings</span>
|
<span className="text-sm text-tertiary">{filtered.length} recordings</span>
|
||||||
<div className="w-56">
|
<div className="w-56">
|
||||||
<Input placeholder="Search agent or phone..." icon={SearchLg} size="sm" value={search} onChange={setSearch} />
|
<Input placeholder="Search agent or phone..." icon={SearchLg} size="sm" value={search} onChange={setSearch} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-4 pt-3">
|
<div className="flex flex-1 flex-col overflow-hidden px-4 pt-3">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<p className="text-sm text-tertiary">Loading recordings...</p>
|
<p className="text-sm text-tertiary">Loading recordings...</p>
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export const MissedCallsPage = () => {
|
|||||||
<>
|
<>
|
||||||
<TopBar title="Missed Calls" />
|
<TopBar title="Missed Calls" />
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
<div className="flex items-end justify-between border-b border-secondary px-6 pt-3 pb-0.5">
|
<div className="flex shrink-0 items-end justify-between border-b border-secondary px-6 pt-3 pb-0.5">
|
||||||
<Tabs selectedKey={tab} onSelectionChange={(key) => setTab(key as StatusTab)}>
|
<Tabs selectedKey={tab} onSelectionChange={(key) => setTab(key as StatusTab)}>
|
||||||
<TabList items={tabItems} type="underline" size="sm">
|
<TabList items={tabItems} type="underline" size="sm">
|
||||||
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
|
{(item) => <Tab key={item.id} id={item.id} label={item.label} badge={item.badge} />}
|
||||||
@@ -120,7 +120,7 @@ export const MissedCallsPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto px-4 pt-3">
|
<div className="flex flex-1 flex-col overflow-hidden px-4 pt-3">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center py-12">
|
<div className="flex items-center justify-center py-12">
|
||||||
<p className="text-sm text-tertiary">Loading missed calls...</p>
|
<p className="text-sm text-tertiary">Loading missed calls...</p>
|
||||||
|
|||||||
24
src/pages/profile.tsx
Normal file
24
src/pages/profile.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { useAuth } from '@/providers/auth-provider';
|
||||||
|
import { TopBar } from '@/components/layout/top-bar';
|
||||||
|
import { Avatar } from '@/components/base/avatar/avatar';
|
||||||
|
|
||||||
|
export const ProfilePage = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<TopBar title="Profile" />
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-8">
|
||||||
|
<Avatar size="2xl" initials={user.initials} />
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-xl font-semibold text-primary">{user.name}</h2>
|
||||||
|
<p className="text-sm text-tertiary">{user.email}</p>
|
||||||
|
<p className="mt-1 text-xs text-quaternary capitalize">{user.role}</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 rounded-lg bg-secondary px-4 py-3 text-sm text-tertiary">
|
||||||
|
Profile management is coming soon.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user