- Remove bulk CommunicationsQuery from useContacts (was loading ALL messages for ALL contacts on init) - Rebuild commThreads from contacts + contactInboxes using the new lastMessageText field from Phase 1 - Per-contact messages now load on-demand via getClientTimeline - Remove commItems from useWorkspaceRouting, use clientTimelineItems Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2511 lines
101 KiB
Vue
2511 lines
101 KiB
Vue
<script setup lang="ts">
|
||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch, watchEffect } from "vue";
|
||
import CrmAuthLoading from "~~/app/components/workspace/auth/CrmAuthLoading.vue";
|
||
import CrmCalendarPanel from "~~/app/components/workspace/calendar/CrmCalendarPanel.vue";
|
||
import CrmCommunicationsContextSidebar from "~~/app/components/workspace/communications/CrmCommunicationsContextSidebar.vue";
|
||
import CrmCommunicationsListSidebar from "~~/app/components/workspace/communications/CrmCommunicationsListSidebar.vue";
|
||
import CrmVoiceDictationButton from "~~/app/components/workspace/communications/CrmVoiceDictationButton.client.vue";
|
||
import CrmDocumentsPanel from "~~/app/components/workspace/documents/CrmDocumentsPanel.vue";
|
||
import CrmWorkspaceTopbar from "~~/app/components/workspace/header/CrmWorkspaceTopbar.vue";
|
||
import CrmPilotSidebar from "~~/app/components/workspace/pilot/CrmPilotSidebar.vue";
|
||
import CrmChangeReviewOverlay from "~~/app/components/workspace/review/CrmChangeReviewOverlay.vue";
|
||
import {
|
||
buildContactDocumentScope,
|
||
formatDocumentScope,
|
||
isDocumentLinkedToContact,
|
||
} from "~~/app/composables/useWorkspaceDocuments";
|
||
import { isVoiceCaptureSupported } from "~~/app/composables/useVoiceTranscription";
|
||
|
||
// Composables
|
||
import { useAuth } from "~~/app/composables/useAuth";
|
||
import { useContacts } from "~~/app/composables/useContacts";
|
||
import { useContactInboxes } from "~~/app/composables/useContactInboxes";
|
||
import { useCalendar } from "~~/app/composables/useCalendar";
|
||
import { useDeals } from "~~/app/composables/useDeals";
|
||
import { useDocuments } from "~~/app/composables/useDocuments";
|
||
import { useFeed } from "~~/app/composables/useFeed";
|
||
import { useTimeline } from "~~/app/composables/useTimeline";
|
||
import { usePilotChat } from "~~/app/composables/usePilotChat";
|
||
import { useCallAudio } from "~~/app/composables/useCallAudio";
|
||
import { usePins } from "~~/app/composables/usePins";
|
||
import { useChangeReview } from "~~/app/composables/useChangeReview";
|
||
import { useCrmRealtime } from "~~/app/composables/useCrmRealtime";
|
||
import { useWorkspaceRouting } from "~~/app/composables/useWorkspaceRouting";
|
||
|
||
// Types from composables
|
||
import type { Contact, CommItem } from "~~/app/composables/useContacts";
|
||
import type { ContactInbox } from "~~/app/composables/useContactInboxes";
|
||
import type {
|
||
CalendarView,
|
||
CalendarEvent,
|
||
EventLifecyclePhase,
|
||
} from "~~/app/composables/useCalendar";
|
||
import {
|
||
dayKey,
|
||
formatDay,
|
||
formatTime,
|
||
formatThreadTime,
|
||
formatStamp,
|
||
eventLifecyclePhase,
|
||
isEventFinalStatus,
|
||
eventRelativeLabel,
|
||
eventPhaseToneClass,
|
||
} from "~~/app/composables/useCalendar";
|
||
import type { Deal, DealStep } from "~~/app/composables/useDeals";
|
||
import type { WorkspaceDocument } from "~~/app/composables/useDocuments";
|
||
import type { FeedCard } from "~~/app/composables/useFeed";
|
||
import type {
|
||
PilotMessage,
|
||
PilotChangeItem,
|
||
ContextScope,
|
||
} from "~~/app/composables/usePilotChat";
|
||
import type { TabId, PeopleLeftMode } from "~~/app/composables/useWorkspaceRouting";
|
||
|
||
function safeTrim(value: unknown) { return String(value ?? "").trim(); }
|
||
|
||
type PeopleSortMode = "name" | "lastContact";
|
||
type PeopleVisibilityMode = "all" | "hidden";
|
||
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 1. Auth
|
||
// ---------------------------------------------------------------------------
|
||
const {
|
||
authMe,
|
||
authResolved,
|
||
apolloAuthReady,
|
||
meLoading,
|
||
loadMe,
|
||
logout: authLogout,
|
||
telegramConnectStatus,
|
||
telegramConnectStatusLoading,
|
||
telegramConnectBusy,
|
||
telegramConnectUrl,
|
||
telegramConnections,
|
||
telegramConnectNotice,
|
||
telegramStatusLabel,
|
||
telegramStatusBadgeClass,
|
||
loadTelegramConnectStatus,
|
||
startTelegramBusinessConnect,
|
||
completeTelegramBusinessConnectFromToken,
|
||
} = useAuth();
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 2. Contacts
|
||
// ---------------------------------------------------------------------------
|
||
const {
|
||
contacts,
|
||
contactSearch,
|
||
selectedChannel,
|
||
sortMode,
|
||
selectedContactId,
|
||
selectedContact,
|
||
filteredContacts,
|
||
groupedContacts,
|
||
channels,
|
||
resetContactFilters,
|
||
brokenAvatarByContactId,
|
||
avatarSrcForThread,
|
||
markAvatarBroken,
|
||
contactInitials,
|
||
refetchContacts,
|
||
} = useContacts({ apolloAuthReady });
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 3. Contact Inboxes
|
||
// ---------------------------------------------------------------------------
|
||
const {
|
||
contactInboxes,
|
||
inboxToggleLoadingById,
|
||
setInboxHidden,
|
||
isInboxToggleLoading,
|
||
threadInboxes,
|
||
formatInboxLabel,
|
||
refetchContactInboxes,
|
||
} = useContactInboxes({ apolloAuthReady });
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 4. Calendar
|
||
// ---------------------------------------------------------------------------
|
||
const calendar = useCalendar({ apolloAuthReady });
|
||
const {
|
||
calendarEvents,
|
||
calendarView,
|
||
calendarCursor,
|
||
selectedDateKey,
|
||
focusedCalendarEventId,
|
||
lifecycleNowMs,
|
||
sortedEvents,
|
||
focusedCalendarEvent,
|
||
eventsByDate,
|
||
getEventsByDate,
|
||
monthLabel,
|
||
calendarViewOptions,
|
||
monthCells,
|
||
monthRows,
|
||
monthCellHasFocusedEvent,
|
||
monthCellEvents,
|
||
weekDays,
|
||
calendarPeriodLabel,
|
||
yearMonths,
|
||
selectedDayEvents,
|
||
calendarContentWrapRef,
|
||
calendarContentScrollRef,
|
||
calendarSceneRef,
|
||
calendarViewportHeight,
|
||
calendarHoveredMonthIndex,
|
||
calendarHoveredWeekStartKey,
|
||
calendarHoveredDayKey,
|
||
calendarFlyRectRef,
|
||
calendarFlyVisible,
|
||
calendarFlyLabelRef,
|
||
calendarFlyLabelVisible,
|
||
calendarToolbarLabelRef,
|
||
calendarZoomBusy,
|
||
calendarZoomPrimeToken,
|
||
normalizedCalendarView,
|
||
calendarZoomLevelIndex,
|
||
calendarZoomOrder,
|
||
setCalendarContentWrapRef,
|
||
setCalendarContentScrollRef,
|
||
setCalendarSceneRef,
|
||
setCalendarFlyRectRef,
|
||
setCalendarFlyLabelRef,
|
||
setCalendarToolbarLabelRef,
|
||
setCalendarHoveredMonthIndex,
|
||
setCalendarHoveredWeekStartKey,
|
||
setCalendarHoveredDayKey,
|
||
onCalendarSceneMouseLeave,
|
||
clearCalendarZoomPrime,
|
||
calendarPrimeMonthToken,
|
||
calendarPrimeWeekToken,
|
||
calendarPrimeDayToken,
|
||
calendarPrimeStyle,
|
||
maybePrimeWheelZoom,
|
||
zoomInCalendar,
|
||
zoomToMonth,
|
||
zoomOutCalendar,
|
||
onCalendarHierarchyWheel,
|
||
setCalendarZoomLevel,
|
||
onCalendarZoomSliderInput,
|
||
shiftCalendar,
|
||
setToday,
|
||
pickDate,
|
||
openDayView,
|
||
openWeekView,
|
||
openYearMonth,
|
||
formatYearMonthFirst,
|
||
commEventForm,
|
||
commEventMode,
|
||
commEventSaving,
|
||
commEventError,
|
||
createCommEvent,
|
||
openCommEventModal,
|
||
closeCommEventModal,
|
||
setDefaultCommEventForm,
|
||
buildCommEventTitle,
|
||
eventCloseOpen,
|
||
eventCloseDraft,
|
||
eventCloseSaving,
|
||
eventCloseError,
|
||
canManuallyCloseEvent,
|
||
isEventCloseOpen,
|
||
toggleEventClose,
|
||
archiveEventManually,
|
||
refetchCalendar,
|
||
} = calendar;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 5. Deals
|
||
// ---------------------------------------------------------------------------
|
||
const {
|
||
deals,
|
||
selectedDealId,
|
||
selectedDealStepsExpanded,
|
||
selectedWorkspaceDeal,
|
||
selectedWorkspaceDealDueDate,
|
||
selectedWorkspaceDealSubtitle,
|
||
selectedWorkspaceDealSteps,
|
||
formatDealHeadline,
|
||
getDealCurrentStepLabel,
|
||
isDealStepDone,
|
||
formatDealStepMeta,
|
||
refetchDeals,
|
||
} = useDeals({ apolloAuthReady, contacts, calendarEvents });
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 6. Documents
|
||
// ---------------------------------------------------------------------------
|
||
const {
|
||
documents,
|
||
documentSearch,
|
||
documentSortMode,
|
||
selectedDocumentId,
|
||
documentDeletingId,
|
||
documentSortOptions,
|
||
selectedDocument,
|
||
filteredDocuments,
|
||
updateSelectedDocumentBody,
|
||
createCommDocument,
|
||
buildCommDocumentTitle,
|
||
deleteWorkspaceDocumentById,
|
||
openDocumentsTab: _openDocumentsTab,
|
||
refetchDocuments,
|
||
} = useDocuments({ apolloAuthReady });
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 7. Timeline
|
||
// ---------------------------------------------------------------------------
|
||
const {
|
||
clientTimelineItems,
|
||
timelineContactId,
|
||
timelineLimit,
|
||
loadClientTimeline,
|
||
refreshSelectedClientTimeline,
|
||
refetchTimeline,
|
||
} = useTimeline({ apolloAuthReady });
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 8. Call Audio
|
||
// ---------------------------------------------------------------------------
|
||
const {
|
||
commCallWaveHosts,
|
||
commCallPlayableById,
|
||
commCallPlayingById,
|
||
callTranscriptOpen,
|
||
callTranscriptLoading,
|
||
callTranscriptText,
|
||
callTranscriptError,
|
||
ensureCommCallWave,
|
||
destroyCommCallWave,
|
||
destroyAllCommCallWaves,
|
||
toggleCommCallPlayback,
|
||
syncCommCallWaves: _syncCommCallWavesRaw,
|
||
transcribeCallItem,
|
||
toggleCallTranscript,
|
||
isCallTranscriptOpen,
|
||
eventArchiveRecordingById,
|
||
eventArchiveTranscribingById,
|
||
eventArchiveMicErrorById,
|
||
startEventArchiveRecording,
|
||
stopEventArchiveRecording,
|
||
toggleEventArchiveRecording,
|
||
setCommCallWaveHost,
|
||
} = useCallAudio();
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Refetch All
|
||
// ---------------------------------------------------------------------------
|
||
async function refetchAllCrmQueries() {
|
||
await Promise.all([
|
||
refetchContacts(),
|
||
refetchContactInboxes(),
|
||
refetchCalendar(),
|
||
refetchDeals(),
|
||
refetchFeed(),
|
||
refetchPins(),
|
||
refetchDocuments(),
|
||
refetchChatMessages(),
|
||
refetchChatConversations(),
|
||
]);
|
||
await refreshSelectedClientTimeline(selectedCommThreadId.value);
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 9. Pilot Chat
|
||
// ---------------------------------------------------------------------------
|
||
const pilotChat = usePilotChat({
|
||
apolloAuthReady,
|
||
authMe,
|
||
selectedContact,
|
||
selectedDeal: selectedWorkspaceDeal,
|
||
calendarView,
|
||
calendarPeriodLabel,
|
||
selectedDateKey,
|
||
focusedCalendarEvent,
|
||
calendarEvents,
|
||
refetchAllCrmQueries,
|
||
});
|
||
const {
|
||
pilotMessages,
|
||
pilotInput,
|
||
pilotSending,
|
||
pilotRecording,
|
||
pilotTranscribing,
|
||
pilotMicSupported,
|
||
pilotMicError,
|
||
setPilotWaveContainerRef,
|
||
livePilotUserText,
|
||
livePilotAssistantText,
|
||
contextPickerEnabled,
|
||
contextScopes,
|
||
pilotLiveLogs,
|
||
pilotLiveLogsExpanded,
|
||
pilotLiveLogHiddenCount,
|
||
pilotVisibleLiveLogs,
|
||
pilotVisibleLogCount,
|
||
chatConversations,
|
||
chatThreadsLoading,
|
||
chatSwitching,
|
||
chatCreating,
|
||
selectedChatId,
|
||
chatThreadPickerOpen,
|
||
chatArchivingId,
|
||
toggleContextPicker,
|
||
hasContextScope,
|
||
toggleContextScope,
|
||
removeContextScope,
|
||
sendPilotText,
|
||
sendPilotMessage,
|
||
togglePilotRecording,
|
||
createNewChatConversation,
|
||
switchChatConversation,
|
||
archiveChatConversation,
|
||
toggleChatThreadPicker,
|
||
closeChatThreadPicker,
|
||
startPilotBackgroundPolling,
|
||
stopPilotBackgroundPolling,
|
||
buildContextPayload,
|
||
pushPilotNote,
|
||
refetchChatMessages,
|
||
refetchChatConversations,
|
||
destroyPilotWaveSurfer,
|
||
togglePilotLiveLogsExpanded,
|
||
} = pilotChat;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 10. Change Review
|
||
// ---------------------------------------------------------------------------
|
||
const {
|
||
activeChangeSetId,
|
||
activeChangeStep,
|
||
changeActionBusy,
|
||
reviewActive,
|
||
activeChangeItems,
|
||
activeChangeItem,
|
||
activeChangeIndex,
|
||
openChangeReview: _openChangeReviewRaw,
|
||
goToPreviousChangeStep,
|
||
goToNextChangeStep,
|
||
finishReview: _finishReviewRaw,
|
||
isReviewHighlightedEvent,
|
||
isReviewHighlightedContact,
|
||
isReviewHighlightedDeal,
|
||
isReviewHighlightedMessage,
|
||
activeReviewCalendarEventId,
|
||
activeReviewContactId,
|
||
activeReviewDealId,
|
||
activeReviewMessageId,
|
||
activeReviewContactDiff,
|
||
confirmLatestChangeSet,
|
||
rollbackLatestChangeSet: _rollbackLatestChangeSetRaw,
|
||
rollbackSelectedChangeItems,
|
||
rollbackChangeItemById,
|
||
describeChangeEntity,
|
||
describeChangeAction,
|
||
} = useChangeReview({
|
||
pilotMessages,
|
||
refetchAllCrmQueries,
|
||
refetchChatMessages,
|
||
refetchChatConversations,
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// UI State (orchestrator-owned)
|
||
// ---------------------------------------------------------------------------
|
||
const selectedTab = ref<TabId>("communications");
|
||
const peopleLeftMode = ref<PeopleLeftMode>("contacts");
|
||
const peopleListMode = ref<"contacts" | "deals">("contacts");
|
||
const peopleSearch = ref("");
|
||
const peopleSortMode = ref<PeopleSortMode>("lastContact");
|
||
const peopleVisibilityMode = ref<PeopleVisibilityMode>("all");
|
||
const peopleSortOptions: Array<{ value: PeopleSortMode; label: string }> = [
|
||
{ value: "lastContact", label: "Last contact" },
|
||
{ value: "name", label: "Name" },
|
||
];
|
||
const peopleVisibilityOptions: Array<{ value: PeopleVisibilityMode; label: string }> = [
|
||
{ value: "all", label: "All" },
|
||
{ value: "hidden", label: "Hidden" },
|
||
];
|
||
|
||
// Comm state
|
||
const commSendChannel = ref<CommItem["channel"] | "">("");
|
||
const commPinnedOnly = ref(false);
|
||
const commDraft = ref("");
|
||
const commSending = ref(false);
|
||
const commRecording = ref(false);
|
||
const commTranscribing = ref(false);
|
||
const commMicError = ref("");
|
||
const commComposerMode = ref<"message" | "planned" | "logged" | "document">("message");
|
||
const commQuickMenuOpen = ref(false);
|
||
const commDocumentForm = ref<{ title: string }>({ title: "" });
|
||
|
||
// Contact right panel
|
||
const contactRightPanelMode = ref<"summary" | "documents">("summary");
|
||
const contactDocumentsSearch = ref("");
|
||
|
||
// Pilot header
|
||
const pilotHeaderPhrases = [
|
||
"Every step moves you forward",
|
||
"Focus first, results follow",
|
||
"Break down hard things into simple moves",
|
||
"Finish what matters today",
|
||
"Less noise, more action",
|
||
"Systems beat chaos",
|
||
"Important before urgent",
|
||
"The best moment to start is now",
|
||
];
|
||
const pilotHeaderText = ref("Every step moves you forward");
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Comm Threads (glue: contacts + contactInboxes — no bulk message loading)
|
||
// ---------------------------------------------------------------------------
|
||
const commThreads = computed(() => {
|
||
const inboxesByContactId = new Map<string, ContactInbox[]>();
|
||
for (const inbox of contactInboxes.value) {
|
||
if (!inboxesByContactId.has(inbox.contactId)) inboxesByContactId.set(inbox.contactId, []);
|
||
inboxesByContactId.get(inbox.contactId)!.push(inbox);
|
||
}
|
||
|
||
return contacts.value
|
||
.map((c) => {
|
||
const inboxes = inboxesByContactId.get(c.id) ?? [];
|
||
const channels = [
|
||
...new Set([
|
||
...c.channels,
|
||
...inboxes.map((i) => i.channel),
|
||
]),
|
||
] as CommItem["channel"][];
|
||
|
||
return {
|
||
id: c.id,
|
||
contact: c.name,
|
||
avatar: c.avatar,
|
||
channels,
|
||
lastAt: c.lastContactAt,
|
||
lastText: c.lastMessageText || "No messages yet",
|
||
items: [] as CommItem[],
|
||
};
|
||
})
|
||
.filter((t) => t.contact)
|
||
.sort((a, b) => b.lastAt.localeCompare(a.lastAt));
|
||
});
|
||
|
||
const peopleContactList = computed(() => {
|
||
const query = peopleSearch.value.trim().toLowerCase();
|
||
const list = commThreads.value.filter((item) => {
|
||
if (!query) return true;
|
||
const haystack = [item.contact, ...(item.channels ?? [])].join(" ").toLowerCase();
|
||
return haystack.includes(query);
|
||
});
|
||
const byVisibility = list.filter((item) => {
|
||
if (peopleVisibilityMode.value === "all") return true;
|
||
return threadInboxes(item).some((inbox: any) => inbox.isHidden);
|
||
});
|
||
return byVisibility.sort((a, b) => {
|
||
if (peopleSortMode.value === "name") return a.contact.localeCompare(b.contact);
|
||
return b.lastAt.localeCompare(a.lastAt);
|
||
});
|
||
});
|
||
|
||
const peopleDealList = computed(() => {
|
||
const query = peopleSearch.value.trim().toLowerCase();
|
||
const list = deals.value.filter((deal) => {
|
||
if (!query) return true;
|
||
const haystack = [deal.title, deal.stage, deal.amount, deal.nextStep, deal.summary, deal.contact]
|
||
.join(" ")
|
||
.toLowerCase();
|
||
return haystack.includes(query);
|
||
});
|
||
return list.sort((a, b) => a.title.localeCompare(b.title));
|
||
});
|
||
|
||
// Selected comm thread
|
||
const selectedCommThreadId = ref("");
|
||
|
||
watchEffect(() => {
|
||
if (!commThreads.value.length) {
|
||
selectedCommThreadId.value = "";
|
||
return;
|
||
}
|
||
if (!commThreads.value.some((thread) => thread.id === selectedCommThreadId.value)) {
|
||
const first = commThreads.value[0];
|
||
if (first) selectedCommThreadId.value = first.id;
|
||
}
|
||
});
|
||
|
||
const selectedCommThread = computed(() =>
|
||
commThreads.value.find((thread) => thread.id === selectedCommThreadId.value),
|
||
);
|
||
|
||
// Thread-derived computeds
|
||
const commSendChannelOptions = computed<CommItem["channel"][]>(() => {
|
||
if (!selectedCommThread.value) return [];
|
||
return selectedCommThread.value.channels.filter((channel) => channel !== "Phone");
|
||
});
|
||
|
||
const visibleThreadItems = computed(() => {
|
||
if (!selectedCommThread.value) return [] as CommItem[];
|
||
return selectedCommThread.value.items;
|
||
});
|
||
|
||
const selectedCommLifecycleEvents = computed(() => {
|
||
const nowMs = lifecycleNowMs.value;
|
||
return clientTimelineItems.value
|
||
.filter((entry) => entry.contentType === "calendar_event" && entry.calendarEvent)
|
||
.map((entry) => {
|
||
const event = entry.calendarEvent as CalendarEvent;
|
||
const phase = eventLifecyclePhase(event, nowMs);
|
||
return { event, phase, timelineAt: entry.datetime };
|
||
})
|
||
.sort((a, b) => a.timelineAt.localeCompare(b.timelineAt));
|
||
});
|
||
|
||
const threadStreamItems = computed(() => {
|
||
const rows = clientTimelineItems.value
|
||
.map((entry) => {
|
||
if (entry.contentType === "message" && entry.message) {
|
||
return { id: entry.id, at: entry.datetime, kind: entry.message.kind, item: entry.message };
|
||
}
|
||
if (entry.contentType === "calendar_event" && entry.calendarEvent) {
|
||
const phase = eventLifecyclePhase(entry.calendarEvent, lifecycleNowMs.value);
|
||
return { id: entry.id, at: entry.datetime, kind: "eventLifecycle" as const, event: entry.calendarEvent, phase };
|
||
}
|
||
if (entry.contentType === "recommendation" && entry.recommendation) {
|
||
return { id: entry.id, at: entry.datetime, kind: "recommendation" as const, card: entry.recommendation };
|
||
}
|
||
if (entry.contentType === "document" && entry.document) {
|
||
return { id: entry.id, at: entry.datetime, kind: "document" as const, document: entry.document };
|
||
}
|
||
return null;
|
||
})
|
||
.filter((entry) => entry !== null) as Array<any>;
|
||
return rows.sort((a, b) => a.at.localeCompare(b.at));
|
||
});
|
||
|
||
// Sync call waves when thread stream changes
|
||
watch(
|
||
() => threadStreamItems.value.map((entry: any) => `${entry.kind}:${entry.id}`).join("|"),
|
||
() => {
|
||
const activeCallIds = new Set(
|
||
threadStreamItems.value
|
||
.filter((entry: any) => entry.kind === "call")
|
||
.map((entry: any) => entry.item.id as string),
|
||
);
|
||
const getCallItem = (id: string) =>
|
||
visibleThreadItems.value.find((item) => item.id === id && item.kind === "call");
|
||
void _syncCommCallWavesRaw(activeCallIds, getCallItem);
|
||
},
|
||
);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 11. Pins (depends on selectedCommThread, selectedCommLifecycleEvents, visibleThreadItems)
|
||
// ---------------------------------------------------------------------------
|
||
const {
|
||
commPins,
|
||
commPinToggling,
|
||
commPinContextMenu,
|
||
selectedCommPins,
|
||
selectedCommPinnedStream,
|
||
togglePinnedText,
|
||
togglePinForEntry,
|
||
isPinnedText,
|
||
isPinnedEntry,
|
||
normalizePinText,
|
||
stripPinnedPrefix,
|
||
latestPinnedItem,
|
||
latestPinnedLabel,
|
||
closeCommPinContextMenu,
|
||
openCommPinContextMenu,
|
||
commPinContextActionLabel,
|
||
applyCommPinContextAction,
|
||
onWindowPointerDownForCommPinMenu,
|
||
onWindowKeyDownForCommPinMenu,
|
||
refetchPins,
|
||
} = usePins({
|
||
apolloAuthReady,
|
||
selectedCommThread,
|
||
selectedCommLifecycleEvents,
|
||
visibleThreadItems,
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 12. Feed
|
||
// ---------------------------------------------------------------------------
|
||
const {
|
||
feedCards,
|
||
decideFeedCard,
|
||
executeFeedAction,
|
||
refetchFeed,
|
||
} = useFeed({
|
||
apolloAuthReady,
|
||
onCreateFollowup: (_card: FeedCard, event: CalendarEvent) => {
|
||
calendarEvents.value = [event, ...calendarEvents.value];
|
||
const start = new Date(event.start);
|
||
selectedDateKey.value = dayKey(start);
|
||
calendarCursor.value = new Date(start.getFullYear(), start.getMonth(), 1);
|
||
setPeopleLeftMode("calendar", true);
|
||
},
|
||
onOpenComm: (card: FeedCard) => {
|
||
openCommunicationThread(card.contact);
|
||
},
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 13. CRM Realtime
|
||
// ---------------------------------------------------------------------------
|
||
const { crmRealtimeState, startCrmRealtime, stopCrmRealtime } = useCrmRealtime({
|
||
isAuthenticated: () => !!authMe.value,
|
||
onDashboardChanged: async () => {
|
||
await Promise.all([refetchAllCrmQueries(), loadTelegramConnectStatus()]);
|
||
},
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 14. Workspace Routing
|
||
// ---------------------------------------------------------------------------
|
||
const activeChangeMessage = computed(() => {
|
||
const targetId = activeChangeSetId.value.trim();
|
||
const latest = [...pilotMessages.value]
|
||
.reverse()
|
||
.find((m) => m.role === "assistant" && m.changeSetId && m.changeStatus !== "rolled_back") ?? null;
|
||
if (!targetId) return latest;
|
||
return (
|
||
[...pilotMessages.value]
|
||
.reverse()
|
||
.find((m) => m.role === "assistant" && m.changeSetId === targetId) ?? null
|
||
);
|
||
});
|
||
|
||
const routing = useWorkspaceRouting({
|
||
selectedTab,
|
||
peopleLeftMode,
|
||
peopleListMode,
|
||
selectedContactId,
|
||
selectedCommThreadId,
|
||
selectedDealId,
|
||
selectedChatId,
|
||
calendarView,
|
||
calendarCursor,
|
||
selectedDateKey,
|
||
selectedDocumentId,
|
||
focusedCalendarEventId,
|
||
activeChangeSetId,
|
||
activeChangeStep,
|
||
sortedEvents,
|
||
commThreads,
|
||
contacts,
|
||
deals,
|
||
clientTimelineItems,
|
||
activeChangeMessage,
|
||
activeChangeItem,
|
||
activeChangeItems,
|
||
activeChangeIndex,
|
||
authMe,
|
||
pickDate,
|
||
openCommunicationThread,
|
||
completeTelegramBusinessConnectFromToken,
|
||
});
|
||
const {
|
||
uiPathSyncLocked,
|
||
applyPathToUi,
|
||
syncPathFromUi,
|
||
applyReviewStepToUi,
|
||
initRouting,
|
||
cleanupRouting,
|
||
} = routing;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Navigation helpers
|
||
// ---------------------------------------------------------------------------
|
||
function setPeopleLeftMode(mode: PeopleLeftMode, push = false) {
|
||
selectedTab.value = "communications";
|
||
peopleLeftMode.value = mode;
|
||
focusedCalendarEventId.value = "";
|
||
syncPathFromUi(push);
|
||
}
|
||
|
||
function openCommunicationThread(contact: string) {
|
||
setPeopleLeftMode("contacts", true);
|
||
peopleListMode.value = "contacts";
|
||
selectedDealStepsExpanded.value = false;
|
||
const linkedContact = contacts.value.find((item) => item.name === contact);
|
||
if (linkedContact) selectedContactId.value = linkedContact.id;
|
||
const linkedDeal = deals.value.find((deal) => deal.contact === contact);
|
||
if (linkedDeal) selectedDealId.value = linkedDeal.id;
|
||
const thread = commThreads.value.find((item) => item.contact === contact);
|
||
if (thread) selectedCommThreadId.value = thread.id;
|
||
}
|
||
|
||
function openDealThread(deal: Deal) {
|
||
selectedDealId.value = deal.id;
|
||
peopleListMode.value = "deals";
|
||
selectedDealStepsExpanded.value = false;
|
||
openCommunicationThread(deal.contact);
|
||
peopleListMode.value = "deals";
|
||
}
|
||
|
||
function openThreadFromCalendarItem(event: CalendarEvent) {
|
||
if (!event.contact?.trim()) {
|
||
setPeopleLeftMode("calendar", true);
|
||
pickDate(event.start.slice(0, 10));
|
||
focusedCalendarEventId.value = event.id;
|
||
syncPathFromUi(true);
|
||
return;
|
||
}
|
||
openCommunicationThread(event.contact);
|
||
}
|
||
|
||
function openEventFromContact(event: CalendarEvent) {
|
||
setPeopleLeftMode("calendar", true);
|
||
pickDate(event.start.slice(0, 10));
|
||
focusedCalendarEventId.value = event.id;
|
||
syncPathFromUi(true);
|
||
}
|
||
|
||
function openMessageFromContact(channel: CommItem["channel"]) {
|
||
if (!selectedContact.value) return;
|
||
openCommunicationThread(selectedContact.value.name);
|
||
commSendChannel.value = channel;
|
||
}
|
||
|
||
function openDocumentsTab(push = false) {
|
||
selectedTab.value = "documents";
|
||
focusedCalendarEventId.value = "";
|
||
if (!selectedDocumentId.value && filteredDocuments.value.length) {
|
||
const first = filteredDocuments.value[0];
|
||
if (first) selectedDocumentId.value = first.id;
|
||
}
|
||
syncPathFromUi(push);
|
||
}
|
||
|
||
// Review wrappers
|
||
function openChangeReview(changeSetId: string, step = 0, push = true) {
|
||
_openChangeReviewRaw(changeSetId, step);
|
||
applyReviewStepToUi(push);
|
||
}
|
||
|
||
function finishReview(push = true) {
|
||
_finishReviewRaw();
|
||
syncPathFromUi(push);
|
||
}
|
||
|
||
async function rollbackLatestChangeSet() {
|
||
await _rollbackLatestChangeSetRaw();
|
||
setPeopleLeftMode("contacts");
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Comm composer
|
||
// ---------------------------------------------------------------------------
|
||
function setDefaultCommDocumentForm() {
|
||
commDocumentForm.value = { title: "" };
|
||
}
|
||
|
||
function openCommDocumentModal() {
|
||
if (!selectedCommThread.value) return;
|
||
setDefaultCommDocumentForm();
|
||
commEventError.value = "";
|
||
commComposerMode.value = "document";
|
||
commQuickMenuOpen.value = false;
|
||
}
|
||
|
||
function toggleCommQuickMenu() {
|
||
if (!selectedCommThread.value || commEventSaving.value) return;
|
||
commQuickMenuOpen.value = !commQuickMenuOpen.value;
|
||
}
|
||
|
||
function closeCommQuickMenu() {
|
||
commQuickMenuOpen.value = false;
|
||
}
|
||
|
||
function commComposerPlaceholder() {
|
||
if (commComposerMode.value === "planned") return "Опиши, что нужно запланировать...";
|
||
if (commComposerMode.value === "logged") return "Опиши итог/отчёт по прошедшему событию...";
|
||
if (commComposerMode.value === "document") return "Опиши документ или вложение для контакта...";
|
||
return "Type a message...";
|
||
}
|
||
|
||
async function sendCommMessage() {
|
||
const text = commDraft.value.trim();
|
||
if (!text || commSending.value || !selectedCommThread.value) return;
|
||
commSending.value = true;
|
||
try {
|
||
const channel = commSendChannel.value;
|
||
if (!channel) return;
|
||
const { useMutation } = await import("@vue/apollo-composable");
|
||
const { CreateCommunicationMutationDocument, CommunicationsQueryDocument, ContactInboxesQueryDocument } = await import("~~/graphql/generated");
|
||
const { mutate: doCreateCommunication } = useMutation(CreateCommunicationMutationDocument, {
|
||
refetchQueries: [{ query: CommunicationsQueryDocument }, { query: ContactInboxesQueryDocument }],
|
||
});
|
||
await doCreateCommunication({
|
||
input: {
|
||
contact: selectedCommThread.value.contact,
|
||
channel,
|
||
kind: "message",
|
||
direction: "out",
|
||
text,
|
||
},
|
||
});
|
||
commDraft.value = "";
|
||
openCommunicationThread(selectedCommThread.value.contact);
|
||
} finally {
|
||
commSending.value = false;
|
||
}
|
||
}
|
||
|
||
function handleCommComposerEnter(event: KeyboardEvent) {
|
||
if (event.shiftKey) return;
|
||
event.preventDefault();
|
||
handleCommComposerSubmit();
|
||
}
|
||
|
||
function handleCommComposerSubmit() {
|
||
if (commComposerMode.value === "message") {
|
||
void sendCommMessage();
|
||
return;
|
||
}
|
||
if (commComposerMode.value === "document") {
|
||
void createCommDocument();
|
||
return;
|
||
}
|
||
void createCommEvent();
|
||
}
|
||
|
||
function handlePilotSendAction() {
|
||
if (pilotRecording.value) {
|
||
// Stop recording and send
|
||
pilotChat.stopPilotRecording?.("send");
|
||
return;
|
||
}
|
||
void sendPilotMessage();
|
||
}
|
||
|
||
function handlePilotComposerEnter(event: KeyboardEvent) {
|
||
if (event.shiftKey) return;
|
||
event.preventDefault();
|
||
handlePilotSendAction();
|
||
}
|
||
|
||
function onCommDictationTranscript(text: string) {
|
||
const next = String(text ?? "").trim();
|
||
if (!next) return;
|
||
const previous = String(commDraft.value ?? "").trim();
|
||
commDraft.value = previous ? `${previous} ${next}` : next;
|
||
commMicError.value = "";
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Thread channel
|
||
// ---------------------------------------------------------------------------
|
||
function threadChannelLabel(thread: { id: string; channels: CommItem["channel"][] }) {
|
||
const visibleChannels = [...new Set(threadInboxes(thread).filter((inbox: any) => !inbox.isHidden).map((inbox: any) => inbox.channel))];
|
||
if (visibleChannels.length === 1) return visibleChannels[0];
|
||
if (visibleChannels.length > 1) return `${visibleChannels[0]} +${visibleChannels.length - 1}`;
|
||
const fallback = [...new Set(thread.channels.filter((channel) => channel !== "Phone"))];
|
||
if (fallback.length === 1) return fallback[0];
|
||
if (fallback.length > 1) return `${fallback[0]} +${fallback.length - 1}`;
|
||
return "No channel";
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Auth display
|
||
// ---------------------------------------------------------------------------
|
||
const authDisplayName = computed(() => authMe.value?.user.name ?? "User");
|
||
const authInitials = computed(() => {
|
||
const parts = authDisplayName.value.trim().split(/\s+/).filter(Boolean).slice(0, 2);
|
||
if (parts.length === 0) return "U";
|
||
return parts.map((part) => part[0]?.toUpperCase() ?? "").join("");
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Selected contact helpers
|
||
// ---------------------------------------------------------------------------
|
||
const selectedContactEvents = computed(() => {
|
||
if (!selectedContact.value) return [];
|
||
const nowIso = new Date().toISOString();
|
||
const events = sortedEvents.value.filter((event) => event.contact === selectedContact.value?.name);
|
||
const upcoming = events.filter((event) => event.end >= nowIso);
|
||
const past = events.filter((event) => event.end < nowIso).reverse();
|
||
return [...upcoming, ...past].slice(0, 8);
|
||
});
|
||
|
||
const selectedContactRecentMessages = computed(() => {
|
||
if (!selectedContact.value) return [];
|
||
return clientTimelineItems.value
|
||
.filter((entry) => entry.contentType === "message" && entry.message)
|
||
.map((entry) => entry.message!)
|
||
.sort((a, b) => b.at.localeCompare(a.at))
|
||
.slice(0, 8);
|
||
});
|
||
|
||
const selectedWorkspaceContact = computed(() => {
|
||
if (selectedContact.value) return selectedContact.value;
|
||
const threadContactId = (selectedCommThread.value?.id ?? "").trim();
|
||
if (threadContactId) {
|
||
const byId = contacts.value.find((contact) => contact.id === threadContactId);
|
||
if (byId) return byId;
|
||
}
|
||
const threadContactName = (selectedCommThread.value?.contact ?? "").trim();
|
||
if (threadContactName) {
|
||
const byName = contacts.value.find((contact) => contact.name === threadContactName);
|
||
if (byName) return byName;
|
||
}
|
||
return contacts.value[0] ?? null;
|
||
});
|
||
|
||
const selectedWorkspaceContactDocuments = computed(() => {
|
||
const contact = selectedWorkspaceContact.value;
|
||
if (!contact) return [];
|
||
return documents.value
|
||
.filter((doc) => isDocumentLinkedToContact(doc.scope, contact))
|
||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||
});
|
||
|
||
const filteredSelectedWorkspaceContactDocuments = computed(() => {
|
||
const query = contactDocumentsSearch.value.trim().toLowerCase();
|
||
if (!query) return selectedWorkspaceContactDocuments.value;
|
||
return selectedWorkspaceContactDocuments.value.filter((doc) => {
|
||
const haystack = [doc.title, doc.summary, doc.owner, formatDocumentScope(doc.scope), doc.body].join(" ").toLowerCase();
|
||
return haystack.includes(query);
|
||
});
|
||
});
|
||
|
||
watch(() => selectedWorkspaceContact.value?.id ?? "", () => {
|
||
contactRightPanelMode.value = "summary";
|
||
contactDocumentsSearch.value = "";
|
||
});
|
||
|
||
watch(() => selectedWorkspaceDeal.value?.id ?? "", () => {
|
||
selectedDealStepsExpanded.value = false;
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Pilot rendering
|
||
// ---------------------------------------------------------------------------
|
||
function normalizePilotTimeline(messages: PilotMessage[]) {
|
||
const sorted = [...messages].sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""));
|
||
const finalizedRequestIds = new Set(
|
||
sorted
|
||
.filter((m) => m.role === "assistant" && m.phase === "final" && m.requestId)
|
||
.map((m) => m.requestId as string),
|
||
);
|
||
const latestAssistantAt = [...sorted].reverse().find((m) => m.role === "assistant")?.createdAt ?? null;
|
||
const out: PilotMessage[] = [];
|
||
const traceIndexByRequestId = new Map<string, number>();
|
||
|
||
for (const message of sorted) {
|
||
const requestId = (message.requestId ?? "").trim();
|
||
const isTrace = message.role === "system" || message.eventType === "trace";
|
||
const isTransient = message.transient === true || isTrace;
|
||
if (isTransient) {
|
||
if (requestId && finalizedRequestIds.has(requestId)) continue;
|
||
if (!requestId && latestAssistantAt && (message.createdAt ?? "") <= latestAssistantAt) continue;
|
||
}
|
||
if (isTrace && requestId) {
|
||
const existingIdx = traceIndexByRequestId.get(requestId);
|
||
if (typeof existingIdx === "number") { out[existingIdx] = message; continue; }
|
||
traceIndexByRequestId.set(requestId, out.length);
|
||
} else if (requestId) {
|
||
traceIndexByRequestId.delete(requestId);
|
||
}
|
||
out.push(message);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
const renderedPilotMessages = computed<PilotMessage[]>(() => {
|
||
const items = normalizePilotTimeline(pilotMessages.value).filter((m) => m.role !== "system");
|
||
const hasPersistedLiveUser = items.some(
|
||
(m) => m.role === "user" && safeTrim(m.text) === livePilotUserText.value.trim(),
|
||
);
|
||
if (livePilotUserText.value && !hasPersistedLiveUser) {
|
||
items.push({ id: "pilot-live-user", role: "user", text: livePilotUserText.value, createdAt: new Date().toISOString(), _live: true });
|
||
}
|
||
if (livePilotAssistantText.value) {
|
||
items.push({ id: "pilot-live-assistant", role: "assistant", text: livePilotAssistantText.value, createdAt: new Date().toISOString(), _live: true });
|
||
}
|
||
return items;
|
||
});
|
||
|
||
function pilotRoleName(role: PilotMessage["role"]) {
|
||
if (role === "user") return authMe.value?.user.name ?? "You";
|
||
if (role === "system") return "Agent status";
|
||
return "Pilot";
|
||
}
|
||
|
||
function pilotRoleBadge(role: PilotMessage["role"]) {
|
||
if (role === "user") return "You";
|
||
if (role === "system") return "...";
|
||
return "AI";
|
||
}
|
||
|
||
function summarizeChangeActions(items: PilotMessage["changeItems"] | null | undefined) {
|
||
const totals = { created: 0, updated: 0, deleted: 0 };
|
||
for (const item of items ?? []) {
|
||
if (item.action === "created") totals.created += 1;
|
||
else if (item.action === "updated") totals.updated += 1;
|
||
else if (item.action === "deleted") totals.deleted += 1;
|
||
}
|
||
return totals;
|
||
}
|
||
|
||
function summarizeChangeEntities(items: PilotMessage["changeItems"] | null | undefined) {
|
||
const map = new Map<string, number>();
|
||
for (const item of items ?? []) {
|
||
const key = item.entity || "unknown";
|
||
map.set(key, (map.get(key) ?? 0) + 1);
|
||
}
|
||
return [...map.entries()].map(([entity, count]) => ({ entity, count })).sort((a, b) => b.count - a.count);
|
||
}
|
||
|
||
function formatPilotStamp(iso?: string) {
|
||
if (!iso) return "";
|
||
return new Intl.DateTimeFormat("en-GB", { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" }).format(new Date(iso));
|
||
}
|
||
|
||
function formatChatThreadMeta(conversation: { lastMessageAt?: string | null; updatedAt?: string; createdAt?: string }) {
|
||
const when = conversation.lastMessageAt ?? conversation.updatedAt ?? conversation.createdAt;
|
||
if (!when) return "";
|
||
return new Intl.DateTimeFormat("en-GB", { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" }).format(new Date(when));
|
||
}
|
||
|
||
function calendarScopeLabel() {
|
||
if (focusedCalendarEvent.value) return `Календарь: ${focusedCalendarEvent.value.title}`;
|
||
if (calendarView.value === "month" || calendarView.value === "agenda") return `Календарь: ${monthLabel.value}`;
|
||
if (calendarView.value === "year") return `Календарь: ${calendarCursor.value.getFullYear()}`;
|
||
if (calendarView.value === "week") return `Календарь: ${calendarPeriodLabel.value}`;
|
||
return `Календарь: ${formatDay(`${selectedDateKey.value}T00:00:00`)}`;
|
||
}
|
||
|
||
function contextScopeLabel(scope: ContextScope) {
|
||
if (scope === "summary") return "Summary";
|
||
if (scope === "deal") return "Сделка";
|
||
if (scope === "message") return "Работа с пользователем";
|
||
return calendarScopeLabel();
|
||
}
|
||
|
||
const contextScopeChips = computed(() =>
|
||
contextScopes.value.map((scope) => ({ scope, label: contextScopeLabel(scope) })),
|
||
);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Message helpers
|
||
// ---------------------------------------------------------------------------
|
||
function channelIcon(channel: "All" | CommItem["channel"]) {
|
||
if (channel === "All") return "all";
|
||
if (channel === "Telegram") return "telegram";
|
||
if (channel === "WhatsApp") return "whatsapp";
|
||
if (channel === "Instagram") return "instagram";
|
||
if (channel === "Email") return "email";
|
||
return "phone";
|
||
}
|
||
|
||
function messageDeliveryUiState(item: CommItem): "none" | "sending" | "sent" | "delivered" | "failed" {
|
||
if (item.kind !== "message" || item.direction !== "out") return "none";
|
||
const rawStatus = String(item.deliveryStatus ?? "").toUpperCase();
|
||
if (rawStatus === "FAILED") return "failed";
|
||
if (rawStatus === "READ" || rawStatus === "DELIVERED") return "delivered";
|
||
if (rawStatus === "SENT") return "sent";
|
||
return "sending";
|
||
}
|
||
|
||
function messageDeliveryLabel(item: CommItem) {
|
||
const state = messageDeliveryUiState(item);
|
||
if (state === "failed") return "Delivery failed";
|
||
if (state === "delivered") return "Delivered";
|
||
if (state === "sent") return "Sent";
|
||
if (state === "sending") return "Sending";
|
||
return "";
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Watch: reset comm state on thread change
|
||
// ---------------------------------------------------------------------------
|
||
watch(selectedCommThreadId, () => {
|
||
stopEventArchiveRecording();
|
||
destroyAllCommCallWaves();
|
||
callTranscriptOpen.value = {};
|
||
callTranscriptLoading.value = {};
|
||
callTranscriptText.value = {};
|
||
callTranscriptError.value = {};
|
||
commPinnedOnly.value = false;
|
||
commDraft.value = "";
|
||
commRecording.value = false;
|
||
commTranscribing.value = false;
|
||
commMicError.value = "";
|
||
commComposerMode.value = "message";
|
||
commQuickMenuOpen.value = false;
|
||
commEventError.value = "";
|
||
commDocumentForm.value = { title: "" };
|
||
inboxToggleLoadingById.value = {};
|
||
eventCloseOpen.value = {};
|
||
eventCloseDraft.value = {};
|
||
eventCloseSaving.value = {};
|
||
eventCloseError.value = {};
|
||
eventArchiveRecordingById.value = {};
|
||
eventArchiveTranscribingById.value = {};
|
||
eventArchiveMicErrorById.value = {};
|
||
const preferred = selectedCommThread.value?.channels.find((channel) => channel !== "Phone") ?? "";
|
||
commSendChannel.value = preferred;
|
||
if (!selectedCommThread.value) {
|
||
clientTimelineItems.value = [];
|
||
return;
|
||
}
|
||
void refreshSelectedClientTimeline(selectedCommThreadId.value).catch(() => undefined);
|
||
});
|
||
|
||
// Watch: URL sync
|
||
watch(
|
||
() => [
|
||
selectedTab.value,
|
||
peopleLeftMode.value,
|
||
peopleListMode.value,
|
||
selectedChatId.value,
|
||
calendarView.value,
|
||
focusedCalendarEventId.value,
|
||
selectedContactId.value,
|
||
selectedDealId.value,
|
||
selectedDocumentId.value,
|
||
activeChangeSetId.value,
|
||
activeChangeStep.value,
|
||
],
|
||
() => {
|
||
if (process.server || uiPathSyncLocked.value) return;
|
||
syncPathFromUi(false);
|
||
},
|
||
);
|
||
|
||
// Watch: review step sync
|
||
watch(
|
||
() => activeChangeMessage.value?.changeSetId,
|
||
() => {
|
||
if (!activeChangeSetId.value.trim()) return;
|
||
const maxIndex = Math.max(0, (activeChangeItems.value.length || 1) - 1);
|
||
if (activeChangeStep.value > maxIndex) activeChangeStep.value = maxIndex;
|
||
applyReviewStepToUi(false);
|
||
},
|
||
);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Bootstrap
|
||
// ---------------------------------------------------------------------------
|
||
async function bootstrapSession() {
|
||
const resetAuthState = () => {
|
||
stopCrmRealtime();
|
||
authMe.value = null;
|
||
pilotMessages.value = [];
|
||
chatConversations.value = [];
|
||
clientTimelineItems.value = [];
|
||
telegramConnectStatus.value = "not_connected";
|
||
telegramConnections.value = [];
|
||
telegramConnectUrl.value = "";
|
||
};
|
||
|
||
try {
|
||
await loadMe();
|
||
if (!authMe.value) {
|
||
resetAuthState();
|
||
if (process.client) await navigateTo("/login", { replace: true });
|
||
return;
|
||
}
|
||
await Promise.all([refetchChatMessages(), refetchChatConversations(), refetchAllCrmQueries(), loadTelegramConnectStatus()]);
|
||
if (process.client) startCrmRealtime();
|
||
} catch {
|
||
resetAuthState();
|
||
if (process.client) await navigateTo("/login", { replace: true });
|
||
} finally {
|
||
authResolved.value = true;
|
||
}
|
||
}
|
||
|
||
async function logout() {
|
||
await authLogout();
|
||
stopCrmRealtime();
|
||
stopPilotBackgroundPolling();
|
||
authMe.value = null;
|
||
pilotMessages.value = [];
|
||
livePilotUserText.value = "";
|
||
livePilotAssistantText.value = "";
|
||
chatConversations.value = [];
|
||
clientTimelineItems.value = [];
|
||
telegramConnectStatus.value = "not_connected";
|
||
telegramConnections.value = [];
|
||
telegramConnectUrl.value = "";
|
||
if (process.client) await navigateTo("/login", { replace: true });
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// SSR bootstrap
|
||
// ---------------------------------------------------------------------------
|
||
if (process.server) {
|
||
await bootstrapSession();
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Lifecycle
|
||
// ---------------------------------------------------------------------------
|
||
onMounted(() => {
|
||
pilotHeaderText.value = pilotHeaderPhrases[Math.floor(Math.random() * pilotHeaderPhrases.length)] ?? "Every step moves you forward";
|
||
pilotMicSupported.value = isVoiceCaptureSupported();
|
||
|
||
initRouting();
|
||
window.addEventListener("pointerdown", onWindowPointerDownForCommPinMenu);
|
||
window.addEventListener("keydown", onWindowKeyDownForCommPinMenu);
|
||
|
||
if (!authResolved.value) {
|
||
void bootstrapSession().finally(() => {
|
||
if (authMe.value) {
|
||
startPilotBackgroundPolling();
|
||
startCrmRealtime();
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (authMe.value) {
|
||
startPilotBackgroundPolling();
|
||
startCrmRealtime();
|
||
}
|
||
});
|
||
|
||
onBeforeUnmount(() => {
|
||
stopCrmRealtime();
|
||
stopEventArchiveRecording();
|
||
destroyAllCommCallWaves();
|
||
destroyPilotWaveSurfer();
|
||
stopPilotBackgroundPolling();
|
||
cleanupRouting();
|
||
window.removeEventListener("pointerdown", onWindowPointerDownForCommPinMenu);
|
||
window.removeEventListener("keydown", onWindowKeyDownForCommPinMenu);
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<div class="h-[100dvh] overflow-hidden bg-base-200/35">
|
||
<CrmAuthLoading v-if="!authResolved || !authMe" />
|
||
|
||
<template v-else>
|
||
<div class="grid h-full min-h-0 grid-cols-1 gap-0 lg:grid-cols-[320px_minmax(0,1fr)]">
|
||
<CrmPilotSidebar
|
||
:pilot-header-text="pilotHeaderText"
|
||
:chat-switching="chatSwitching"
|
||
:chat-threads-loading="chatThreadsLoading"
|
||
:chat-conversations="chatConversations"
|
||
:auth-me="authMe"
|
||
:chat-creating="chatCreating"
|
||
:rendered-pilot-messages="renderedPilotMessages"
|
||
:pilot-live-logs="pilotLiveLogs"
|
||
:pilot-live-logs-expanded="pilotLiveLogsExpanded"
|
||
:pilot-live-log-hidden-count="pilotLiveLogHiddenCount"
|
||
:pilot-visible-log-count="pilotVisibleLogCount"
|
||
:pilot-visible-live-logs="pilotVisibleLiveLogs"
|
||
:chat-thread-picker-open="chatThreadPickerOpen"
|
||
:selected-chat-id="selectedChatId"
|
||
:chat-archiving-id="chatArchivingId"
|
||
:pilot-input="pilotInput"
|
||
:pilot-recording="pilotRecording"
|
||
:context-scope-chips="contextScopeChips"
|
||
:context-picker-enabled="contextPickerEnabled"
|
||
:pilot-transcribing="pilotTranscribing"
|
||
:pilot-sending="pilotSending"
|
||
:pilot-mic-supported="pilotMicSupported"
|
||
:pilot-mic-error="pilotMicError"
|
||
:toggle-chat-thread-picker="toggleChatThreadPicker"
|
||
:create-new-chat-conversation="createNewChatConversation"
|
||
:pilot-role-badge="pilotRoleBadge"
|
||
:pilot-role-name="pilotRoleName"
|
||
:format-pilot-stamp="formatPilotStamp"
|
||
:summarize-change-actions="summarizeChangeActions"
|
||
:summarize-change-entities="summarizeChangeEntities"
|
||
:open-change-review="openChangeReview"
|
||
:toggle-pilot-live-logs-expanded="togglePilotLiveLogsExpanded"
|
||
:close-chat-thread-picker="closeChatThreadPicker"
|
||
:switch-chat-conversation="switchChatConversation"
|
||
:format-chat-thread-meta="formatChatThreadMeta"
|
||
:archive-chat-conversation="archiveChatConversation"
|
||
:handle-pilot-composer-enter="handlePilotComposerEnter"
|
||
:on-pilot-input="(value) => { pilotInput = value; }"
|
||
:set-pilot-wave-container-ref="setPilotWaveContainerRef"
|
||
:toggle-context-picker="toggleContextPicker"
|
||
:remove-context-scope="removeContextScope"
|
||
:toggle-pilot-recording="togglePilotRecording"
|
||
:handle-pilot-send-action="handlePilotSendAction"
|
||
/>
|
||
|
||
<main class="relative min-h-0 bg-base-100">
|
||
<div class="flex h-full min-h-0 flex-col">
|
||
<CrmWorkspaceTopbar
|
||
:selected-tab="selectedTab"
|
||
:people-left-mode="peopleLeftMode"
|
||
:auth-initials="authInitials"
|
||
:auth-display-name="authDisplayName"
|
||
:telegram-status-badge-class="telegramStatusBadgeClass"
|
||
:telegram-status-label="telegramStatusLabel"
|
||
:telegram-connect-busy="telegramConnectBusy"
|
||
:telegram-connect-notice="telegramConnectNotice"
|
||
@open-contacts="setPeopleLeftMode('contacts', true)"
|
||
@open-calendar="setPeopleLeftMode('calendar', true)"
|
||
@open-documents="openDocumentsTab(true)"
|
||
@start-telegram-connect="startTelegramBusinessConnect"
|
||
@logout="logout"
|
||
/>
|
||
<div
|
||
class="min-h-0 flex-1"
|
||
:class="selectedTab === 'documents' || (selectedTab === 'communications' && peopleLeftMode === 'contacts') ? 'px-0 pt-0 pb-0' : 'px-3 pt-3 pb-0 md:px-4 md:pt-4 md:pb-0'"
|
||
>
|
||
<CrmCalendarPanel
|
||
v-if="selectedTab === 'communications' && peopleLeftMode === 'calendar'"
|
||
:context-picker-enabled="contextPickerEnabled"
|
||
:has-context-scope="hasContextScope"
|
||
:toggle-context-scope="toggleContextScope"
|
||
:context-scope-label="contextScopeLabel"
|
||
:set-today="setToday"
|
||
:calendar-period-label="calendarPeriodLabel"
|
||
:calendar-zoom-level-index="calendarZoomLevelIndex"
|
||
:on-calendar-zoom-slider-input="onCalendarZoomSliderInput"
|
||
:focused-calendar-event="focusedCalendarEvent"
|
||
:format-day="formatDay"
|
||
:format-time="formatTime"
|
||
:set-calendar-content-wrap-ref="setCalendarContentWrapRef"
|
||
:shift-calendar="shiftCalendar"
|
||
:set-calendar-content-scroll-ref="setCalendarContentScrollRef"
|
||
:on-calendar-hierarchy-wheel="onCalendarHierarchyWheel"
|
||
:set-calendar-scene-ref="setCalendarSceneRef"
|
||
:calendar-viewport-height="calendarViewportHeight"
|
||
:normalized-calendar-view="normalizedCalendarView"
|
||
:calendar-fly-visible="calendarFlyVisible"
|
||
:set-calendar-fly-rect-ref="setCalendarFlyRectRef"
|
||
:calendar-fly-label-visible="calendarFlyLabelVisible"
|
||
:set-calendar-fly-label-ref="setCalendarFlyLabelRef"
|
||
:set-calendar-toolbar-label-ref="setCalendarToolbarLabelRef"
|
||
:on-calendar-scene-mouse-leave="onCalendarSceneMouseLeave"
|
||
:calendar-view="calendarView"
|
||
:year-months="yearMonths"
|
||
:calendar-cursor-month="calendarCursor.getMonth()"
|
||
:calendar-hovered-month-index="calendarHoveredMonthIndex"
|
||
:set-calendar-hovered-month-index="setCalendarHoveredMonthIndex"
|
||
:calendar-zoom-prime-token="calendarZoomPrimeToken"
|
||
:calendar-prime-month-token="calendarPrimeMonthToken"
|
||
:calendar-prime-style="calendarPrimeStyle"
|
||
:zoom-to-month="zoomToMonth"
|
||
:open-thread-from-calendar-item="openThreadFromCalendarItem"
|
||
:month-rows="monthRows"
|
||
:calendar-hovered-week-start-key="calendarHoveredWeekStartKey"
|
||
:set-calendar-hovered-week-start-key="setCalendarHoveredWeekStartKey"
|
||
:calendar-prime-week-token="calendarPrimeWeekToken"
|
||
:selected-date-key="selectedDateKey"
|
||
:month-cell-has-focused-event="monthCellHasFocusedEvent"
|
||
:calendar-hovered-day-key="calendarHoveredDayKey"
|
||
:set-calendar-hovered-day-key="setCalendarHoveredDayKey"
|
||
:pick-date="pickDate"
|
||
:month-cell-events="monthCellEvents"
|
||
:is-review-highlighted-event="isReviewHighlightedEvent"
|
||
:week-days="weekDays"
|
||
:calendar-prime-day-token="calendarPrimeDayToken"
|
||
:selected-day-events="selectedDayEvents"
|
||
/>
|
||
|
||
|
||
<section v-else-if="selectedTab === 'communications' && peopleLeftMode === 'contacts'" class="flex h-full min-h-0 flex-col gap-0">
|
||
<div class="grid h-full min-h-0 flex-1 gap-0 md:grid-cols-[248px_minmax(0,1fr)_320px] md:grid-rows-[auto_minmax(0,1fr)]">
|
||
<CrmCommunicationsListSidebar
|
||
:people-list-mode="peopleListMode"
|
||
:people-search="peopleSearch"
|
||
:people-sort-options="peopleSortOptions"
|
||
:people-sort-mode="peopleSortMode"
|
||
:people-visibility-options="peopleVisibilityOptions"
|
||
:people-visibility-mode="peopleVisibilityMode"
|
||
:people-contact-list="peopleContactList"
|
||
:selected-comm-thread-id="selectedCommThreadId"
|
||
:is-review-highlighted-contact="isReviewHighlightedContact"
|
||
:open-communication-thread="openCommunicationThread"
|
||
:avatar-src-for-thread="avatarSrcForThread"
|
||
:mark-avatar-broken="markAvatarBroken"
|
||
:contact-initials="contactInitials"
|
||
:format-thread-time="formatThreadTime"
|
||
:thread-channel-label="threadChannelLabel"
|
||
:people-deal-list="peopleDealList"
|
||
:selected-deal-id="selectedDealId"
|
||
:is-review-highlighted-deal="isReviewHighlightedDeal"
|
||
:open-deal-thread="openDealThread"
|
||
:get-deal-current-step-label="getDealCurrentStepLabel"
|
||
:on-people-list-mode-change="(mode) => { peopleListMode = mode; }"
|
||
:on-people-search-input="(value) => { peopleSearch = value; }"
|
||
:on-people-sort-mode-change="(mode) => { peopleSortMode = mode; }"
|
||
:on-people-visibility-mode-change="(mode) => { peopleVisibilityMode = mode; }"
|
||
/>
|
||
|
||
<div class="hidden h-12 items-center justify-between gap-2 border-b border-base-300 px-3 md:flex md:col-span-2">
|
||
<div v-if="selectedWorkspaceContact">
|
||
<p class="font-medium">{{ selectedWorkspaceContact.name }}</p>
|
||
</div>
|
||
<div v-else-if="selectedCommThread">
|
||
<p class="font-medium">{{ selectedCommThread.contact }}</p>
|
||
</div>
|
||
<div v-if="selectedCommThread" class="dropdown dropdown-end" @click.stop>
|
||
<button
|
||
tabindex="0"
|
||
class="btn btn-ghost btn-sm btn-square"
|
||
title="Source visibility settings"
|
||
>
|
||
<svg viewBox="0 0 24 24" class="h-4 w-4 fill-current">
|
||
<path d="M19.14 12.94a7.43 7.43 0 0 0 .05-.94 7.43 7.43 0 0 0-.05-.94l2.03-1.58a.5.5 0 0 0 .12-.63l-1.92-3.32a.5.5 0 0 0-.6-.22l-2.39.96a7.2 7.2 0 0 0-1.62-.94l-.36-2.54A.5.5 0 0 0 13.9 2h-3.8a.5.5 0 0 0-.49.41L9.25 4.95a7.2 7.2 0 0 0-1.62.94l-2.39-.96a.5.5 0 0 0-.6.22L2.72 8.47a.5.5 0 0 0 .12.63l2.03 1.58a7.43 7.43 0 0 0-.05.94c0 .31.02.63.05.94l-2.03 1.58a.5.5 0 0 0-.12.63l1.92 3.32c.13.23.39.32.6.22l2.39-.96c.5.39 1.05.71 1.62.94l.36 2.54c.04.24.25.41.49.41h3.8c.24 0 .45-.17.49-.41l.36-2.54c.57-.23 1.12-.55 1.62-.94l2.39.96c.22.09.47 0 .6-.22l1.92-3.32a.5.5 0 0 0-.12-.63zM12 15.5A3.5 3.5 0 1 1 12 8a3.5 3.5 0 0 1 0 7.5Z" />
|
||
</svg>
|
||
</button>
|
||
<div tabindex="0" class="dropdown-content z-20 mt-1 w-60 rounded-xl border border-base-300 bg-base-100 p-2 shadow-lg">
|
||
<p class="px-1 pb-1 text-[10px] font-semibold uppercase tracking-wide text-base-content/55">Sources</p>
|
||
<div v-if="threadInboxes(selectedCommThread).length" class="space-y-1">
|
||
<button
|
||
v-for="inbox in threadInboxes(selectedCommThread)"
|
||
:key="`thread-header-inbox-setting-${inbox.id}`"
|
||
class="btn btn-ghost btn-xs h-auto min-h-0 w-full justify-between px-2 py-1 text-left normal-case"
|
||
@click.stop="setInboxHidden(inbox.id, !inbox.isHidden)"
|
||
>
|
||
<span class="min-w-0 truncate">{{ formatInboxLabel(inbox) }}</span>
|
||
<span class="shrink-0 text-[10px] text-base-content/70">
|
||
{{
|
||
isInboxToggleLoading(inbox.id)
|
||
? "..."
|
||
: inbox.isHidden
|
||
? "Hidden"
|
||
: "Visible"
|
||
}}
|
||
</span>
|
||
</button>
|
||
</div>
|
||
<p v-else class="px-1 py-1 text-[11px] text-base-content/60">No sources.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<article class="h-full min-h-0 border-r border-base-300 flex flex-col">
|
||
<div v-if="false" class="p-3">
|
||
<div class="grid grid-cols-[auto_1fr_auto] items-center gap-2">
|
||
<div class="flex items-center gap-1">
|
||
<button class="btn btn-xs" @click="setToday">Today</button>
|
||
<button class="btn btn-xs btn-ghost" @click="shiftCalendar(-1)">←</button>
|
||
<button class="btn btn-xs btn-ghost" @click="shiftCalendar(1)">→</button>
|
||
</div>
|
||
|
||
<div class="text-center text-sm font-medium">
|
||
{{ calendarPeriodLabel }}
|
||
</div>
|
||
|
||
<div class="justify-self-end">
|
||
<select v-model="calendarView" class="select select-bordered select-xs w-36">
|
||
<option
|
||
v-for="option in calendarViewOptions"
|
||
:key="`workspace-right-calendar-view-${option.value}`"
|
||
:value="option.value"
|
||
>
|
||
{{ option.label }}
|
||
</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="calendarView === 'month'" class="mt-3 space-y-1">
|
||
<div class="grid grid-cols-7 gap-1 text-center text-xs font-semibold text-base-content/60">
|
||
<span>Sun</span>
|
||
<span>Mon</span>
|
||
<span>Tue</span>
|
||
<span>Wed</span>
|
||
<span>Thu</span>
|
||
<span>Fri</span>
|
||
<span>Sat</span>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-7 gap-1">
|
||
<button
|
||
v-for="cell in monthCells"
|
||
:key="`workspace-right-month-${cell.key}`"
|
||
class="min-h-24 rounded-lg border p-1 text-left"
|
||
:class="[
|
||
cell.inMonth ? 'border-base-300 bg-base-100' : 'border-base-200 bg-base-200/40 text-base-content/40',
|
||
selectedDateKey === cell.key ? 'border-primary bg-primary/5' : '',
|
||
]"
|
||
@click="pickDate(cell.key)"
|
||
>
|
||
<p class="mb-1 text-xs font-semibold">{{ cell.day }}</p>
|
||
<button
|
||
v-for="event in cell.events.slice(0, 2)"
|
||
:key="`workspace-right-month-event-${event.id}`"
|
||
class="block w-full truncate text-left text-[10px] text-base-content/70 hover:underline"
|
||
@click.stop="openThreadFromCalendarItem(event)"
|
||
>
|
||
{{ formatTime(event.start) }} {{ event.title }}
|
||
</button>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else-if="calendarView === 'week'" class="mt-3 space-y-2">
|
||
<article
|
||
v-for="day in weekDays"
|
||
:key="`workspace-right-week-${day.key}`"
|
||
class="rounded-xl border border-base-300 p-3"
|
||
:class="selectedDateKey === day.key ? 'border-primary bg-primary/5' : ''"
|
||
@click="pickDate(day.key)"
|
||
>
|
||
<p class="mb-2 text-sm font-semibold">{{ day.label }} {{ day.day }}</p>
|
||
<div class="space-y-1">
|
||
<button
|
||
v-for="event in day.events"
|
||
:key="`workspace-right-week-event-${event.id}`"
|
||
class="block w-full rounded bg-base-200 px-2 py-1 text-left text-xs hover:bg-base-300/80"
|
||
@click.stop="openThreadFromCalendarItem(event)"
|
||
>
|
||
{{ formatTime(event.start) }} - {{ event.title }} ({{ event.contact }})
|
||
</button>
|
||
<p v-if="day.events.length === 0" class="text-xs text-base-content/50">No events</p>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
|
||
<div v-else-if="calendarView === 'day'" class="mt-3 space-y-2">
|
||
<button
|
||
v-for="event in selectedDayEvents"
|
||
:key="`workspace-right-day-event-${event.id}`"
|
||
class="block w-full rounded-xl border border-base-300 p-3 text-left hover:bg-base-200/60"
|
||
@click="openThreadFromCalendarItem(event)"
|
||
>
|
||
<p class="font-medium">{{ event.title }}</p>
|
||
<p class="text-xs text-base-content/60">{{ event.contact }}</p>
|
||
<p class="text-xs text-base-content/60">{{ formatTime(event.start) }} - {{ formatTime(event.end) }}</p>
|
||
<p class="mt-1 text-sm text-base-content/80">{{ event.note }}</p>
|
||
</button>
|
||
<p v-if="selectedDayEvents.length === 0" class="text-sm text-base-content/60">No events on this day.</p>
|
||
</div>
|
||
|
||
<div v-else-if="calendarView === 'year'" class="mt-3 grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||
<button
|
||
v-for="item in yearMonths"
|
||
:key="`workspace-right-year-${item.monthIndex}`"
|
||
class="rounded-xl border border-base-300 p-3 text-left transition hover:border-primary/50 hover:bg-primary/5"
|
||
@click="openYearMonth(item.monthIndex)"
|
||
>
|
||
<p class="font-medium">{{ item.label }}</p>
|
||
<p class="text-xs text-base-content/60">{{ item.count }} events</p>
|
||
<p v-if="item.first" class="mt-1 text-xs text-base-content/70">
|
||
{{ formatYearMonthFirst(item) }}
|
||
</p>
|
||
</button>
|
||
</div>
|
||
|
||
<div v-else class="mt-3 space-y-2">
|
||
<button
|
||
v-for="event in sortedEvents"
|
||
:key="`workspace-right-agenda-${event.id}`"
|
||
class="block w-full rounded-xl border border-base-300 p-3 text-left hover:bg-base-200/60"
|
||
@click="openThreadFromCalendarItem(event)"
|
||
>
|
||
<p class="font-medium">{{ event.title }}</p>
|
||
<p class="text-xs text-base-content/60">{{ event.contact }}</p>
|
||
<p class="text-xs text-base-content/60">{{ formatDay(event.start) }} · {{ formatTime(event.start) }} - {{ formatTime(event.end) }}</p>
|
||
<p class="mt-1 text-sm text-base-content/80">{{ event.note }}</p>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else-if="selectedCommThread" class="relative flex h-full min-h-0 flex-col">
|
||
<div class="comm-thread-surface min-h-0 flex-1 space-y-2 overflow-y-auto px-3 pb-2">
|
||
<button
|
||
class="sticky top-0 z-10 -mx-3 mb-2 flex w-[calc(100%+1.5rem)] items-center gap-2 border-b border-base-300 bg-base-100/80 px-3 py-2 text-left backdrop-blur-sm transition hover:bg-base-100"
|
||
@click="commPinnedOnly = !commPinnedOnly"
|
||
>
|
||
<svg viewBox="0 0 24 24" class="h-4 w-4 shrink-0 fill-current text-base-content/75">
|
||
<path d="M14 3a1 1 0 0 0-1 1v4.59l-1.7 1.7A2 2 0 0 0 10.7 12H8v2h2.7a2 2 0 0 0 .6 1.41L13 17.1V21l2-1.2v-2.7l1.7-1.7A2 2 0 0 0 17.3 14H20v-2h-2.7a2 2 0 0 0-.6-1.41L15 8.9V4a1 1 0 0 0-1-1Z" />
|
||
</svg>
|
||
<span class="min-w-0 flex-1 truncate text-xs text-base-content/80">{{ latestPinnedLabel }}</span>
|
||
<span class="shrink-0 text-xs text-base-content/75">{{ selectedCommPinnedStream.length }}</span>
|
||
</button>
|
||
|
||
<div
|
||
v-for="entry in (commPinnedOnly ? selectedCommPinnedStream : threadStreamItems)"
|
||
:key="entry.id"
|
||
@contextmenu.prevent="openCommPinContextMenu($event, entry)"
|
||
>
|
||
<div
|
||
v-if="entry.kind === 'pin'"
|
||
class="flex"
|
||
:class="entry.sourceItem ? (entry.sourceItem.direction === 'out' ? 'justify-end' : 'justify-start') : 'justify-center'"
|
||
>
|
||
<div
|
||
class="max-w-[88%] rounded-xl border border-base-300 p-3"
|
||
:class="entry.sourceItem?.direction === 'out' ? 'bg-base-200' : 'bg-base-100'"
|
||
>
|
||
<p class="text-sm">{{ stripPinnedPrefix(entry.text) }}</p>
|
||
<p class="mt-1 text-xs text-base-content/60">
|
||
<span class="mr-1 inline-flex h-4 w-4 items-center justify-center align-middle">
|
||
<svg viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
|
||
<path d="M14 3a1 1 0 0 0-1 1v4.59l-1.7 1.7A2 2 0 0 0 10.7 12H8v2h2.7a2 2 0 0 0 .6 1.41L13 17.1V21l2-1.2v-2.7l1.7-1.7A2 2 0 0 0 17.3 14H20v-2h-2.7a2 2 0 0 0-.6-1.41L15 8.9V4a1 1 0 0 0-1-1Z" />
|
||
</svg>
|
||
</span>
|
||
<span>{{ entry.sourceItem ? formatStamp(entry.sourceItem.at) : "Pinned" }}</span>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
v-else-if="entry.kind === 'call'"
|
||
class="flex"
|
||
:class="entry.item.direction === 'out' ? 'justify-end' : 'justify-start'"
|
||
>
|
||
<div
|
||
class="call-wave-card w-full max-w-[460px] rounded-2xl border border-base-300 px-4 py-3"
|
||
:class="[
|
||
entry.item.direction === 'out' ? 'bg-base-200' : 'bg-base-100',
|
||
isReviewHighlightedMessage(entry.item.id) ? 'border-success/60 bg-success/10 ring-2 ring-success/40' : '',
|
||
]"
|
||
>
|
||
<p class="mb-2 text-xs text-base-content/65">
|
||
<span class="font-semibold">{{ entry.item.direction === "out" ? "You" : selectedCommThread?.contact || "Contact" }}</span>
|
||
<span class="mx-1">·</span>
|
||
{{ formatDay(entry.item.at) }} · {{ formatTime(entry.item.at) }}
|
||
<span v-if="entry.item.duration"> · {{ entry.item.duration }}</span>
|
||
</p>
|
||
<div class="comm-call-wave-wrap mb-2">
|
||
<div class="comm-call-wave" :ref="(el) => setCommCallWaveHost(entry.item.id, el as Element | null)" />
|
||
<button
|
||
class="call-wave-center-play"
|
||
:disabled="!isCommCallPlayable(entry.item)"
|
||
:title="isCommCallPlayable(entry.item) ? 'Play voice message' : 'Audio unavailable'"
|
||
@click="toggleCommCallPlayback(entry.item)"
|
||
>
|
||
<svg v-if="!isCommCallPlaying(entry.item.id)" viewBox="0 0 20 20" class="h-4 w-4">
|
||
<path
|
||
fill="currentColor"
|
||
d="M6.5 4.75a.75.75 0 0 1 1.12-.65l7.5 4.25a.75.75 0 0 1 0 1.3l-7.5 4.25a.75.75 0 0 1-1.12-.65v-8.5Z"
|
||
/>
|
||
</svg>
|
||
<svg v-else viewBox="0 0 20 20" class="h-4 w-4">
|
||
<path
|
||
fill="currentColor"
|
||
d="M6.75 4.5a.75.75 0 0 1 .75.75v9.5a.75.75 0 0 1-1.5 0v-9.5a.75.75 0 0 1 .75-.75Zm6.5 0a.75.75 0 0 1 .75.75v9.5a.75.75 0 0 1-1.5 0v-9.5a.75.75 0 0 1 .75-.75Z"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="mt-2 flex" :class="entry.item.direction === 'out' ? 'justify-end' : 'justify-start'">
|
||
<button class="call-transcript-toggle" @click="toggleCallTranscript(entry.item)">
|
||
<span>
|
||
{{
|
||
callTranscriptLoading[entry.item.id]
|
||
? "Generating transcript..."
|
||
: isCallTranscriptOpen(entry.item.id)
|
||
? "Hide transcript"
|
||
: "Show transcript"
|
||
}}
|
||
</span>
|
||
<svg
|
||
viewBox="0 0 20 20"
|
||
class="h-3.5 w-3.5 transition-transform"
|
||
:class="isCallTranscriptOpen(entry.item.id) ? 'rotate-180' : ''"
|
||
>
|
||
<path
|
||
fill="currentColor"
|
||
fill-rule="evenodd"
|
||
clip-rule="evenodd"
|
||
d="M5.22 7.22a.75.75 0 0 1 1.06 0L10 10.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 8.28a.75.75 0 0 1 0-1.06Z"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<transition name="accordion">
|
||
<div v-if="isCallTranscriptOpen(entry.item.id)" class="mt-2 rounded-xl border border-base-300 bg-base-100 p-2 text-left">
|
||
<div v-if="callTranscriptLoading[entry.item.id]" class="call-transcript-loader" aria-live="polite" aria-busy="true">
|
||
<span />
|
||
<span />
|
||
<span />
|
||
<span />
|
||
</div>
|
||
<div v-else-if="callTranscriptError[entry.item.id]" class="space-y-2">
|
||
<p class="text-xs leading-relaxed text-error">
|
||
{{ callTranscriptError[entry.item.id] }}
|
||
</p>
|
||
<button class="btn btn-xs btn-outline" @click="transcribeCallItem(entry.item)">Retry</button>
|
||
</div>
|
||
<p v-else class="text-xs leading-relaxed text-base-content/80">
|
||
{{ callTranscriptText[entry.item.id] || "No transcript yet" }}
|
||
</p>
|
||
</div>
|
||
</transition>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else-if="entry.kind === 'eventLifecycle'" class="flex justify-center">
|
||
<article
|
||
class="w-full max-w-[460px] rounded-xl border p-3 text-center"
|
||
:class="[eventPhaseToneClass(entry.phase), isReviewHighlightedEvent(entry.event.id) ? 'ring-2 ring-success/45' : '']"
|
||
>
|
||
<p class="text-xs text-base-content/70">
|
||
{{ eventRelativeLabel(entry.event, lifecycleNowMs) }} · {{ formatDay(entry.event.start) }} {{ formatTime(entry.event.start) }}
|
||
</p>
|
||
<p class="mt-1 text-sm text-base-content/90">{{ entry.event.note || entry.event.title }}</p>
|
||
<p v-if="entry.event.archiveNote" class="mt-2 text-xs text-base-content/70">Archive note: {{ entry.event.archiveNote }}</p>
|
||
|
||
<div v-if="canManuallyCloseEvent(entry)" class="mt-2">
|
||
<button class="btn btn-xs btn-outline" @click="toggleEventClose(entry.event.id)">
|
||
{{ isEventCloseOpen(entry.event.id) ? "Cancel" : "Archive event" }}
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="canManuallyCloseEvent(entry) && isEventCloseOpen(entry.event.id)" class="mt-2 space-y-2 text-left">
|
||
<textarea
|
||
v-model="eventCloseDraft[entry.event.id]"
|
||
class="textarea textarea-bordered w-full text-xs"
|
||
rows="3"
|
||
placeholder="Archive note (optional)"
|
||
/>
|
||
<div class="flex justify-between gap-2">
|
||
<button
|
||
class="btn btn-xs btn-outline"
|
||
:disabled="isEventArchiveTranscribing(entry.event.id)"
|
||
@click="toggleEventArchiveRecording(entry.event.id)"
|
||
>
|
||
{{
|
||
isEventArchiveTranscribing(entry.event.id)
|
||
? "Transcribing..."
|
||
: isEventArchiveRecording(entry.event.id)
|
||
? "Stop mic"
|
||
: "Voice note"
|
||
}}
|
||
</button>
|
||
</div>
|
||
<p v-if="eventArchiveMicErrorById[entry.event.id]" class="text-xs text-error">{{ eventArchiveMicErrorById[entry.event.id] }}</p>
|
||
<p v-if="eventCloseError[entry.event.id]" class="text-xs text-error">{{ eventCloseError[entry.event.id] }}</p>
|
||
<div class="flex justify-end">
|
||
<button
|
||
class="btn btn-xs"
|
||
:disabled="eventCloseSaving[entry.event.id]"
|
||
@click="archiveEventManually(entry.event)"
|
||
>
|
||
{{ eventCloseSaving[entry.event.id] ? "Saving..." : "Confirm archive" }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
|
||
<div v-else-if="entry.kind === 'document'" class="flex justify-center">
|
||
<article class="w-full max-w-[460px] rounded-xl border border-base-300 bg-base-100 p-3 text-left">
|
||
<p class="text-xs text-base-content/65">Document · {{ formatStamp(entry.at) }}</p>
|
||
<p class="mt-1 text-sm font-semibold text-base-content">{{ entry.document.title }}</p>
|
||
<p class="mt-1 text-xs text-base-content/70">
|
||
{{ formatDocumentScope(entry.document.scope) }} · {{ entry.document.owner }}
|
||
</p>
|
||
<p class="mt-2 text-sm text-base-content/85">{{ entry.document.summary }}</p>
|
||
</article>
|
||
</div>
|
||
|
||
<div v-else-if="entry.kind === 'recommendation'" class="flex justify-center">
|
||
<article class="w-full max-w-[460px] rounded-xl border border-base-300 bg-base-100 p-3">
|
||
<p class="text-sm">{{ entry.card.text }}</p>
|
||
|
||
<div class="mt-2 rounded-lg border border-base-300 bg-base-200/30 p-2">
|
||
<p class="text-xs font-semibold uppercase tracking-wide text-base-content/70">{{ entry.card.proposal.title }}</p>
|
||
<p
|
||
v-for="line in entry.card.proposal.details"
|
||
:key="`${entry.card.id}-${line}`"
|
||
class="mt-1 text-xs text-base-content/80"
|
||
>
|
||
{{ line }}
|
||
</p>
|
||
</div>
|
||
|
||
<div v-if="entry.card.decision === 'pending'" class="mt-2 flex gap-2">
|
||
<button class="btn btn-xs flex-1" @click="decideFeedCard(entry.card, 'accepted')">Yes</button>
|
||
<button class="btn btn-xs btn-outline flex-1" @click="decideFeedCard(entry.card, 'rejected')">No</button>
|
||
</div>
|
||
<p v-else class="mt-2 text-xs text-base-content/70">{{ entry.card.decisionNote }}</p>
|
||
</article>
|
||
</div>
|
||
|
||
<div
|
||
v-else
|
||
class="flex"
|
||
:class="entry.item.direction === 'out' ? 'justify-end' : 'justify-start'"
|
||
>
|
||
<div
|
||
class="max-w-[88%] rounded-xl border border-base-300 p-3"
|
||
:class="[
|
||
entry.item.direction === 'out' ? 'bg-base-200' : 'bg-base-100',
|
||
isReviewHighlightedMessage(entry.item.id) ? 'border-success/60 bg-success/10 ring-2 ring-success/40' : '',
|
||
]"
|
||
>
|
||
<p class="text-sm">{{ entry.item.text }}</p>
|
||
<p class="mt-1 text-xs text-base-content/60">
|
||
<span class="mr-1 inline-flex h-4 w-4 items-center justify-center align-middle">
|
||
<svg v-if="channelIcon(entry.item.channel) === 'telegram'" viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
|
||
<path d="M9.04 15.51 8.7 20.27c.49 0 .7-.21.96-.46l2.3-2.2 4.77 3.49c.88.49 1.5.23 1.74-.81l3.15-14.77.01-.01c.29-1.35-.49-1.88-1.35-1.56L1.74 11.08c-1.28.5-1.26 1.22-.22 1.54l4.74 1.48L17.3 7.03c.52-.34 1-.15.61.19" />
|
||
</svg>
|
||
<svg v-else-if="channelIcon(entry.item.channel) === 'whatsapp'" viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
|
||
<path d="M20 3.99A9.94 9.94 0 0 0 12.01 1C6.49 1 2 5.49 2 11c0 1.76.46 3.49 1.33 5.03L2 23l7.17-1.88A9.95 9.95 0 0 0 12 21h.01c5.51 0 9.99-4.49 9.99-10.01 0-2.67-1.04-5.18-2.99-7m-7.99 15.32h-.01a8.3 8.3 0 0 1-4.23-1.16l-.3-.18-4.26 1.12 1.14-4.15-.2-.32a8.28 8.28 0 0 1-1.27-4.4c0-4.58 3.73-8.31 8.32-8.31a8.27 8.27 0 0 1 5.88 2.44 8.25 8.25 0 0 1 2.43 5.87c0 4.59-3.73 8.32-8.31 8.32m4.56-6.23c-.25-.12-1.49-.74-1.73-.82-.23-.09-.4-.12-.57.12s-.66.82-.81.99-.3.18-.55.06a6.7 6.7 0 0 1-1.97-1.21 7.43 7.43 0 0 1-1.38-1.71c-.14-.24-.01-.37.1-.49.11-.11.24-.3.36-.45.12-.14.16-.24.25-.39.08-.18.05-.3-.02-.42-.07-.12-.56-1.35-.77-1.85-.2-.48-.41-.41-.57-.42h-.48c-.16 0-.42.06-.64.3-.22.24-.84.82-.84 2s.86 2.31.98 2.48c.12.16 1.69 2.57 4.09 3.6.57.24 1.01.38 1.36.48.58.18 1.11.15 1.52.09.46-.06 1.49-.61 1.7-1.19.21-.58.21-1.09.15-1.19-.06-.11-.23-.17-.48-.3" />
|
||
</svg>
|
||
<svg v-else-if="channelIcon(entry.item.channel) === 'instagram'" viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
|
||
<path d="M7 2h10a5 5 0 0 1 5 5v10a5 5 0 0 1-5 5H7a5 5 0 0 1-5-5V7a5 5 0 0 1 5-5m10 2H7a3 3 0 0 0-3 3v10a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V7a3 3 0 0 0-3-3m-5 3.5A4.5 4.5 0 1 1 7.5 12 4.5 4.5 0 0 1 12 7.5m0 2A2.5 2.5 0 1 0 14.5 12 2.5 2.5 0 0 0 12 9.5m4.8-3.2a1.2 1.2 0 1 1-1.2 1.2 1.2 1.2 0 0 1 1.2-1.2" />
|
||
</svg>
|
||
<svg v-else-if="channelIcon(entry.item.channel) === 'email'" viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
|
||
<path d="M20 4H4a2 2 0 0 0-2 2v.4l10 5.6 10-5.6V6a2 2 0 0 0-2-2m0 4.2-7.4 4.14a1.25 1.25 0 0 1-1.2 0L4 8.2V18a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2z" />
|
||
</svg>
|
||
<svg v-else viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
|
||
<path d="M6.62 10.79a15.47 15.47 0 0 0 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1C10.07 21 3 13.93 3 5c0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.24.2 2.45.57 3.57.11.35.03.74-.25 1.02z" />
|
||
</svg>
|
||
</span>
|
||
<span>{{ formatStamp(entry.item.at) }}</span>
|
||
<span
|
||
v-if="messageDeliveryUiState(entry.item) !== 'none'"
|
||
class="ml-1 inline-flex items-center align-middle text-base-content/70"
|
||
:title="messageDeliveryLabel(entry.item)"
|
||
>
|
||
<span
|
||
v-if="messageDeliveryUiState(entry.item) === 'sending'"
|
||
class="inline-block h-2.5 w-2.5 animate-spin rounded-full border border-current border-t-transparent"
|
||
/>
|
||
<span
|
||
v-else-if="messageDeliveryUiState(entry.item) === 'sent'"
|
||
class="text-[10px] leading-none"
|
||
>
|
||
✓
|
||
</span>
|
||
<span
|
||
v-else-if="messageDeliveryUiState(entry.item) === 'delivered'"
|
||
class="text-[10px] leading-none tracking-[-0.12em]"
|
||
>
|
||
✓✓
|
||
</span>
|
||
<span
|
||
v-else-if="messageDeliveryUiState(entry.item) === 'failed'"
|
||
class="text-[10px] font-semibold leading-none text-error"
|
||
>
|
||
!
|
||
</span>
|
||
</span>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
v-if="commPinContextMenu.open"
|
||
class="comm-pin-context-menu"
|
||
:style="{ left: `${commPinContextMenu.x}px`, top: `${commPinContextMenu.y}px` }"
|
||
@click.stop
|
||
>
|
||
<button
|
||
class="comm-pin-context-menu-item"
|
||
:disabled="commPinToggling"
|
||
@click="applyCommPinContextAction"
|
||
>
|
||
{{ commPinContextActionLabel }}
|
||
</button>
|
||
</div>
|
||
|
||
<div class="sticky bottom-0 z-10 mt-0 border-t border-base-300 bg-base-100/95 px-3 pt-3 backdrop-blur">
|
||
<div class="absolute left-1/2 top-0 z-20 -translate-x-1/2 -translate-y-1/2">
|
||
<div
|
||
class="dropdown dropdown-top dropdown-center"
|
||
:class="{ 'dropdown-open': commQuickMenuOpen }"
|
||
@focusout="closeCommQuickMenu"
|
||
>
|
||
<button
|
||
tabindex="0"
|
||
type="button"
|
||
class="btn btn-sm btn-circle border border-base-300 bg-base-100 text-base-content/85 hover:bg-base-200"
|
||
title="Add item"
|
||
@click.stop="toggleCommQuickMenu"
|
||
>
|
||
+
|
||
</button>
|
||
<ul tabindex="0" class="dropdown-content menu menu-sm mb-2 w-56 rounded-xl border border-base-300 bg-base-100 p-2 shadow-xl">
|
||
<li>
|
||
<button @click="openCommEventModal('planned')">
|
||
Plan event
|
||
</button>
|
||
</li>
|
||
<li>
|
||
<button @click="openCommEventModal('logged')">
|
||
Log past event
|
||
</button>
|
||
</li>
|
||
<li>
|
||
<button @click="openCommDocumentModal">
|
||
Attach document
|
||
</button>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
<div
|
||
class="comm-input-wrap"
|
||
:class="[
|
||
contextPickerEnabled ? 'context-scope-block context-scope-block-active' : '',
|
||
hasContextScope('message') ? 'context-scope-block-selected' : '',
|
||
]"
|
||
@click="toggleContextScope('message')"
|
||
>
|
||
<span v-if="contextPickerEnabled" class="context-scope-label">Работа с пользователем</span>
|
||
<div class="comm-input-shell">
|
||
<textarea
|
||
v-model="commDraft"
|
||
class="comm-input-textarea"
|
||
:placeholder="commComposerPlaceholder()"
|
||
:disabled="commSending || commEventSaving"
|
||
@keydown.enter="handleCommComposerEnter"
|
||
/>
|
||
|
||
<div v-if="commComposerMode === 'planned' || commComposerMode === 'logged'" class="comm-event-controls">
|
||
<input
|
||
v-model="commEventForm.startDate"
|
||
type="date"
|
||
class="input input-bordered input-xs h-7 min-h-7"
|
||
:disabled="commEventSaving"
|
||
>
|
||
<input
|
||
v-model="commEventForm.startTime"
|
||
type="time"
|
||
class="input input-bordered input-xs h-7 min-h-7"
|
||
:disabled="commEventSaving"
|
||
>
|
||
<select
|
||
v-model.number="commEventForm.durationMinutes"
|
||
class="select select-bordered select-xs h-7 min-h-7"
|
||
:disabled="commEventSaving"
|
||
>
|
||
<option :value="15">15m</option>
|
||
<option :value="30">30m</option>
|
||
<option :value="45">45m</option>
|
||
<option :value="60">60m</option>
|
||
<option :value="90">90m</option>
|
||
</select>
|
||
</div>
|
||
<div v-else-if="commComposerMode === 'document'" class="comm-event-controls">
|
||
<input
|
||
v-model="commDocumentForm.title"
|
||
type="text"
|
||
class="input input-bordered input-xs h-7 min-h-7 flex-1"
|
||
:disabled="commEventSaving"
|
||
placeholder="Document title (optional)"
|
||
>
|
||
</div>
|
||
|
||
<p v-if="commEventError && commComposerMode !== 'message'" class="comm-event-error text-xs text-error">
|
||
{{ commEventError }}
|
||
</p>
|
||
<p v-if="commMicError" class="comm-mic-error text-xs text-error">
|
||
{{ commMicError }}
|
||
</p>
|
||
|
||
<div v-if="commComposerMode === 'message'" class="comm-input-channel dropdown dropdown-top not-prose">
|
||
<button
|
||
tabindex="0"
|
||
class="btn btn-ghost btn-xs h-7 min-h-7 px-1 text-xs font-medium"
|
||
:disabled="commSending"
|
||
:title="`Channel: ${commSendChannel}`"
|
||
>
|
||
<span class="mr-1">{{ commSendChannel || "Channel" }}</span>
|
||
<svg viewBox="0 0 20 20" class="h-3.5 w-3.5 fill-current">
|
||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" />
|
||
</svg>
|
||
</button>
|
||
<ul tabindex="-1" class="dropdown-content menu menu-sm bg-base-200 rounded-box my-2 w-40 border border-base-300 shadow-2xl">
|
||
<li v-for="channel in commSendChannelOptions" :key="`comm-send-menu-${channel}`">
|
||
<button @click="commSendChannel = channel">
|
||
<span>{{ channel }}</span>
|
||
<span v-if="commSendChannel === channel">✓</span>
|
||
</button>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="comm-input-actions">
|
||
<button
|
||
v-if="commComposerMode !== 'message'"
|
||
class="btn btn-xs btn-circle border border-base-300 bg-base-100 text-base-content/80 hover:bg-base-200"
|
||
:disabled="commEventSaving"
|
||
title="Back to message"
|
||
@click="closeCommEventModal"
|
||
>
|
||
<svg viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
|
||
<path d="M20 11H7.83l4.58-4.59L11 5l-7 7 7 7 1.41-1.41L7.83 13H20z" />
|
||
</svg>
|
||
</button>
|
||
<CrmVoiceDictationButton
|
||
class="btn btn-xs btn-circle border border-base-300 bg-base-100 text-base-content/80 hover:bg-base-200"
|
||
:class="commRecording || commTranscribing ? 'comm-mic-active' : ''"
|
||
:disabled="commSending || commEventSaving"
|
||
:session-key="selectedCommThreadId"
|
||
idle-title="Voice input"
|
||
recording-title="Stop and insert transcript"
|
||
transcribing-title="Transcribing..."
|
||
@update:recording="commRecording = $event"
|
||
@update:transcribing="commTranscribing = $event"
|
||
@transcript="onCommDictationTranscript"
|
||
@error="commMicError = $event"
|
||
/>
|
||
|
||
<button
|
||
class="btn btn-sm btn-circle border-0 bg-[#5865f2] text-white hover:bg-[#4752c4]"
|
||
:disabled="commSending || commEventSaving || commRecording || commTranscribing || !commDraft.trim() || (commComposerMode === 'message' && !commSendChannel)"
|
||
:title="
|
||
commComposerMode === 'message'
|
||
? `Send via ${commSendChannel}`
|
||
: commComposerMode === 'logged'
|
||
? 'Save log event'
|
||
: commComposerMode === 'document'
|
||
? 'Save document'
|
||
: 'Create event'
|
||
"
|
||
@click="handleCommComposerSubmit"
|
||
>
|
||
<svg viewBox="0 0 24 24" class="h-4 w-4 fill-current" :class="commSending ? 'opacity-50' : ''">
|
||
<path d="M4.5 19.5 21 12 4.5 4.5l.02 5.84L15 12l-10.48 1.66z" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else class="flex h-full items-center justify-center text-sm text-base-content/60">
|
||
No communication history.
|
||
</div>
|
||
</article>
|
||
|
||
<CrmCommunicationsContextSidebar
|
||
:selected-workspace-contact-documents="selectedWorkspaceContactDocuments"
|
||
:contact-right-panel-mode="contactRightPanelMode"
|
||
:on-contact-right-panel-mode-change="(mode) => { contactRightPanelMode = mode; }"
|
||
:selected-document-id="selectedDocumentId"
|
||
:on-selected-document-id-change="(documentId) => { selectedDocumentId = documentId; }"
|
||
:contact-documents-search="contactDocumentsSearch"
|
||
:on-contact-documents-search-input="(value) => { contactDocumentsSearch = value; }"
|
||
:filtered-selected-workspace-contact-documents="filteredSelectedWorkspaceContactDocuments"
|
||
:format-stamp="formatStamp"
|
||
:open-documents-tab="openDocumentsTab"
|
||
:selected-workspace-deal="selectedWorkspaceDeal"
|
||
:is-review-highlighted-deal="isReviewHighlightedDeal"
|
||
:context-picker-enabled="contextPickerEnabled"
|
||
:has-context-scope="hasContextScope"
|
||
:toggle-context-scope="toggleContextScope"
|
||
:format-deal-headline="formatDealHeadline"
|
||
:selected-workspace-deal-subtitle="selectedWorkspaceDealSubtitle"
|
||
:selected-workspace-deal-steps="selectedWorkspaceDealSteps"
|
||
:selected-deal-steps-expanded="selectedDealStepsExpanded"
|
||
:on-selected-deal-steps-expanded-change="(value) => { selectedDealStepsExpanded = value; }"
|
||
:is-deal-step-done="isDealStepDone"
|
||
:format-deal-step-meta="formatDealStepMeta"
|
||
:active-review-contact-diff="activeReviewContactDiff"
|
||
:selected-workspace-contact="selectedWorkspaceContact"
|
||
/>
|
||
</div>
|
||
</section>
|
||
|
||
<CrmDocumentsPanel
|
||
v-else-if="selectedTab === 'documents'"
|
||
:document-search="documentSearch"
|
||
:document-sort-mode="documentSortMode"
|
||
:document-sort-options="documentSortOptions"
|
||
:filtered-documents="filteredDocuments"
|
||
:selected-document-id="selectedDocumentId"
|
||
:selected-document="selectedDocument"
|
||
:format-document-scope="formatDocumentScope"
|
||
:format-stamp="formatStamp"
|
||
@update:document-search="documentSearch = $event"
|
||
@update:document-sort-mode="documentSortMode = $event"
|
||
@select-document="selectedDocumentId = $event"
|
||
@update-selected-document-body="updateSelectedDocumentBody"
|
||
@delete-document="deleteWorkspaceDocumentById"
|
||
/>
|
||
|
||
<CrmChangeReviewOverlay
|
||
:visible="reviewActive && selectedTab === 'communications'"
|
||
:active-change-step-number="activeChangeStepNumber"
|
||
:active-change-items="activeChangeItems"
|
||
:active-change-item="activeChangeItem"
|
||
:active-change-index="activeChangeIndex"
|
||
:rollbackable-count="rollbackableCount"
|
||
:change-action-busy="changeActionBusy"
|
||
:describe-change-entity="describeChangeEntity"
|
||
:describe-change-action="describeChangeAction"
|
||
@close="finishReview(true)"
|
||
@open-item-target="openChangeItemTarget"
|
||
@rollback-item="rollbackChangeItemById"
|
||
@rollback-all="rollbackSelectedChangeItems"
|
||
@prev-step="goToPreviousChangeStep"
|
||
@next-step="goToNextChangeStep"
|
||
@done="finishReview(true)"
|
||
/>
|
||
|
||
</div>
|
||
|
||
</div>
|
||
</main>
|
||
</div>
|
||
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.comm-input-wrap {
|
||
display: grid;
|
||
gap: 6px;
|
||
}
|
||
|
||
.comm-input-shell {
|
||
position: relative;
|
||
}
|
||
|
||
.comm-input-textarea {
|
||
width: 100%;
|
||
min-height: 96px;
|
||
resize: none;
|
||
border-radius: 0;
|
||
border: 0;
|
||
background: transparent;
|
||
color: var(--color-base-content);
|
||
padding: 10px 88px 36px 12px;
|
||
font-size: 13px;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.comm-event-controls {
|
||
position: absolute;
|
||
left: 10px;
|
||
bottom: 8px;
|
||
display: grid;
|
||
grid-template-columns: 118px 88px 64px;
|
||
gap: 6px;
|
||
align-items: center;
|
||
}
|
||
|
||
.comm-event-controls :is(input, select) {
|
||
font-size: 11px;
|
||
padding-inline: 8px;
|
||
}
|
||
|
||
.comm-event-error {
|
||
position: absolute;
|
||
left: 12px;
|
||
top: 8px;
|
||
}
|
||
|
||
.comm-mic-error {
|
||
position: absolute;
|
||
left: 12px;
|
||
top: 8px;
|
||
max-width: 65%;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.comm-input-textarea::placeholder {
|
||
color: color-mix(in oklab, var(--color-base-content) 45%, transparent);
|
||
}
|
||
|
||
.comm-input-textarea:focus {
|
||
outline: none;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.comm-input-channel {
|
||
position: absolute;
|
||
left: 10px;
|
||
bottom: 8px;
|
||
}
|
||
|
||
.comm-input-actions {
|
||
position: absolute;
|
||
right: 10px;
|
||
bottom: 8px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.comm-mic-active {
|
||
border-color: rgba(255, 95, 95, 0.7) !important;
|
||
background: rgba(255, 95, 95, 0.12) !important;
|
||
color: rgba(185, 30, 30, 0.9) !important;
|
||
}
|
||
|
||
.comm-thread-surface {
|
||
background-color: #eaf3ff;
|
||
background-image:
|
||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='132' height='132' viewBox='0 0 132 132'%3E%3Cg fill='none' stroke='%2395acd3' stroke-width='1.2' stroke-linecap='round' stroke-linejoin='round' opacity='0.22'%3E%3Cpath d='M16 20h14a6 6 0 0 1 0 12h-7l-5 4v-4h-2a6 6 0 0 1 0-12z'/%3E%3Ccircle cx='92' cy='28' r='6'/%3E%3Cpath d='M88 62h18a5 5 0 0 1 0 10H96l-4 3v-3h-4a5 5 0 0 1 0-10z'/%3E%3Cpath d='M24 86h8m-4-4v8'/%3E%3Cpath d='M74 96l2.3 4.8 5.3.8-3.8 3.7.9 5.2-4.7-2.4-4.7 2.4.9-5.2-3.8-3.7 5.3-.8z'/%3E%3C/g%3E%3C/svg%3E");
|
||
background-size: 132px 132px;
|
||
background-repeat: repeat;
|
||
}
|
||
|
||
.comm-thread-surface::after {
|
||
content: "";
|
||
display: block;
|
||
height: 14px;
|
||
}
|
||
|
||
.comm-pin-context-menu {
|
||
position: fixed;
|
||
z-index: 60;
|
||
min-width: 128px;
|
||
border: 1px solid color-mix(in oklab, var(--color-base-content) 14%, transparent);
|
||
border-radius: 10px;
|
||
background: var(--color-base-100);
|
||
box-shadow: 0 16px 30px rgba(11, 23, 46, 0.22);
|
||
padding: 4px;
|
||
}
|
||
|
||
.comm-pin-context-menu-item {
|
||
width: 100%;
|
||
border: 0;
|
||
border-radius: 8px;
|
||
background: transparent;
|
||
color: color-mix(in oklab, var(--color-base-content) 88%, transparent);
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
line-height: 1.2;
|
||
text-align: left;
|
||
padding: 7px 9px;
|
||
transition: background-color 120ms ease;
|
||
}
|
||
|
||
.comm-pin-context-menu-item:hover:not(:disabled) {
|
||
background: color-mix(in oklab, var(--color-base-200) 82%, transparent);
|
||
}
|
||
|
||
.comm-pin-context-menu-item:disabled {
|
||
opacity: 0.55;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.comm-event-modal {
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 25;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 16px;
|
||
background: rgba(14, 22, 38, 0.42);
|
||
backdrop-filter: blur(2px);
|
||
}
|
||
|
||
.comm-event-modal-card {
|
||
width: min(520px, 100%);
|
||
border: 1px solid color-mix(in oklab, var(--color-base-content) 14%, transparent);
|
||
border-radius: 14px;
|
||
background: var(--color-base-100);
|
||
box-shadow: 0 24px 48px rgba(11, 23, 46, 0.25);
|
||
padding: 14px;
|
||
}
|
||
|
||
|
||
.feed-chart-wrap {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 12px;
|
||
background:
|
||
radial-gradient(circle at 20% 20%, rgba(30, 107, 255, 0.12), transparent 45%),
|
||
radial-gradient(circle at 80% 80%, rgba(30, 107, 255, 0.08), transparent 45%),
|
||
#f6f9ff;
|
||
border-bottom: 1px solid rgba(30, 107, 255, 0.15);
|
||
}
|
||
|
||
.feed-chart-bars {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
gap: 10px;
|
||
width: 100%;
|
||
max-width: 280px;
|
||
height: 100%;
|
||
}
|
||
|
||
.feed-chart-bars span {
|
||
flex: 1 1 0;
|
||
border-radius: 999px 999px 6px 6px;
|
||
background: linear-gradient(180deg, rgba(30, 107, 255, 0.9), rgba(30, 107, 255, 0.35));
|
||
}
|
||
|
||
.feed-chart-pie {
|
||
width: min(140px, 70%);
|
||
aspect-ratio: 1;
|
||
border-radius: 999px;
|
||
background: conic-gradient(
|
||
rgba(30, 107, 255, 0.92) 0 42%,
|
||
rgba(30, 107, 255, 0.55) 42% 73%,
|
||
rgba(30, 107, 255, 0.25) 73% 100%
|
||
);
|
||
box-shadow: 0 8px 24px rgba(30, 107, 255, 0.2);
|
||
}
|
||
|
||
.call-wave-card {
|
||
background: var(--color-base-100);
|
||
}
|
||
|
||
.comm-call-wave-wrap {
|
||
position: relative;
|
||
}
|
||
|
||
.comm-call-wave {
|
||
height: 30px;
|
||
width: 100%;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.comm-call-wave :deep(wave) {
|
||
display: block;
|
||
height: 100% !important;
|
||
}
|
||
|
||
.comm-call-wave :deep(canvas) {
|
||
height: 100% !important;
|
||
}
|
||
|
||
.call-wave-center-play {
|
||
position: absolute;
|
||
left: 50%;
|
||
top: 50%;
|
||
transform: translate(-50%, -50%);
|
||
z-index: 2;
|
||
width: 34px;
|
||
height: 34px;
|
||
border: 1px solid color-mix(in oklab, var(--color-base-content) 22%, transparent);
|
||
border-radius: 999px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: color-mix(in oklab, var(--color-base-content) 88%, transparent);
|
||
background: color-mix(in oklab, var(--color-base-100) 72%, transparent);
|
||
backdrop-filter: blur(2px);
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
transition: opacity 140ms ease, background-color 140ms ease;
|
||
}
|
||
|
||
.comm-call-wave-wrap:hover .call-wave-center-play,
|
||
.comm-call-wave-wrap:focus-within .call-wave-center-play {
|
||
opacity: 1;
|
||
pointer-events: auto;
|
||
}
|
||
|
||
.call-wave-center-play:hover:not(:disabled) {
|
||
background: color-mix(in oklab, var(--color-base-100) 58%, var(--color-base-200));
|
||
}
|
||
|
||
.call-wave-center-play:disabled {
|
||
opacity: 0.45;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.call-transcript-toggle {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
border: 1px solid color-mix(in oklab, var(--color-base-content) 18%, transparent);
|
||
border-radius: 999px;
|
||
padding: 4px 10px;
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
color: color-mix(in oklab, var(--color-base-content) 72%, transparent);
|
||
background: color-mix(in oklab, var(--color-base-100) 90%, transparent);
|
||
}
|
||
|
||
.call-transcript-toggle:hover {
|
||
background: color-mix(in oklab, var(--color-base-100) 72%, var(--color-base-200));
|
||
}
|
||
|
||
.call-transcript-loader {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
justify-content: center;
|
||
gap: 4px;
|
||
height: 28px;
|
||
}
|
||
|
||
.call-transcript-loader span {
|
||
display: block;
|
||
width: 4px;
|
||
border-radius: 999px;
|
||
background: color-mix(in oklab, var(--color-base-content) 40%, transparent);
|
||
animation: transcript-ladder 1s ease-in-out infinite;
|
||
}
|
||
|
||
.call-transcript-loader span:nth-child(1) {
|
||
height: 8px;
|
||
animation-delay: 0ms;
|
||
}
|
||
|
||
.call-transcript-loader span:nth-child(2) {
|
||
height: 14px;
|
||
animation-delay: 120ms;
|
||
}
|
||
|
||
.call-transcript-loader span:nth-child(3) {
|
||
height: 20px;
|
||
animation-delay: 240ms;
|
||
}
|
||
|
||
.call-transcript-loader span:nth-child(4) {
|
||
height: 14px;
|
||
animation-delay: 360ms;
|
||
}
|
||
|
||
@keyframes transcript-ladder {
|
||
0%, 100% {
|
||
transform: scaleY(0.55);
|
||
opacity: 0.45;
|
||
}
|
||
50% {
|
||
transform: scaleY(1);
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
.accordion-enter-active,
|
||
.accordion-leave-active {
|
||
transition: all 160ms ease;
|
||
}
|
||
|
||
.accordion-enter-from,
|
||
.accordion-leave-to {
|
||
opacity: 0;
|
||
transform: translateY(-4px);
|
||
}
|
||
|
||
.context-scope-block {
|
||
position: relative;
|
||
cursor: crosshair;
|
||
transition: box-shadow 220ms ease, outline-color 220ms ease, transform 220ms ease;
|
||
}
|
||
|
||
.context-scope-block-active {
|
||
outline: 2px solid color-mix(in oklab, var(--color-primary) 58%, transparent);
|
||
outline-offset: 2px;
|
||
box-shadow: 0 0 0 1px color-mix(in oklab, var(--color-primary) 30%, transparent) inset;
|
||
}
|
||
|
||
.context-scope-block-selected {
|
||
outline: 2px solid color-mix(in oklab, var(--color-primary) 72%, transparent);
|
||
outline-offset: 2px;
|
||
box-shadow: 0 0 0 2px color-mix(in oklab, var(--color-primary) 22%, transparent) inset;
|
||
}
|
||
|
||
.context-scope-label {
|
||
position: absolute;
|
||
top: 6px;
|
||
left: 8px;
|
||
z-index: 20;
|
||
border-radius: 6px;
|
||
border: 1px solid color-mix(in oklab, var(--color-primary) 40%, transparent);
|
||
background: color-mix(in oklab, var(--color-base-100) 86%, var(--color-primary));
|
||
padding: 2px 7px;
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.02em;
|
||
color: color-mix(in oklab, var(--color-primary-content) 65%, var(--color-base-content));
|
||
}
|
||
</style>
|