Files
clientsflow/frontend/app/components/workspace/CrmWorkspaceApp.vue
2026-02-26 12:18:14 +07:00

2610 lines
104 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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,
markContactRead,
refetchContacts,
} = useContacts({ apolloAuthReady });
// ---------------------------------------------------------------------------
// 3. Contact Inboxes
// ---------------------------------------------------------------------------
const {
contactInboxes,
inboxToggleLoadingById,
setInboxHidden,
isInboxToggleLoading,
threadInboxes,
formatInboxLabel,
refetchContactInboxes,
} = useContactInboxes({ apolloAuthReady, onHidden: () => void refetchContacts() });
// ---------------------------------------------------------------------------
// 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,
timelineLoading,
timelineContactId,
timelineLimit,
loadClientTimeline,
refreshSelectedClientTimeline,
refetchTimeline,
} = useTimeline({ apolloAuthReady });
// ---------------------------------------------------------------------------
// 8. Call Audio
// ---------------------------------------------------------------------------
const {
commCallWaveHosts,
commCallPlayableById,
commCallPlayingById,
callTranscriptOpen,
callTranscriptLoading,
callTranscriptText,
callTranscriptError,
isCommCallPlayable,
isCommCallPlaying,
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,
handleRealtimePilotTrace,
handleRealtimePilotFinished,
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",
hasUnread: c.hasUnread,
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 (flush: 'post' ensures DOM refs are registered)
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) => {
const timelineItem = clientTimelineItems.value.find(
(entry) => entry.contentType === "message" && entry.message?.id === id && entry.message?.kind === "call",
);
return timelineItem?.message ?? undefined;
};
void _syncCommCallWavesRaw(activeCallIds, getCallItem);
},
{ flush: "post" },
);
// ---------------------------------------------------------------------------
// 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()]);
},
onNewMessage: (msg) => {
// If the message is for the currently open thread → refresh its timeline + mark read
if (msg.contactId === selectedCommThreadId.value) {
void refreshSelectedClientTimeline(selectedCommThreadId.value);
markContactRead(msg.contactId);
}
// Refresh contacts to update sidebar preview (lastMessageText, lastAt, hasUnread)
void refetchContacts();
},
onPilotTrace: (log) => handleRealtimePilotTrace(log),
onPilotFinished: () => void handleRealtimePilotFinished(),
});
// ---------------------------------------------------------------------------
// 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;
const contactId = selectedCommThreadId.value;
const contactName = selectedCommThread.value.contact;
try {
const channel = commSendChannel.value;
if (!channel) return;
const { useMutation } = await import("@vue/apollo-composable");
const { CreateCommunicationMutationDocument } = await import("~~/graphql/generated");
const { mutate: doCreateCommunication } = useMutation(CreateCommunicationMutationDocument);
const result = await doCreateCommunication({
input: {
contact: contactName,
channel,
kind: "message",
direction: "out",
text,
},
});
commDraft.value = "";
// Optimistically append the sent message to timeline (no full reload)
const newId = result?.data?.createCommunication?.id ?? `temp-${Date.now()}`;
const now = new Date().toISOString();
clientTimelineItems.value = [
...clientTimelineItems.value,
{
id: newId,
contactId,
contentType: "message",
contentId: newId,
datetime: now,
message: {
id: newId,
at: now,
contact: contactName,
contactInboxId: "",
sourceExternalId: "",
sourceTitle: "",
channel: channel as CommItem["channel"],
kind: "message",
direction: "out",
text,
},
},
];
scrollCommThreadToBottom();
// Refresh sidebar preview (lastMessageText) — lightweight
void refetchContacts();
} 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";
}
const contactByName = computed(() => {
const map = new Map<string, Contact>();
for (const contact of contacts.value) {
const key = (contact.name ?? "").trim();
if (!key || map.has(key)) continue;
map.set(key, contact);
}
return map;
});
function avatarSrcForCalendarEvent(event: CalendarEvent) {
const contactName = String(event.contact ?? "").trim();
if (!contactName) return "";
const contact = contactByName.value.get(contactName);
if (!contact) return "";
return avatarSrcForThread({ id: contact.id, avatar: contact.avatar });
}
function markCalendarAvatarBroken(event: CalendarEvent) {
const contactName = String(event.contact ?? "").trim();
if (!contactName) return;
const contact = contactByName.value.get(contactName);
if (!contact) return;
markAvatarBroken(contact.id);
}
// ---------------------------------------------------------------------------
// 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 "";
}
// ---------------------------------------------------------------------------
// Comm thread auto-scroll
// ---------------------------------------------------------------------------
const commThreadSurfaceRef = ref<HTMLElement | null>(null);
function scrollCommThreadToBottom() {
nextTick(() => {
const el = commThreadSurfaceRef.value;
if (el) el.scrollTop = el.scrollHeight;
});
}
// Scroll to bottom when a new thread loads (items go from [] → [items])
watch(clientTimelineItems, (items, oldItems) => {
if (items.length && (!oldItems || oldItems.length === 0)) scrollCommThreadToBottom();
});
// ---------------------------------------------------------------------------
// 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;
}
markContactRead(selectedCommThreadId.value);
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"
:avatar-src-for-calendar-event="avatarSrcForCalendarEvent"
:mark-calendar-avatar-broken="markCalendarAvatarBroken"
:contact-initials="contactInitials"
: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">
<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-start gap-2 px-2 py-1.5 text-left normal-case"
@click.stop="setInboxHidden(inbox.id, !inbox.isHidden)"
>
<span v-if="isInboxToggleLoading(inbox.id)" class="loading loading-spinner loading-xs shrink-0" />
<template v-else>
<span class="min-w-0 truncate">{{ inbox.isHidden ? 'Показать' : 'Скрыть' }} {{ formatInboxLabel(inbox) }}</span>
</template>
</button>
</div>
<p v-else class="px-1 py-1 text-[11px] text-base-content/60">Нет источников</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 ref="commThreadSurfaceRef" class="comm-thread-surface min-h-0 flex-1 space-y-2 overflow-y-auto px-3 pb-2">
<!-- Loading spinner while timeline is fetching -->
<div v-if="timelineLoading && clientTimelineItems.length === 0" class="flex h-full items-center justify-center">
<span class="loading loading-spinner loading-md text-primary" />
</div>
<template v-else>
<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>
</template>
</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>