@adatechnology/conversations-ui 0.1.0-rc.41 → 0.1.0-rc.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/flows/index.js +136 -23
- package/dist/index.d.ts +40 -1
- package/dist/index.js +45 -0
- package/package.json +1 -1
- package/src/ConversationRow.tsx +22 -0
- package/src/flows/FlowPalette.tsx +52 -3
- package/src/flows/FlowsWorkspace.tsx +38 -2
- package/src/flows/flowMenuPlacement.test.ts +130 -0
- package/src/flows/flowMenuPlacement.ts +86 -0
- package/src/index.ts +11 -0
- package/src/replyLatency.test.ts +71 -0
- package/src/replyLatency.ts +57 -0
package/dist/flows/index.js
CHANGED
|
@@ -858,8 +858,42 @@ function FlowPortalNode({ data }) {
|
|
|
858
858
|
var flowPortalNodeTypes = { flowPortal: FlowPortalNode };
|
|
859
859
|
|
|
860
860
|
// src/flows/FlowPalette.tsx
|
|
861
|
-
import { useEffect, useRef, useState as useState2 } from "react";
|
|
861
|
+
import { useEffect, useLayoutEffect, useRef, useState as useState2 } from "react";
|
|
862
862
|
import { Plus as Plus2, MessageCircleQuestion as MessageCircleQuestion2, GitBranch as GitBranch3, Zap as Zap2, Diamond as Diamond2, ChevronRight } from "lucide-react";
|
|
863
|
+
|
|
864
|
+
// src/flows/flowMenuPlacement.ts
|
|
865
|
+
var DEFAULT_GAP = 4;
|
|
866
|
+
var DEFAULT_MARGIN = 8;
|
|
867
|
+
function clamp(value, minimum, maximum) {
|
|
868
|
+
return Math.max(minimum, Math.min(value, Math.max(minimum, maximum)));
|
|
869
|
+
}
|
|
870
|
+
function placeFloatingPanel({
|
|
871
|
+
anchor,
|
|
872
|
+
panel,
|
|
873
|
+
viewport,
|
|
874
|
+
prefer,
|
|
875
|
+
gap = DEFAULT_GAP,
|
|
876
|
+
margin = DEFAULT_MARGIN
|
|
877
|
+
}) {
|
|
878
|
+
const maxHeight = Math.min(panel.height, viewport.height - margin * 2);
|
|
879
|
+
let left;
|
|
880
|
+
if (prefer === "side") {
|
|
881
|
+
const toTheRight = anchor.right + gap;
|
|
882
|
+
const toTheLeft = anchor.left - gap - panel.width;
|
|
883
|
+
left = toTheRight + panel.width <= viewport.width - margin ? toTheRight : toTheLeft;
|
|
884
|
+
} else {
|
|
885
|
+
left = anchor.left + gap;
|
|
886
|
+
}
|
|
887
|
+
left = clamp(left, margin, viewport.width - margin - panel.width);
|
|
888
|
+
const top = prefer === "side" ? anchor.top : anchor.bottom;
|
|
889
|
+
return {
|
|
890
|
+
left,
|
|
891
|
+
top: clamp(top, margin, viewport.height - margin - maxHeight),
|
|
892
|
+
maxHeight
|
|
893
|
+
};
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// src/flows/FlowPalette.tsx
|
|
863
897
|
import { Fragment, jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
|
|
864
898
|
var QUESTION_TYPES = ["text", "money", "date", "int", "cpf"];
|
|
865
899
|
function FlowPaletteMenu({ onSelect, labels, actionOptions }) {
|
|
@@ -867,6 +901,38 @@ function FlowPaletteMenu({ onSelect, labels, actionOptions }) {
|
|
|
867
901
|
{ actionKind: "handoff", label: labels.actionKindLabels.handoff ?? "Encaminhar para atendimento" }
|
|
868
902
|
];
|
|
869
903
|
const [submenu, setSubmenu] = useState2(null);
|
|
904
|
+
const questionTriggerRef = useRef(null);
|
|
905
|
+
const actionTriggerRef = useRef(null);
|
|
906
|
+
const submenuRef = useRef(null);
|
|
907
|
+
const [placement, setPlacement] = useState2(null);
|
|
908
|
+
useLayoutEffect(() => {
|
|
909
|
+
if (!submenu) {
|
|
910
|
+
setPlacement(null);
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
913
|
+
const trigger = submenu === "question" ? questionTriggerRef.current : actionTriggerRef.current;
|
|
914
|
+
const panel = submenuRef.current;
|
|
915
|
+
if (!trigger || !panel) return;
|
|
916
|
+
const anchor = trigger.getBoundingClientRect();
|
|
917
|
+
setPlacement(
|
|
918
|
+
placeFloatingPanel({
|
|
919
|
+
anchor: { left: anchor.left, top: anchor.top, right: anchor.right, bottom: anchor.bottom },
|
|
920
|
+
panel: { width: panel.offsetWidth, height: panel.scrollHeight },
|
|
921
|
+
viewport: { width: window.innerWidth, height: window.innerHeight },
|
|
922
|
+
prefer: "side"
|
|
923
|
+
})
|
|
924
|
+
);
|
|
925
|
+
}, [submenu]);
|
|
926
|
+
const submenuStyle = {
|
|
927
|
+
position: "fixed",
|
|
928
|
+
left: placement?.left ?? 0,
|
|
929
|
+
top: placement?.top ?? 0,
|
|
930
|
+
maxHeight: placement?.maxHeight,
|
|
931
|
+
overflowY: "auto",
|
|
932
|
+
// Até a medição terminar o painel existe mas não aparece — senão ele pisca um quadro na
|
|
933
|
+
// posição errada, que é justamente o salto que esta correção remove.
|
|
934
|
+
visibility: placement ? "visible" : "hidden"
|
|
935
|
+
};
|
|
870
936
|
function select(spec) {
|
|
871
937
|
onSelect(spec);
|
|
872
938
|
setSubmenu(null);
|
|
@@ -876,6 +942,7 @@ function FlowPaletteMenu({ onSelect, labels, actionOptions }) {
|
|
|
876
942
|
/* @__PURE__ */ jsxs6(
|
|
877
943
|
"button",
|
|
878
944
|
{
|
|
945
|
+
ref: questionTriggerRef,
|
|
879
946
|
"data-cv-tooltip": labels.palette.question,
|
|
880
947
|
"aria-label": labels.palette.question,
|
|
881
948
|
onMouseEnter: () => setSubmenu("question"),
|
|
@@ -891,17 +958,25 @@ function FlowPaletteMenu({ onSelect, labels, actionOptions }) {
|
|
|
891
958
|
]
|
|
892
959
|
}
|
|
893
960
|
),
|
|
894
|
-
submenu === "question" && /* @__PURE__ */ jsx7(
|
|
895
|
-
"
|
|
961
|
+
submenu === "question" && /* @__PURE__ */ jsx7(
|
|
962
|
+
"div",
|
|
896
963
|
{
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
964
|
+
ref: submenuRef,
|
|
965
|
+
style: submenuStyle,
|
|
966
|
+
className: "z-50 w-56 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1",
|
|
967
|
+
children: QUESTION_TYPES.map((qt) => /* @__PURE__ */ jsx7(
|
|
968
|
+
"button",
|
|
969
|
+
{
|
|
970
|
+
"data-cv-tooltip": labels.questionTypeLabels[qt],
|
|
971
|
+
"aria-label": labels.questionTypeLabels[qt],
|
|
972
|
+
onClick: () => select({ kind: "question", questionType: qt }),
|
|
973
|
+
className: "w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700",
|
|
974
|
+
children: labels.questionTypeLabels[qt]
|
|
975
|
+
},
|
|
976
|
+
qt
|
|
977
|
+
))
|
|
978
|
+
}
|
|
979
|
+
)
|
|
905
980
|
] }),
|
|
906
981
|
/* @__PURE__ */ jsxs6(
|
|
907
982
|
"button",
|
|
@@ -937,6 +1012,7 @@ function FlowPaletteMenu({ onSelect, labels, actionOptions }) {
|
|
|
937
1012
|
/* @__PURE__ */ jsxs6(
|
|
938
1013
|
"button",
|
|
939
1014
|
{
|
|
1015
|
+
ref: actionTriggerRef,
|
|
940
1016
|
"data-cv-tooltip": labels.palette.action,
|
|
941
1017
|
"aria-label": labels.palette.action,
|
|
942
1018
|
onMouseEnter: () => setSubmenu("action"),
|
|
@@ -952,17 +1028,25 @@ function FlowPaletteMenu({ onSelect, labels, actionOptions }) {
|
|
|
952
1028
|
]
|
|
953
1029
|
}
|
|
954
1030
|
),
|
|
955
|
-
submenu === "action" && /* @__PURE__ */ jsx7(
|
|
956
|
-
"
|
|
1031
|
+
submenu === "action" && /* @__PURE__ */ jsx7(
|
|
1032
|
+
"div",
|
|
957
1033
|
{
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
1034
|
+
ref: submenuRef,
|
|
1035
|
+
style: submenuStyle,
|
|
1036
|
+
className: "z-50 w-64 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1",
|
|
1037
|
+
children: resolvedActionOptions.map((option) => /* @__PURE__ */ jsx7(
|
|
1038
|
+
"button",
|
|
1039
|
+
{
|
|
1040
|
+
"data-cv-tooltip": option.label,
|
|
1041
|
+
"aria-label": option.label,
|
|
1042
|
+
onClick: () => select({ kind: "action", actionKind: option.actionKind }),
|
|
1043
|
+
className: "w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700",
|
|
1044
|
+
children: option.label
|
|
1045
|
+
},
|
|
1046
|
+
option.actionKind
|
|
1047
|
+
))
|
|
1048
|
+
}
|
|
1049
|
+
)
|
|
966
1050
|
] })
|
|
967
1051
|
] });
|
|
968
1052
|
}
|
|
@@ -1541,7 +1625,7 @@ function FlowNodePanel({
|
|
|
1541
1625
|
}
|
|
1542
1626
|
|
|
1543
1627
|
// src/flows/FlowsWorkspace.tsx
|
|
1544
|
-
import { useCallback, useEffect as useEffect3, useMemo as useMemo2, useRef as useRef3, useState as useState6 } from "react";
|
|
1628
|
+
import { useCallback, useEffect as useEffect3, useLayoutEffect as useLayoutEffect2, useMemo as useMemo2, useRef as useRef3, useState as useState6 } from "react";
|
|
1545
1629
|
import {
|
|
1546
1630
|
ReactFlow as ReactFlow2,
|
|
1547
1631
|
Background as Background2,
|
|
@@ -1872,6 +1956,7 @@ var EDGE_COLOR_LINEAR = "#94a3b8";
|
|
|
1872
1956
|
var FOCUS_MAX_ZOOM = 1;
|
|
1873
1957
|
var FOCUS_PADDING = 0.2;
|
|
1874
1958
|
var FOCUS_DURATION_MS = 400;
|
|
1959
|
+
var QUICK_ADD_MENU_GAP = 12;
|
|
1875
1960
|
var EDGE_COLOR_BRANCH = "#8b5cf6";
|
|
1876
1961
|
var EDGE_COLOR_FALLBACK = "#cbd5e1";
|
|
1877
1962
|
var EDGE_COLOR_LIVE = "#3b82f6";
|
|
@@ -1941,6 +2026,8 @@ function FlowsWorkspace({
|
|
|
1941
2026
|
const [flowInstance, setFlowInstance] = useState6(null);
|
|
1942
2027
|
const [pendingFocusNodeId, setPendingFocusNodeId] = useState6(null);
|
|
1943
2028
|
const [pendingFocusFlowKey, setPendingFocusFlowKey] = useState6(null);
|
|
2029
|
+
const quickAddMenuRef = useRef3(null);
|
|
2030
|
+
const [quickAddPlacement, setQuickAddPlacement] = useState6(null);
|
|
1944
2031
|
const reloadGraphs = useCallback(async () => {
|
|
1945
2032
|
try {
|
|
1946
2033
|
const loaded = await api.getGraphs();
|
|
@@ -2239,6 +2326,31 @@ function FlowsWorkspace({
|
|
|
2239
2326
|
});
|
|
2240
2327
|
setPendingFocusFlowKey(null);
|
|
2241
2328
|
}, [pendingFocusFlowKey, flowInstance, rfNodes]);
|
|
2329
|
+
useLayoutEffect2(() => {
|
|
2330
|
+
if (!quickAddFrom) {
|
|
2331
|
+
setQuickAddPlacement(null);
|
|
2332
|
+
return;
|
|
2333
|
+
}
|
|
2334
|
+
const panel = quickAddMenuRef.current;
|
|
2335
|
+
if (!panel) return;
|
|
2336
|
+
const { x, y } = quickAddFrom.anchor;
|
|
2337
|
+
setQuickAddPlacement(
|
|
2338
|
+
placeFloatingPanel({
|
|
2339
|
+
anchor: { left: x, top: y, right: x, bottom: y },
|
|
2340
|
+
panel: { width: panel.offsetWidth, height: panel.scrollHeight },
|
|
2341
|
+
viewport: { width: window.innerWidth, height: window.innerHeight },
|
|
2342
|
+
prefer: "below",
|
|
2343
|
+
gap: QUICK_ADD_MENU_GAP
|
|
2344
|
+
})
|
|
2345
|
+
);
|
|
2346
|
+
}, [quickAddFrom]);
|
|
2347
|
+
const quickAddMenuStyle = {
|
|
2348
|
+
left: quickAddPlacement?.left ?? 0,
|
|
2349
|
+
top: quickAddPlacement?.top ?? 0,
|
|
2350
|
+
maxHeight: quickAddPlacement?.maxHeight,
|
|
2351
|
+
overflowY: "auto",
|
|
2352
|
+
visibility: quickAddPlacement ? "visible" : "hidden"
|
|
2353
|
+
};
|
|
2242
2354
|
const onNodesChange = useCallback((changes) => {
|
|
2243
2355
|
setRfNodes((current) => applyNodeChanges(changes, current));
|
|
2244
2356
|
}, []);
|
|
@@ -2620,8 +2732,9 @@ function FlowsWorkspace({
|
|
|
2620
2732
|
/* @__PURE__ */ jsxs11(
|
|
2621
2733
|
"div",
|
|
2622
2734
|
{
|
|
2735
|
+
ref: quickAddMenuRef,
|
|
2623
2736
|
className: "fixed z-50 w-64 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1",
|
|
2624
|
-
style:
|
|
2737
|
+
style: quickAddMenuStyle,
|
|
2625
2738
|
children: [
|
|
2626
2739
|
/* @__PURE__ */ jsx12("p", { className: "px-3 py-1.5 text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500", children: labels.quickAdd.title }),
|
|
2627
2740
|
/* @__PURE__ */ jsx12(
|
package/dist/index.d.ts
CHANGED
|
@@ -545,6 +545,45 @@ type WindowOfParams = {
|
|
|
545
545
|
declare function windowOf(params: WindowOfParams): ConversationWindow;
|
|
546
546
|
declare function formatStalledFor(lastAt: string, now: number): string;
|
|
547
547
|
|
|
548
|
+
/**
|
|
549
|
+
* Copyright (c) 2026 Ada Technology. MIT License.
|
|
550
|
+
*
|
|
551
|
+
* Há quanto tempo o cliente espera resposta.
|
|
552
|
+
*
|
|
553
|
+
* Não confundir com `conversationWindow`: aquilo é a janela de sessão da plataforma (o que o canal
|
|
554
|
+
* ainda deixa enviar), isto é serviço (quanto o cliente esperou). Os dois divergem justamente no
|
|
555
|
+
* caso que interessa — respondida a conversa, o relógio do SLA para e o da janela continua correndo.
|
|
556
|
+
*
|
|
557
|
+
* A conta sai de dados que a listagem já traz: se a última mensagem foi do cliente, ninguém
|
|
558
|
+
* respondeu ainda e a espera conta desde ela. Se a última foi nossa, não há espera pendente.
|
|
559
|
+
*/
|
|
560
|
+
declare const REPLY_LATENCY: {
|
|
561
|
+
/** Até 6h — dentro do combinado. */
|
|
562
|
+
readonly WITHIN: "within";
|
|
563
|
+
/** 6h a 12h — passou do combinado. */
|
|
564
|
+
readonly LATE: "late";
|
|
565
|
+
/** Acima de 12h. */
|
|
566
|
+
readonly CRITICAL: "critical";
|
|
567
|
+
};
|
|
568
|
+
type ReplyLatency = (typeof REPLY_LATENCY)[keyof typeof REPLY_LATENCY];
|
|
569
|
+
declare const REPLY_LATENCY_LATE_HOURS = 6;
|
|
570
|
+
declare const REPLY_LATENCY_CRITICAL_HOURS = 12;
|
|
571
|
+
type ReplyLatencyParams = {
|
|
572
|
+
/** Direção da última mensagem. Ausente = desconhecida, e aí não se afirma espera. */
|
|
573
|
+
readonly lastDirection?: 'inbound' | 'outbound' | undefined;
|
|
574
|
+
readonly lastInboundAt: string | null;
|
|
575
|
+
readonly now: number;
|
|
576
|
+
};
|
|
577
|
+
/**
|
|
578
|
+
* `null` quando não há espera a mostrar — conversa já respondida, ou cliente que nunca escreveu.
|
|
579
|
+
*
|
|
580
|
+
* Devolver uma faixa nesses casos encheria a lista de selos em conversa que não deve nada, e o
|
|
581
|
+
* alerta que aponta para todo lado não aponta para lugar nenhum.
|
|
582
|
+
*/
|
|
583
|
+
declare function replyLatencyOf(params: ReplyLatencyParams): ReplyLatency | null;
|
|
584
|
+
/** Só o que passou do combinado merece alerta — é o corte que a lista usa para destacar. */
|
|
585
|
+
declare function isReplyOverdue(latency: ReplyLatency | null): boolean;
|
|
586
|
+
|
|
548
587
|
type ConversationRowClassNames = {
|
|
549
588
|
root: string;
|
|
550
589
|
windowBar: string;
|
|
@@ -1880,4 +1919,4 @@ interface ConversationsInboxListProps {
|
|
|
1880
1919
|
}
|
|
1881
1920
|
declare function ConversationsInboxList({ inbox, labels, className, renderFilters, renderBulkActions, renderRow, onSendTemplateToSelected, }: ConversationsInboxListProps): react.JSX.Element;
|
|
1882
1921
|
|
|
1883
|
-
export { type AsyncResourceState, AudioPlayer, type AudioPlayerProps, AudioTranscription, type AudioTranscriptionProps, Avatar, type AvatarLabels, type AvatarProps, type BotMessages, type BuildTranscriptTextParams, BulkActionBar, type BulkActionBarProps, CHANNEL_BRAND_COLOR, CONVERSATIONS_PER_PAGE, CONVERSATION_WINDOW, ChannelFilter, ChannelFilterOption, ChannelIcon, type ChannelIconProps, ConversationChannel, type ConversationContextEntry, ConversationContextPanel, type ConversationContextPanelClassNames, type ConversationContextPanelLabels, type ConversationContextPanelProps, type ConversationContextStatus, ConversationDocument, ConversationDocumentsPanel, type ConversationDocumentsPanelClassNames, type ConversationDocumentsPanelLabels, type ConversationDocumentsPanelProps, ConversationHeader, type ConversationHeaderClassNames, type ConversationHeaderLabels, type ConversationHeaderProps, type ConversationHeaderUtility, ConversationListItem, type ConversationListItemLabels, type ConversationListItemProps, type ConversationLocales, ConversationLocalesProvider, type ConversationLocalesProviderProps, ConversationPane, type ConversationPaneProps, type ConversationRealtimeHandler, ConversationRow, type ConversationRowClassNames, type ConversationRowProps, ConversationSimulatorClient, ConversationSummary, ConversationTemplate, ConversationWallpaper, type ConversationWallpaperProps, type ConversationWindow, ConversationsApi, ConversationsFeatures, ConversationsInboxList, type ConversationsInboxListProps, ConversationsProvider, ConversationsWorkspace, type ConversationsWorkspaceLabels, type ConversationsWorkspaceProps, type ConversationsWorkspaceSimulator, DEFAULT_ACCEPTED_FILE_TYPES, DEFAULT_AVATAR_LABELS, DEFAULT_CONVERSATIONS_WORKSPACE_LABELS, DEFAULT_CONVERSATION_CONTEXT_LABELS, DEFAULT_CONVERSATION_DOCUMENTS_LABELS, DEFAULT_CONVERSATION_HEADER_LABELS, DEFAULT_CONVERSATION_LIST_ITEM_LABELS, DEFAULT_DARK_MODE_TOGGLE_LABELS, DEFAULT_DOCUMENTS_LIBRARY_LABELS, DEFAULT_DOCUMENTS_WORKSPACE_LABELS, DEFAULT_EMOJI_PICKER_LABELS, DEFAULT_INTERACTIVE_MESSAGE_LABELS, DEFAULT_LIGHTBOX_LABELS, DEFAULT_MESSAGE_COMPOSER_LABELS, DEFAULT_RICH_COMPOSER_TOOLTIPS, DEFAULT_TEMPLATES_SETTINGS_LABELS, DEFAULT_WHATSAPP_MESSAGE_EDITOR_LABELS, DEFAULT_WINDOW_EXPIRED_LABELS, DOCUMENT_SOURCE_FILTER, DarkModeToggle, type DarkModeToggleLabels, type DarkModeToggleProps, DateDivider, type DateDividerClassNames, type DateDividerProps, type DocumentSourceFilter, type DocumentsFiltersContext, DocumentsLibrary, type DocumentsLibraryClassNames, type DocumentsLibraryLabels, type DocumentsLibraryProps, DocumentsWorkspace, type DocumentsWorkspaceClassNames, type DocumentsWorkspaceLabels, type DocumentsWorkspaceProps, EMOJI_CATEGORIES, type EmojiCategory, type EmojiEntry, EmojiPicker, type EmojiPickerLabels, type EmojiPickerProps, FileIcon, type FileIconProps, type FilterOption, InteractiveMessage, type InteractiveMessageLabels, type InteractiveMessageProps, InteractivePayload, InteractiveSelection, Lightbox, type LightboxLabels, type LightboxProps, ListConversationsParams, ListDocumentsParams, ListingPagination, type ListingPaginationProps, MessageBubble, type MessageBubbleProps, MessageComposer, type MessageComposerClassNames, type MessageComposerLabels, type MessageComposerProps, MessagePayload, MessageTail, MessageText, type MessageTextProps, MessageTimestamp, MessageTranscription, MessagesWorkspace, type MessagesWorkspaceApi, type MessagesWorkspaceLabels, type MessagesWorkspaceProps, type MessagesWorkspaceTemplateRole, MultiSelectFilter, type MultiSelectFilterProps, NARROW_MAX_WIDTH_PX, type QuickReply, RICH_COMPOSER_ACTION, ResolveMediaUrl, type RichComposerAction, type RichComposerQuickReply, type RichComposerTooltips, type RichComposerVariable, RichMessageComposer, type RichMessageComposerHandle, type RichMessageComposerProps, SSEProvider, SimpleEmojiPicker, type SimpleEmojiPickerProps, type SimulatorTransportFactory, type SimulatorTransportParams, type SortDirection, SortableHead, type SortableHeadProps, StatusTicks, type StatusTicksProps, TEMPLATE_SETTINGS_TAB, TOOLTIP_ATTRIBUTE, type TemplateSettings, type TemplateSettingsTab, ToastProvider, TooltipLayer, type TopicItem, TopicsForm, type TopicsFormLabels, type TopicsFormProps, TranscriptionMode, type TranscriptionSettings, TranscriptionSettingsForm, type TranscriptionSettingsFormLabels, type TranscriptionSettingsFormProps, type UrlStateOptions, type UseConversationActionsResult, type UseConversationContextResult, type UseConversationDocumentsParams, type UseConversationDocumentsResult, type UseConversationListParams, type UseConversationListResult, type UseConversationMessagesResult, type UseConversationsInboxParams, type UseConversationsInboxResult, type UseInboxActionsResult, type UseScrollToLatestMessageParams, type UseScrollToLatestMessageResult, type UseWaitingNotificationsLabels, type UseWaitingNotificationsParams, type UseWaitingNotificationsResult, WINDOW_FILTERS, WelcomeFarewellForm, type WelcomeFarewellFormLabels, type WelcomeFarewellFormProps, WhatsAppCreateTemplateForm, type WhatsAppCreateTemplateFormLabels, type WhatsAppCreateTemplateFormProps, type WhatsAppCreateTemplateResult, type WhatsAppCreateTemplateState, WhatsAppMessageEditor, type WhatsAppMessageEditorLabels, type WhatsAppMessageEditorProps, type WhatsAppTemplateHeaderType, WhatsAppTemplateSettingsForm, type WhatsAppTemplateSettingsFormLabels, type WhatsAppTemplateSettingsFormProps, type WhatsAppTemplateSummary, type WhatsAppTemplateVariableSuggestion, WhatsAppTemplatesSettings, type WhatsAppTemplatesSettingsLabels, type WhatsAppTemplatesSettingsProps, WindowExpiredNotice, type WindowExpiredNoticeLabels, type WindowExpiredNoticeProps, type WindowOfParams, applyQuickReplyVariables, buildTranscriptFilename, buildTranscriptText, createMediaUrlResolver, downloadTextFile, formatDateTime, formatFileSize, formatPhone, formatStalledFor, formatTimestamp, htmlToWA, isSameDay, isWindowBlocking, parseWhatsAppFormatting, phoneInitials, resolveQuickReply, searchEmojis, toast, useConversationActions, useConversationContext, useConversationDocuments, useConversationList, useConversationLocales, useConversationMessages, useConversationRealtime, useConversations, useConversationsInbox, useDarkMode, useDebouncedValue, useGlobalRealtime, useInboxActions, useIsDarkTheme, useIsNarrow, useScrollToLatestMessage, useToast, useUrlArrayState, useUrlNumberState, useUrlStringState, useWaitingNotifications, waToHTML, waToHTMLInline, windowOf };
|
|
1922
|
+
export { type AsyncResourceState, AudioPlayer, type AudioPlayerProps, AudioTranscription, type AudioTranscriptionProps, Avatar, type AvatarLabels, type AvatarProps, type BotMessages, type BuildTranscriptTextParams, BulkActionBar, type BulkActionBarProps, CHANNEL_BRAND_COLOR, CONVERSATIONS_PER_PAGE, CONVERSATION_WINDOW, ChannelFilter, ChannelFilterOption, ChannelIcon, type ChannelIconProps, ConversationChannel, type ConversationContextEntry, ConversationContextPanel, type ConversationContextPanelClassNames, type ConversationContextPanelLabels, type ConversationContextPanelProps, type ConversationContextStatus, ConversationDocument, ConversationDocumentsPanel, type ConversationDocumentsPanelClassNames, type ConversationDocumentsPanelLabels, type ConversationDocumentsPanelProps, ConversationHeader, type ConversationHeaderClassNames, type ConversationHeaderLabels, type ConversationHeaderProps, type ConversationHeaderUtility, ConversationListItem, type ConversationListItemLabels, type ConversationListItemProps, type ConversationLocales, ConversationLocalesProvider, type ConversationLocalesProviderProps, ConversationPane, type ConversationPaneProps, type ConversationRealtimeHandler, ConversationRow, type ConversationRowClassNames, type ConversationRowProps, ConversationSimulatorClient, ConversationSummary, ConversationTemplate, ConversationWallpaper, type ConversationWallpaperProps, type ConversationWindow, ConversationsApi, ConversationsFeatures, ConversationsInboxList, type ConversationsInboxListProps, ConversationsProvider, ConversationsWorkspace, type ConversationsWorkspaceLabels, type ConversationsWorkspaceProps, type ConversationsWorkspaceSimulator, DEFAULT_ACCEPTED_FILE_TYPES, DEFAULT_AVATAR_LABELS, DEFAULT_CONVERSATIONS_WORKSPACE_LABELS, DEFAULT_CONVERSATION_CONTEXT_LABELS, DEFAULT_CONVERSATION_DOCUMENTS_LABELS, DEFAULT_CONVERSATION_HEADER_LABELS, DEFAULT_CONVERSATION_LIST_ITEM_LABELS, DEFAULT_DARK_MODE_TOGGLE_LABELS, DEFAULT_DOCUMENTS_LIBRARY_LABELS, DEFAULT_DOCUMENTS_WORKSPACE_LABELS, DEFAULT_EMOJI_PICKER_LABELS, DEFAULT_INTERACTIVE_MESSAGE_LABELS, DEFAULT_LIGHTBOX_LABELS, DEFAULT_MESSAGE_COMPOSER_LABELS, DEFAULT_RICH_COMPOSER_TOOLTIPS, DEFAULT_TEMPLATES_SETTINGS_LABELS, DEFAULT_WHATSAPP_MESSAGE_EDITOR_LABELS, DEFAULT_WINDOW_EXPIRED_LABELS, DOCUMENT_SOURCE_FILTER, DarkModeToggle, type DarkModeToggleLabels, type DarkModeToggleProps, DateDivider, type DateDividerClassNames, type DateDividerProps, type DocumentSourceFilter, type DocumentsFiltersContext, DocumentsLibrary, type DocumentsLibraryClassNames, type DocumentsLibraryLabels, type DocumentsLibraryProps, DocumentsWorkspace, type DocumentsWorkspaceClassNames, type DocumentsWorkspaceLabels, type DocumentsWorkspaceProps, EMOJI_CATEGORIES, type EmojiCategory, type EmojiEntry, EmojiPicker, type EmojiPickerLabels, type EmojiPickerProps, FileIcon, type FileIconProps, type FilterOption, InteractiveMessage, type InteractiveMessageLabels, type InteractiveMessageProps, InteractivePayload, InteractiveSelection, Lightbox, type LightboxLabels, type LightboxProps, ListConversationsParams, ListDocumentsParams, ListingPagination, type ListingPaginationProps, MessageBubble, type MessageBubbleProps, MessageComposer, type MessageComposerClassNames, type MessageComposerLabels, type MessageComposerProps, MessagePayload, MessageTail, MessageText, type MessageTextProps, MessageTimestamp, MessageTranscription, MessagesWorkspace, type MessagesWorkspaceApi, type MessagesWorkspaceLabels, type MessagesWorkspaceProps, type MessagesWorkspaceTemplateRole, MultiSelectFilter, type MultiSelectFilterProps, NARROW_MAX_WIDTH_PX, type QuickReply, REPLY_LATENCY, REPLY_LATENCY_CRITICAL_HOURS, REPLY_LATENCY_LATE_HOURS, RICH_COMPOSER_ACTION, type ReplyLatency, type ReplyLatencyParams, ResolveMediaUrl, type RichComposerAction, type RichComposerQuickReply, type RichComposerTooltips, type RichComposerVariable, RichMessageComposer, type RichMessageComposerHandle, type RichMessageComposerProps, SSEProvider, SimpleEmojiPicker, type SimpleEmojiPickerProps, type SimulatorTransportFactory, type SimulatorTransportParams, type SortDirection, SortableHead, type SortableHeadProps, StatusTicks, type StatusTicksProps, TEMPLATE_SETTINGS_TAB, TOOLTIP_ATTRIBUTE, type TemplateSettings, type TemplateSettingsTab, ToastProvider, TooltipLayer, type TopicItem, TopicsForm, type TopicsFormLabels, type TopicsFormProps, TranscriptionMode, type TranscriptionSettings, TranscriptionSettingsForm, type TranscriptionSettingsFormLabels, type TranscriptionSettingsFormProps, type UrlStateOptions, type UseConversationActionsResult, type UseConversationContextResult, type UseConversationDocumentsParams, type UseConversationDocumentsResult, type UseConversationListParams, type UseConversationListResult, type UseConversationMessagesResult, type UseConversationsInboxParams, type UseConversationsInboxResult, type UseInboxActionsResult, type UseScrollToLatestMessageParams, type UseScrollToLatestMessageResult, type UseWaitingNotificationsLabels, type UseWaitingNotificationsParams, type UseWaitingNotificationsResult, WINDOW_FILTERS, WelcomeFarewellForm, type WelcomeFarewellFormLabels, type WelcomeFarewellFormProps, WhatsAppCreateTemplateForm, type WhatsAppCreateTemplateFormLabels, type WhatsAppCreateTemplateFormProps, type WhatsAppCreateTemplateResult, type WhatsAppCreateTemplateState, WhatsAppMessageEditor, type WhatsAppMessageEditorLabels, type WhatsAppMessageEditorProps, type WhatsAppTemplateHeaderType, WhatsAppTemplateSettingsForm, type WhatsAppTemplateSettingsFormLabels, type WhatsAppTemplateSettingsFormProps, type WhatsAppTemplateSummary, type WhatsAppTemplateVariableSuggestion, WhatsAppTemplatesSettings, type WhatsAppTemplatesSettingsLabels, type WhatsAppTemplatesSettingsProps, WindowExpiredNotice, type WindowExpiredNoticeLabels, type WindowExpiredNoticeProps, type WindowOfParams, applyQuickReplyVariables, buildTranscriptFilename, buildTranscriptText, createMediaUrlResolver, downloadTextFile, formatDateTime, formatFileSize, formatPhone, formatStalledFor, formatTimestamp, htmlToWA, isReplyOverdue, isSameDay, isWindowBlocking, parseWhatsAppFormatting, phoneInitials, replyLatencyOf, resolveQuickReply, searchEmojis, toast, useConversationActions, useConversationContext, useConversationDocuments, useConversationList, useConversationLocales, useConversationMessages, useConversationRealtime, useConversations, useConversationsInbox, useDarkMode, useDebouncedValue, useGlobalRealtime, useInboxActions, useIsDarkTheme, useIsNarrow, useScrollToLatestMessage, useToast, useUrlArrayState, useUrlNumberState, useUrlStringState, useWaitingNotifications, waToHTML, waToHTMLInline, windowOf };
|
package/dist/index.js
CHANGED
|
@@ -1170,6 +1170,30 @@ function formatStalledFor(lastAt, now) {
|
|
|
1170
1170
|
return `${minutes}m`;
|
|
1171
1171
|
}
|
|
1172
1172
|
|
|
1173
|
+
// src/replyLatency.ts
|
|
1174
|
+
var HOUR_MS2 = 60 * 60 * 1e3;
|
|
1175
|
+
var REPLY_LATENCY = {
|
|
1176
|
+
/** Até 6h — dentro do combinado. */
|
|
1177
|
+
WITHIN: "within",
|
|
1178
|
+
/** 6h a 12h — passou do combinado. */
|
|
1179
|
+
LATE: "late",
|
|
1180
|
+
/** Acima de 12h. */
|
|
1181
|
+
CRITICAL: "critical"
|
|
1182
|
+
};
|
|
1183
|
+
var REPLY_LATENCY_LATE_HOURS = 6;
|
|
1184
|
+
var REPLY_LATENCY_CRITICAL_HOURS = 12;
|
|
1185
|
+
function replyLatencyOf(params) {
|
|
1186
|
+
if (params.lastDirection !== "inbound") return null;
|
|
1187
|
+
if (!params.lastInboundAt) return null;
|
|
1188
|
+
const elapsedHours = (params.now - new Date(params.lastInboundAt).getTime()) / HOUR_MS2;
|
|
1189
|
+
if (elapsedHours < REPLY_LATENCY_LATE_HOURS) return REPLY_LATENCY.WITHIN;
|
|
1190
|
+
if (elapsedHours < REPLY_LATENCY_CRITICAL_HOURS) return REPLY_LATENCY.LATE;
|
|
1191
|
+
return REPLY_LATENCY.CRITICAL;
|
|
1192
|
+
}
|
|
1193
|
+
function isReplyOverdue(latency) {
|
|
1194
|
+
return latency === REPLY_LATENCY.LATE || latency === REPLY_LATENCY.CRITICAL;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1173
1197
|
// src/ConversationRow.tsx
|
|
1174
1198
|
import { AlarmClock, Bot, Hourglass, Play, UserRound } from "lucide-react";
|
|
1175
1199
|
|
|
@@ -1246,6 +1270,11 @@ var WINDOW_TITLE = {
|
|
|
1246
1270
|
[CONVERSATION_WINDOW.EXPIRED]: "Janela de 24h expirada \u2014 s\xF3 template"
|
|
1247
1271
|
};
|
|
1248
1272
|
var STALLED_THRESHOLD_MS = 60 * 60 * 1e3;
|
|
1273
|
+
var REPLY_LATENCY_PILL_CLASS = {
|
|
1274
|
+
[REPLY_LATENCY.WITHIN]: "cv-pill--success",
|
|
1275
|
+
[REPLY_LATENCY.LATE]: "cv-pill--warning",
|
|
1276
|
+
[REPLY_LATENCY.CRITICAL]: "cv-pill--danger"
|
|
1277
|
+
};
|
|
1249
1278
|
var TAKEOVER_LABEL = "Continuar Atendimento";
|
|
1250
1279
|
var WINDOW_BAR_CLASS = {
|
|
1251
1280
|
[CONVERSATION_WINDOW.ALL]: "bg-transparent",
|
|
@@ -1271,6 +1300,11 @@ function ConversationRow({
|
|
|
1271
1300
|
const stalledMs = now - new Date(conversation.lastAt).getTime();
|
|
1272
1301
|
const isStalled = stalledMs > STALLED_THRESHOLD_MS;
|
|
1273
1302
|
const isWaiting = conversation.mode === "bot" && conversation.waitingHuman;
|
|
1303
|
+
const replyLatency = replyLatencyOf({
|
|
1304
|
+
lastDirection: conversation.lastDirection,
|
|
1305
|
+
lastInboundAt: conversation.lastInboundAt,
|
|
1306
|
+
now
|
|
1307
|
+
});
|
|
1274
1308
|
return /* @__PURE__ */ jsxs9("div", { className: cn("cv-row flex border-b", active && "cv-row--active", classNames?.root, className), children: [
|
|
1275
1309
|
/* @__PURE__ */ jsx11(
|
|
1276
1310
|
"span",
|
|
@@ -1321,6 +1355,12 @@ function ConversationRow({
|
|
|
1321
1355
|
/* @__PURE__ */ jsx11(Bot, { size: ICON_SIZE_PILL, "aria-hidden": "true" }),
|
|
1322
1356
|
" bot ativo"
|
|
1323
1357
|
] }) : null,
|
|
1358
|
+
replyLatency ? /* @__PURE__ */ jsxs9("span", { className: cn("cv-pill", REPLY_LATENCY_PILL_CLASS[replyLatency]), children: [
|
|
1359
|
+
/* @__PURE__ */ jsx11(AlarmClock, { size: ICON_SIZE_PILL, "aria-hidden": "true" }),
|
|
1360
|
+
" sem resposta h\xE1",
|
|
1361
|
+
" ",
|
|
1362
|
+
formatStalledFor(conversation.lastInboundAt ?? conversation.lastAt, now)
|
|
1363
|
+
] }) : null,
|
|
1324
1364
|
isStalled ? /* @__PURE__ */ jsxs9("span", { className: "cv-pill cv-pill--danger", children: [
|
|
1325
1365
|
/* @__PURE__ */ jsx11(AlarmClock, { size: ICON_SIZE_PILL, "aria-hidden": "true" }),
|
|
1326
1366
|
" parada h\xE1",
|
|
@@ -5000,6 +5040,9 @@ export {
|
|
|
5000
5040
|
MultiSelectFilter,
|
|
5001
5041
|
NARROW_MAX_WIDTH_PX,
|
|
5002
5042
|
REOPEN_MECHANISM,
|
|
5043
|
+
REPLY_LATENCY,
|
|
5044
|
+
REPLY_LATENCY_CRITICAL_HOURS,
|
|
5045
|
+
REPLY_LATENCY_LATE_HOURS,
|
|
5003
5046
|
RICH_COMPOSER_ACTION,
|
|
5004
5047
|
RichMessageComposer,
|
|
5005
5048
|
SimpleEmojiPicker,
|
|
@@ -5033,10 +5076,12 @@ export {
|
|
|
5033
5076
|
formatStalledFor,
|
|
5034
5077
|
formatTimestamp,
|
|
5035
5078
|
htmlToWA,
|
|
5079
|
+
isReplyOverdue,
|
|
5036
5080
|
isSameDay,
|
|
5037
5081
|
isWindowBlocking,
|
|
5038
5082
|
parseWhatsAppFormatting,
|
|
5039
5083
|
phoneInitials,
|
|
5084
|
+
replyLatencyOf,
|
|
5040
5085
|
resolveQuickReply,
|
|
5041
5086
|
searchEmojis,
|
|
5042
5087
|
toast,
|
package/package.json
CHANGED
package/src/ConversationRow.tsx
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
windowOf,
|
|
22
22
|
type ConversationWindow,
|
|
23
23
|
} from './conversationWindow'
|
|
24
|
+
import { REPLY_LATENCY, replyLatencyOf } from './replyLatency'
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
const WINDOW_TITLE: Record<ConversationWindow, string> = {
|
|
@@ -34,6 +35,14 @@ const WINDOW_TITLE: Record<ConversationWindow, string> = {
|
|
|
34
35
|
// Abaixo disto a conversa é recente e o aviso de "parada" só faria ruído.
|
|
35
36
|
const STALLED_THRESHOLD_MS = 60 * 60 * 1000
|
|
36
37
|
|
|
38
|
+
// Verde só até 6h: a partir daí o selo perde a cor de "tudo certo", que é o sinal que o operador
|
|
39
|
+
// varre na lista. Crítico ganha vermelho para não se confundir com uma espera de 7h.
|
|
40
|
+
const REPLY_LATENCY_PILL_CLASS = {
|
|
41
|
+
[REPLY_LATENCY.WITHIN]: 'cv-pill--success',
|
|
42
|
+
[REPLY_LATENCY.LATE]: 'cv-pill--warning',
|
|
43
|
+
[REPLY_LATENCY.CRITICAL]: 'cv-pill--danger',
|
|
44
|
+
} as const
|
|
45
|
+
|
|
37
46
|
const TAKEOVER_LABEL = 'Continuar Atendimento'
|
|
38
47
|
|
|
39
48
|
const WINDOW_BAR_CLASS: Record<ConversationWindow, string> = {
|
|
@@ -81,6 +90,13 @@ export function ConversationRow({
|
|
|
81
90
|
// ao `waitingHuman` escondia justamente o caso ruim: conversa assumida e esquecida.
|
|
82
91
|
const isStalled = stalledMs > STALLED_THRESHOLD_MS
|
|
83
92
|
const isWaiting = conversation.mode === 'bot' && conversation.waitingHuman
|
|
93
|
+
// Espera do cliente por resposta — outra coisa que a janela de sessão acima: aqui o relógio para
|
|
94
|
+
// quando alguém responde. Ver `replyLatency.ts`.
|
|
95
|
+
const replyLatency = replyLatencyOf({
|
|
96
|
+
lastDirection: conversation.lastDirection,
|
|
97
|
+
lastInboundAt: conversation.lastInboundAt,
|
|
98
|
+
now,
|
|
99
|
+
})
|
|
84
100
|
|
|
85
101
|
// Realce e hover ficam na linha inteira: com eles no item, só o bloco dele ficava cinza enquanto
|
|
86
102
|
// checkbox, barra lateral e pills continuavam brancos — parecia meia linha selecionada.
|
|
@@ -137,6 +153,12 @@ export function ConversationRow({
|
|
|
137
153
|
<Bot size={ICON_SIZE_PILL} aria-hidden="true" /> bot ativo
|
|
138
154
|
</span>
|
|
139
155
|
) : null}
|
|
156
|
+
{replyLatency ? (
|
|
157
|
+
<span className={cn('cv-pill', REPLY_LATENCY_PILL_CLASS[replyLatency])}>
|
|
158
|
+
<AlarmClock size={ICON_SIZE_PILL} aria-hidden="true" /> sem resposta há{' '}
|
|
159
|
+
{formatStalledFor(conversation.lastInboundAt ?? conversation.lastAt, now)}
|
|
160
|
+
</span>
|
|
161
|
+
) : null}
|
|
140
162
|
{isStalled ? (
|
|
141
163
|
<span className="cv-pill cv-pill--danger">
|
|
142
164
|
<AlarmClock size={ICON_SIZE_PILL} aria-hidden="true" /> parada há{' '}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { useEffect, useRef, useState } from 'react'
|
|
1
|
+
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
2
2
|
import { Plus, MessageCircleQuestion, GitBranch, Zap, Diamond, ChevronRight } from 'lucide-react'
|
|
3
|
+
|
|
4
|
+
import { placeFloatingPanel, type FloatingPlacement } from './flowMenuPlacement'
|
|
3
5
|
import { DEFAULT_FLOW_EDITOR_LABELS, type FlowEditorLabels } from './labels'
|
|
4
6
|
import type { FlowActionKind, FlowQuestionType } from './flowGraph'
|
|
5
7
|
|
|
@@ -45,6 +47,43 @@ export function FlowPaletteMenu({ onSelect, labels, actionOptions }: FlowPalette
|
|
|
45
47
|
{ actionKind: 'handoff', label: labels.actionKindLabels.handoff ?? 'Encaminhar para atendimento' },
|
|
46
48
|
]
|
|
47
49
|
const [submenu, setSubmenu] = useState<'question' | 'action' | null>(null)
|
|
50
|
+
const questionTriggerRef = useRef<HTMLButtonElement>(null)
|
|
51
|
+
const actionTriggerRef = useRef<HTMLButtonElement>(null)
|
|
52
|
+
const submenuRef = useRef<HTMLDivElement>(null)
|
|
53
|
+
const [placement, setPlacement] = useState<FloatingPlacement | null>(null)
|
|
54
|
+
|
|
55
|
+
// `fixed` posicionado por medição, e não preso ao item por CSS: alinhado sempre à direita e ao
|
|
56
|
+
// topo do item, o submenu era cortado pela borda da tela — e sem rolagem os últimos itens ficavam
|
|
57
|
+
// inalcançáveis. Roda antes da pintura, então não pisca.
|
|
58
|
+
useLayoutEffect(() => {
|
|
59
|
+
if (!submenu) {
|
|
60
|
+
setPlacement(null)
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
const trigger = submenu === 'question' ? questionTriggerRef.current : actionTriggerRef.current
|
|
64
|
+
const panel = submenuRef.current
|
|
65
|
+
if (!trigger || !panel) return
|
|
66
|
+
const anchor = trigger.getBoundingClientRect()
|
|
67
|
+
setPlacement(
|
|
68
|
+
placeFloatingPanel({
|
|
69
|
+
anchor: { left: anchor.left, top: anchor.top, right: anchor.right, bottom: anchor.bottom },
|
|
70
|
+
panel: { width: panel.offsetWidth, height: panel.scrollHeight },
|
|
71
|
+
viewport: { width: window.innerWidth, height: window.innerHeight },
|
|
72
|
+
prefer: 'side',
|
|
73
|
+
}),
|
|
74
|
+
)
|
|
75
|
+
}, [submenu])
|
|
76
|
+
|
|
77
|
+
const submenuStyle = {
|
|
78
|
+
position: 'fixed' as const,
|
|
79
|
+
left: placement?.left ?? 0,
|
|
80
|
+
top: placement?.top ?? 0,
|
|
81
|
+
maxHeight: placement?.maxHeight,
|
|
82
|
+
overflowY: 'auto' as const,
|
|
83
|
+
// Até a medição terminar o painel existe mas não aparece — senão ele pisca um quadro na
|
|
84
|
+
// posição errada, que é justamente o salto que esta correção remove.
|
|
85
|
+
visibility: placement ? ('visible' as const) : ('hidden' as const),
|
|
86
|
+
}
|
|
48
87
|
|
|
49
88
|
function select(spec: NewNodeSpec) {
|
|
50
89
|
onSelect(spec)
|
|
@@ -55,6 +94,7 @@ export function FlowPaletteMenu({ onSelect, labels, actionOptions }: FlowPalette
|
|
|
55
94
|
<>
|
|
56
95
|
<div className="relative">
|
|
57
96
|
<button
|
|
97
|
+
ref={questionTriggerRef}
|
|
58
98
|
data-cv-tooltip={labels.palette.question} aria-label={labels.palette.question}
|
|
59
99
|
onMouseEnter={() => setSubmenu('question')}
|
|
60
100
|
onClick={() => setSubmenu(submenu === 'question' ? null : 'question')}
|
|
@@ -66,7 +106,11 @@ export function FlowPaletteMenu({ onSelect, labels, actionOptions }: FlowPalette
|
|
|
66
106
|
<ChevronRight size={13} className="text-gray-400" />
|
|
67
107
|
</button>
|
|
68
108
|
{submenu === 'question' && (
|
|
69
|
-
<div
|
|
109
|
+
<div
|
|
110
|
+
ref={submenuRef}
|
|
111
|
+
style={submenuStyle}
|
|
112
|
+
className="z-50 w-56 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1"
|
|
113
|
+
>
|
|
70
114
|
{QUESTION_TYPES.map((qt) => (
|
|
71
115
|
<button
|
|
72
116
|
data-cv-tooltip={labels.questionTypeLabels[qt]} aria-label={labels.questionTypeLabels[qt]}
|
|
@@ -101,6 +145,7 @@ export function FlowPaletteMenu({ onSelect, labels, actionOptions }: FlowPalette
|
|
|
101
145
|
|
|
102
146
|
<div className="relative">
|
|
103
147
|
<button
|
|
148
|
+
ref={actionTriggerRef}
|
|
104
149
|
data-cv-tooltip={labels.palette.action} aria-label={labels.palette.action}
|
|
105
150
|
onMouseEnter={() => setSubmenu('action')}
|
|
106
151
|
onClick={() => setSubmenu(submenu === 'action' ? null : 'action')}
|
|
@@ -112,7 +157,11 @@ export function FlowPaletteMenu({ onSelect, labels, actionOptions }: FlowPalette
|
|
|
112
157
|
<ChevronRight size={13} className="text-gray-400" />
|
|
113
158
|
</button>
|
|
114
159
|
{submenu === 'action' && (
|
|
115
|
-
<div
|
|
160
|
+
<div
|
|
161
|
+
ref={submenuRef}
|
|
162
|
+
style={submenuStyle}
|
|
163
|
+
className="z-50 w-64 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1"
|
|
164
|
+
>
|
|
116
165
|
{resolvedActionOptions.map((option) => (
|
|
117
166
|
<button
|
|
118
167
|
data-cv-tooltip={option.label} aria-label={option.label}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
1
|
+
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
|
2
2
|
import {
|
|
3
3
|
ReactFlow,
|
|
4
4
|
Background,
|
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
removeNodeAndCleanRefs,
|
|
52
52
|
resolveConnection,
|
|
53
53
|
} from './flowEditorOps'
|
|
54
|
+
import { placeFloatingPanel, type FloatingPlacement } from './flowMenuPlacement'
|
|
54
55
|
import {
|
|
55
56
|
computeAutoLayout,
|
|
56
57
|
targetsOf,
|
|
@@ -103,6 +104,9 @@ const EDGE_COLOR_LINEAR = '#94a3b8'
|
|
|
103
104
|
const FOCUS_MAX_ZOOM = 1
|
|
104
105
|
const FOCUS_PADDING = 0.2
|
|
105
106
|
const FOCUS_DURATION_MS = 400
|
|
107
|
+
|
|
108
|
+
/** Folga entre o "+" e o menu que ele abre. */
|
|
109
|
+
const QUICK_ADD_MENU_GAP = 12
|
|
106
110
|
const EDGE_COLOR_BRANCH = '#8b5cf6'
|
|
107
111
|
const EDGE_COLOR_FALLBACK = '#cbd5e1'
|
|
108
112
|
const EDGE_COLOR_LIVE = '#3b82f6'
|
|
@@ -268,6 +272,8 @@ export function FlowsWorkspace({
|
|
|
268
272
|
const [flowInstance, setFlowInstance] = useState<ReactFlowInstance | null>(null)
|
|
269
273
|
const [pendingFocusNodeId, setPendingFocusNodeId] = useState<string | null>(null)
|
|
270
274
|
const [pendingFocusFlowKey, setPendingFocusFlowKey] = useState<string | null>(null)
|
|
275
|
+
const quickAddMenuRef = useRef<HTMLDivElement>(null)
|
|
276
|
+
const [quickAddPlacement, setQuickAddPlacement] = useState<FloatingPlacement | null>(null)
|
|
271
277
|
|
|
272
278
|
const reloadGraphs = useCallback(async () => {
|
|
273
279
|
try {
|
|
@@ -663,6 +669,35 @@ export function FlowsWorkspace({
|
|
|
663
669
|
setPendingFocusFlowKey(null)
|
|
664
670
|
}, [pendingFocusFlowKey, flowInstance, rfNodes])
|
|
665
671
|
|
|
672
|
+
// O menu do "+" saía da tela quando o card estava perto da borda: a âncora era usada crua, sem
|
|
673
|
+
// consultar o tamanho da janela. Mede depois de montar e reposiciona antes da pintura.
|
|
674
|
+
useLayoutEffect(() => {
|
|
675
|
+
if (!quickAddFrom) {
|
|
676
|
+
setQuickAddPlacement(null)
|
|
677
|
+
return
|
|
678
|
+
}
|
|
679
|
+
const panel = quickAddMenuRef.current
|
|
680
|
+
if (!panel) return
|
|
681
|
+
const { x, y } = quickAddFrom.anchor
|
|
682
|
+
setQuickAddPlacement(
|
|
683
|
+
placeFloatingPanel({
|
|
684
|
+
anchor: { left: x, top: y, right: x, bottom: y },
|
|
685
|
+
panel: { width: panel.offsetWidth, height: panel.scrollHeight },
|
|
686
|
+
viewport: { width: window.innerWidth, height: window.innerHeight },
|
|
687
|
+
prefer: 'below',
|
|
688
|
+
gap: QUICK_ADD_MENU_GAP,
|
|
689
|
+
}),
|
|
690
|
+
)
|
|
691
|
+
}, [quickAddFrom])
|
|
692
|
+
|
|
693
|
+
const quickAddMenuStyle = {
|
|
694
|
+
left: quickAddPlacement?.left ?? 0,
|
|
695
|
+
top: quickAddPlacement?.top ?? 0,
|
|
696
|
+
maxHeight: quickAddPlacement?.maxHeight,
|
|
697
|
+
overflowY: 'auto' as const,
|
|
698
|
+
visibility: quickAddPlacement ? ('visible' as const) : ('hidden' as const),
|
|
699
|
+
}
|
|
700
|
+
|
|
666
701
|
const onNodesChange = useCallback((changes: NodeChange[]) => {
|
|
667
702
|
setRfNodes((current) => applyNodeChanges(changes, current))
|
|
668
703
|
}, [])
|
|
@@ -1071,8 +1106,9 @@ export function FlowsWorkspace({
|
|
|
1071
1106
|
<>
|
|
1072
1107
|
<div className="fixed inset-0 z-40" onClick={() => setQuickAddFrom(null)} />
|
|
1073
1108
|
<div
|
|
1109
|
+
ref={quickAddMenuRef}
|
|
1074
1110
|
className="fixed z-50 w-64 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-lg py-1"
|
|
1075
|
-
style={
|
|
1111
|
+
style={quickAddMenuStyle}
|
|
1076
1112
|
>
|
|
1077
1113
|
<p className="px-3 py-1.5 text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
|
|
1078
1114
|
{labels.quickAdd.title}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Ada Technology. MIT License.
|
|
3
|
+
*
|
|
4
|
+
* O caso que originou isto: o submenu "Ação" aberto perto do rodapé mostrava metade da lista, com
|
|
5
|
+
* "Enviar catálogo de produtos" cortado pela borda — e sem rolagem, o item era inalcançável.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, it } from 'bun:test'
|
|
9
|
+
|
|
10
|
+
import { placeFloatingPanel } from './flowMenuPlacement'
|
|
11
|
+
|
|
12
|
+
const VIEWPORT = { width: 1200, height: 800 }
|
|
13
|
+
const rect = (left: number, top: number, width = 0, height = 0) => ({
|
|
14
|
+
left,
|
|
15
|
+
top,
|
|
16
|
+
right: left + width,
|
|
17
|
+
bottom: top + height,
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('menu abaixo do ponto clicado', () => {
|
|
21
|
+
it('fica onde foi pedido quando há espaço', () => {
|
|
22
|
+
const placement = placeFloatingPanel({
|
|
23
|
+
anchor: rect(100, 100),
|
|
24
|
+
panel: { width: 256, height: 300 },
|
|
25
|
+
viewport: VIEWPORT,
|
|
26
|
+
prefer: 'below',
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
expect(placement.left).toBeGreaterThanOrEqual(100)
|
|
30
|
+
expect(placement.top).toBe(100)
|
|
31
|
+
expect(placement.maxHeight).toBe(300)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('encosta na borda direita em vez de sair dela', () => {
|
|
35
|
+
const placement = placeFloatingPanel({
|
|
36
|
+
anchor: rect(1150, 100),
|
|
37
|
+
panel: { width: 256, height: 300 },
|
|
38
|
+
viewport: VIEWPORT,
|
|
39
|
+
prefer: 'below',
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
expect(placement.left + 256).toBeLessThanOrEqual(VIEWPORT.width)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('sobe quando não cabe para baixo, em vez de vazar pelo rodapé', () => {
|
|
46
|
+
const placement = placeFloatingPanel({
|
|
47
|
+
anchor: rect(100, 700),
|
|
48
|
+
panel: { width: 256, height: 300 },
|
|
49
|
+
viewport: VIEWPORT,
|
|
50
|
+
prefer: 'below',
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
expect(placement.top + placement.maxHeight).toBeLessThanOrEqual(VIEWPORT.height)
|
|
54
|
+
expect(placement.top).toBeLessThan(700)
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('submenu ao lado do item', () => {
|
|
59
|
+
it('abre à direita quando cabe', () => {
|
|
60
|
+
const placement = placeFloatingPanel({
|
|
61
|
+
anchor: rect(300, 200, 256, 36),
|
|
62
|
+
panel: { width: 256, height: 240 },
|
|
63
|
+
viewport: VIEWPORT,
|
|
64
|
+
prefer: 'side',
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
expect(placement.left).toBeGreaterThanOrEqual(300 + 256)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('vira para a esquerda quando não cabe à direita', () => {
|
|
71
|
+
const placement = placeFloatingPanel({
|
|
72
|
+
anchor: rect(900, 200, 256, 36),
|
|
73
|
+
panel: { width: 256, height: 240 },
|
|
74
|
+
viewport: VIEWPORT,
|
|
75
|
+
prefer: 'side',
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
expect(placement.left).toBeLessThan(900)
|
|
79
|
+
expect(placement.left).toBeGreaterThanOrEqual(0)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('lista longa perto do rodapé ganha rolagem em vez de ser cortada', () => {
|
|
83
|
+
// O caso da captura: submenu de ações mais alto que o espaço abaixo do item.
|
|
84
|
+
const placement = placeFloatingPanel({
|
|
85
|
+
anchor: rect(300, 620, 256, 36),
|
|
86
|
+
panel: { width: 256, height: 520 },
|
|
87
|
+
viewport: VIEWPORT,
|
|
88
|
+
prefer: 'side',
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
expect(placement.top + placement.maxHeight).toBeLessThanOrEqual(VIEWPORT.height)
|
|
92
|
+
expect(placement.maxHeight).toBeLessThanOrEqual(520)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('painel mais alto que a tela cabe inteiro na tela, rolando por dentro', () => {
|
|
96
|
+
const placement = placeFloatingPanel({
|
|
97
|
+
anchor: rect(300, 400, 256, 36),
|
|
98
|
+
panel: { width: 256, height: 2000 },
|
|
99
|
+
viewport: VIEWPORT,
|
|
100
|
+
prefer: 'side',
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
expect(placement.top).toBeGreaterThanOrEqual(0)
|
|
104
|
+
expect(placement.maxHeight).toBeLessThan(VIEWPORT.height)
|
|
105
|
+
expect(placement.top + placement.maxHeight).toBeLessThanOrEqual(VIEWPORT.height)
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
describe('os menus do editor usam a conta acima', () => {
|
|
110
|
+
/**
|
|
111
|
+
* A função pura pode estar correta e ninguém chamá-la. Estes dois amarram o uso: sem eles a
|
|
112
|
+
* correção some no primeiro refactor e o menu volta a encostar na borda.
|
|
113
|
+
*/
|
|
114
|
+
it('o menu do "+" mede antes de posicionar, em vez de usar a âncora crua', async () => {
|
|
115
|
+
const content = await Bun.file(`${import.meta.dir}/FlowsWorkspace.tsx`).text()
|
|
116
|
+
|
|
117
|
+
expect(content).toContain('placeFloatingPanel')
|
|
118
|
+
expect(content).not.toContain('left: quickAddFrom.anchor.x + 12')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('o submenu não fica mais preso ao item por CSS', async () => {
|
|
122
|
+
const content = await Bun.file(`${import.meta.dir}/FlowPalette.tsx`).text()
|
|
123
|
+
|
|
124
|
+
expect(content).toContain('placeFloatingPanel')
|
|
125
|
+
// Verifica o uso em JSX, não a menção: o comentário do arquivo cita a classe antiga para
|
|
126
|
+
// explicar o que mudou, e casar com o texto solto reprovaria a própria explicação.
|
|
127
|
+
expect(content).not.toContain('className="absolute left-full')
|
|
128
|
+
expect(content).toContain('overflowY')
|
|
129
|
+
})
|
|
130
|
+
})
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Ada Technology. MIT License.
|
|
3
|
+
*
|
|
4
|
+
* Onde um menu flutuante do editor pode aparecer sem sair da tela.
|
|
5
|
+
*
|
|
6
|
+
* Existe porque tanto o menu do "+" quanto os submenus dele eram posicionados por uma âncora crua:
|
|
7
|
+
* o menu em `fixed` na coordenada do clique, os submenus em `left-full top-0`. Card perto da borda
|
|
8
|
+
* direita jogava o menu para fora; item de ação perto do rodapé cortava a lista no meio, e não havia
|
|
9
|
+
* como rolar nem alcançar o resto — o menu ficava preso contra a borda.
|
|
10
|
+
*
|
|
11
|
+
* A conta é pura de propósito: é ela que decide se o operador consegue clicar na opção, e testá-la
|
|
12
|
+
* exige apenas números.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Retângulo do gatilho. Um clique é um retângulo de tamanho zero. */
|
|
16
|
+
export type AnchorRect = {
|
|
17
|
+
readonly left: number
|
|
18
|
+
readonly top: number
|
|
19
|
+
readonly right: number
|
|
20
|
+
readonly bottom: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type PanelSize = {
|
|
24
|
+
readonly width: number
|
|
25
|
+
readonly height: number
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type ViewportSize = {
|
|
29
|
+
readonly width: number
|
|
30
|
+
readonly height: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type FloatingPlacement = {
|
|
34
|
+
readonly left: number
|
|
35
|
+
readonly top: number
|
|
36
|
+
/** Teto de altura: acima disso o painel rola por dentro em vez de vazar pelo rodapé. */
|
|
37
|
+
readonly maxHeight: number
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type PlaceFloatingPanelParams = {
|
|
41
|
+
readonly anchor: AnchorRect
|
|
42
|
+
readonly panel: PanelSize
|
|
43
|
+
readonly viewport: ViewportSize
|
|
44
|
+
/** `side`: submenu sai ao lado do item; `below`: menu sai abaixo do ponto clicado. */
|
|
45
|
+
readonly prefer: 'side' | 'below'
|
|
46
|
+
readonly gap?: number
|
|
47
|
+
readonly margin?: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const DEFAULT_GAP = 4
|
|
51
|
+
const DEFAULT_MARGIN = 8
|
|
52
|
+
|
|
53
|
+
function clamp(value: number, minimum: number, maximum: number): number {
|
|
54
|
+
// `maximum` menor que `minimum` acontece com painel maior que a viewport: a margem de cima manda,
|
|
55
|
+
// porque cortar em cima esconde o começo da lista.
|
|
56
|
+
return Math.max(minimum, Math.min(value, Math.max(minimum, maximum)))
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function placeFloatingPanel({
|
|
60
|
+
anchor,
|
|
61
|
+
panel,
|
|
62
|
+
viewport,
|
|
63
|
+
prefer,
|
|
64
|
+
gap = DEFAULT_GAP,
|
|
65
|
+
margin = DEFAULT_MARGIN,
|
|
66
|
+
}: PlaceFloatingPanelParams): FloatingPlacement {
|
|
67
|
+
const maxHeight = Math.min(panel.height, viewport.height - margin * 2)
|
|
68
|
+
|
|
69
|
+
let left: number
|
|
70
|
+
if (prefer === 'side') {
|
|
71
|
+
// Vira para a esquerda quando não cabe à direita — o inverso de escolher sempre um lado.
|
|
72
|
+
const toTheRight = anchor.right + gap
|
|
73
|
+
const toTheLeft = anchor.left - gap - panel.width
|
|
74
|
+
left = toTheRight + panel.width <= viewport.width - margin ? toTheRight : toTheLeft
|
|
75
|
+
} else {
|
|
76
|
+
left = anchor.left + gap
|
|
77
|
+
}
|
|
78
|
+
left = clamp(left, margin, viewport.width - margin - panel.width)
|
|
79
|
+
|
|
80
|
+
const top = prefer === 'side' ? anchor.top : anchor.bottom
|
|
81
|
+
return {
|
|
82
|
+
left,
|
|
83
|
+
top: clamp(top, margin, viewport.height - margin - maxHeight),
|
|
84
|
+
maxHeight,
|
|
85
|
+
}
|
|
86
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -42,6 +42,17 @@ export { MessageTail } from './MessageTail'
|
|
|
42
42
|
// plataforma e do ofício de atender, não de um produto — por isso moram aqui.
|
|
43
43
|
export { CONVERSATION_WINDOW, WINDOW_FILTERS, windowOf, formatStalledFor } from './conversationWindow'
|
|
44
44
|
export type { WindowOfParams } from './conversationWindow'
|
|
45
|
+
|
|
46
|
+
// Tempo sem resposta (SLA), distinto da janela de sessão acima: o host usa isto para o alerta e
|
|
47
|
+
// para filtrar quem está esperando demais.
|
|
48
|
+
export {
|
|
49
|
+
REPLY_LATENCY,
|
|
50
|
+
REPLY_LATENCY_LATE_HOURS,
|
|
51
|
+
REPLY_LATENCY_CRITICAL_HOURS,
|
|
52
|
+
replyLatencyOf,
|
|
53
|
+
isReplyOverdue,
|
|
54
|
+
} from './replyLatency'
|
|
55
|
+
export type { ReplyLatency, ReplyLatencyParams } from './replyLatency'
|
|
45
56
|
// Canal de origem: capacidades por plataforma (janela de sessão, reabertura, tipo de identificador).
|
|
46
57
|
export {
|
|
47
58
|
CONVERSATION_CHANNEL,
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Ada Technology. MIT License.
|
|
3
|
+
*
|
|
4
|
+
* A distinção que estes testes protegem: janela de sessão e tempo sem resposta medem coisas
|
|
5
|
+
* diferentes, e reaproveitar `windowOf` para o SLA marcaria como atrasada toda conversa já
|
|
6
|
+
* respondida — o alerta apontaria para tudo, ou seja, para nada.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from 'bun:test'
|
|
10
|
+
|
|
11
|
+
import { REPLY_LATENCY, isReplyOverdue, replyLatencyOf } from './replyLatency'
|
|
12
|
+
|
|
13
|
+
const NOW = new Date('2026-08-29T12:00:00Z').getTime()
|
|
14
|
+
const hoursAgo = (hours: number) => new Date(NOW - hours * 60 * 60 * 1000).toISOString()
|
|
15
|
+
|
|
16
|
+
describe('faixas de espera', () => {
|
|
17
|
+
it('até 6h está dentro do combinado', () => {
|
|
18
|
+
expect(replyLatencyOf({ lastDirection: 'inbound', lastInboundAt: hoursAgo(0), now: NOW })).toBe(
|
|
19
|
+
REPLY_LATENCY.WITHIN,
|
|
20
|
+
)
|
|
21
|
+
expect(replyLatencyOf({ lastDirection: 'inbound', lastInboundAt: hoursAgo(5.9), now: NOW })).toBe(
|
|
22
|
+
REPLY_LATENCY.WITHIN,
|
|
23
|
+
)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('de 6h a 12h já passou do combinado', () => {
|
|
27
|
+
expect(replyLatencyOf({ lastDirection: 'inbound', lastInboundAt: hoursAgo(6), now: NOW })).toBe(REPLY_LATENCY.LATE)
|
|
28
|
+
expect(replyLatencyOf({ lastDirection: 'inbound', lastInboundAt: hoursAgo(11.9), now: NOW })).toBe(
|
|
29
|
+
REPLY_LATENCY.LATE,
|
|
30
|
+
)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('acima de 12h é crítico — não se confunde com uma espera de 7h', () => {
|
|
34
|
+
expect(replyLatencyOf({ lastDirection: 'inbound', lastInboundAt: hoursAgo(12), now: NOW })).toBe(
|
|
35
|
+
REPLY_LATENCY.CRITICAL,
|
|
36
|
+
)
|
|
37
|
+
expect(replyLatencyOf({ lastDirection: 'inbound', lastInboundAt: hoursAgo(72), now: NOW })).toBe(
|
|
38
|
+
REPLY_LATENCY.CRITICAL,
|
|
39
|
+
)
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('quando não há espera a mostrar', () => {
|
|
44
|
+
it('conversa já respondida não tem selo, por mais antiga que seja', () => {
|
|
45
|
+
// É aqui que o SLA se separa da janela de sessão: `windowOf` marcaria isto como crítico.
|
|
46
|
+
expect(replyLatencyOf({ lastDirection: 'outbound', lastInboundAt: hoursAgo(20), now: NOW })).toBeNull()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('cliente que nunca escreveu não tem espera', () => {
|
|
50
|
+
expect(replyLatencyOf({ lastDirection: 'inbound', lastInboundAt: null, now: NOW })).toBeNull()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('direção desconhecida não afirma espera', () => {
|
|
54
|
+
expect(replyLatencyOf({ lastInboundAt: hoursAgo(20), now: NOW })).toBeNull()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('relógio adiantado não vira conversa crítica', () => {
|
|
58
|
+
expect(replyLatencyOf({ lastDirection: 'inbound', lastInboundAt: hoursAgo(-0.05), now: NOW })).toBe(
|
|
59
|
+
REPLY_LATENCY.WITHIN,
|
|
60
|
+
)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('o que merece alerta', () => {
|
|
65
|
+
it('só o que passou de 6h', () => {
|
|
66
|
+
expect(isReplyOverdue(REPLY_LATENCY.WITHIN)).toBe(false)
|
|
67
|
+
expect(isReplyOverdue(REPLY_LATENCY.LATE)).toBe(true)
|
|
68
|
+
expect(isReplyOverdue(REPLY_LATENCY.CRITICAL)).toBe(true)
|
|
69
|
+
expect(isReplyOverdue(null)).toBe(false)
|
|
70
|
+
})
|
|
71
|
+
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Ada Technology. MIT License.
|
|
3
|
+
*
|
|
4
|
+
* Há quanto tempo o cliente espera resposta.
|
|
5
|
+
*
|
|
6
|
+
* Não confundir com `conversationWindow`: aquilo é a janela de sessão da plataforma (o que o canal
|
|
7
|
+
* ainda deixa enviar), isto é serviço (quanto o cliente esperou). Os dois divergem justamente no
|
|
8
|
+
* caso que interessa — respondida a conversa, o relógio do SLA para e o da janela continua correndo.
|
|
9
|
+
*
|
|
10
|
+
* A conta sai de dados que a listagem já traz: se a última mensagem foi do cliente, ninguém
|
|
11
|
+
* respondeu ainda e a espera conta desde ela. Se a última foi nossa, não há espera pendente.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const HOUR_MS = 60 * 60 * 1000
|
|
15
|
+
|
|
16
|
+
export const REPLY_LATENCY = {
|
|
17
|
+
/** Até 6h — dentro do combinado. */
|
|
18
|
+
WITHIN: 'within',
|
|
19
|
+
/** 6h a 12h — passou do combinado. */
|
|
20
|
+
LATE: 'late',
|
|
21
|
+
/** Acima de 12h. */
|
|
22
|
+
CRITICAL: 'critical',
|
|
23
|
+
} as const
|
|
24
|
+
export type ReplyLatency = (typeof REPLY_LATENCY)[keyof typeof REPLY_LATENCY]
|
|
25
|
+
|
|
26
|
+
export const REPLY_LATENCY_LATE_HOURS = 6
|
|
27
|
+
export const REPLY_LATENCY_CRITICAL_HOURS = 12
|
|
28
|
+
|
|
29
|
+
export type ReplyLatencyParams = {
|
|
30
|
+
/** Direção da última mensagem. Ausente = desconhecida, e aí não se afirma espera. */
|
|
31
|
+
readonly lastDirection?: 'inbound' | 'outbound' | undefined
|
|
32
|
+
readonly lastInboundAt: string | null
|
|
33
|
+
readonly now: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `null` quando não há espera a mostrar — conversa já respondida, ou cliente que nunca escreveu.
|
|
38
|
+
*
|
|
39
|
+
* Devolver uma faixa nesses casos encheria a lista de selos em conversa que não deve nada, e o
|
|
40
|
+
* alerta que aponta para todo lado não aponta para lugar nenhum.
|
|
41
|
+
*/
|
|
42
|
+
export function replyLatencyOf(params: ReplyLatencyParams): ReplyLatency | null {
|
|
43
|
+
if (params.lastDirection !== 'inbound') return null
|
|
44
|
+
if (!params.lastInboundAt) return null
|
|
45
|
+
|
|
46
|
+
const elapsedHours = (params.now - new Date(params.lastInboundAt).getTime()) / HOUR_MS
|
|
47
|
+
// Espera negativa é relógio fora de sincronia entre servidor e navegador, não conversa do futuro:
|
|
48
|
+
// tratar como recém-chegada evita um selo "crítico" nascido de alguns segundos de diferença.
|
|
49
|
+
if (elapsedHours < REPLY_LATENCY_LATE_HOURS) return REPLY_LATENCY.WITHIN
|
|
50
|
+
if (elapsedHours < REPLY_LATENCY_CRITICAL_HOURS) return REPLY_LATENCY.LATE
|
|
51
|
+
return REPLY_LATENCY.CRITICAL
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Só o que passou do combinado merece alerta — é o corte que a lista usa para destacar. */
|
|
55
|
+
export function isReplyOverdue(latency: ReplyLatency | null): boolean {
|
|
56
|
+
return latency === REPLY_LATENCY.LATE || latency === REPLY_LATENCY.CRITICAL
|
|
57
|
+
}
|