diff --git a/frontend/app/components/workspace/CrmWorkspaceApp.vue b/frontend/app/components/workspace/CrmWorkspaceApp.vue
index 24b7154..f6a5c18 100644
--- a/frontend/app/components/workspace/CrmWorkspaceApp.vue
+++ b/frontend/app/components/workspace/CrmWorkspaceApp.vue
@@ -1,6 +1,6 @@
@@ -5107,9 +1398,6 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
: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"
diff --git a/frontend/app/composables/crm-types.ts b/frontend/app/composables/crm-types.ts
new file mode 100644
index 0000000..2b77abc
--- /dev/null
+++ b/frontend/app/composables/crm-types.ts
@@ -0,0 +1,373 @@
+// ---------------------------------------------------------------------------
+// Shared CRM types & utility functions
+// ---------------------------------------------------------------------------
+
+export type TabId = "communications" | "documents";
+export type CalendarView = "day" | "week" | "month" | "year" | "agenda";
+export type SortMode = "name" | "lastContact";
+export type PeopleLeftMode = "contacts" | "calendar";
+export type PeopleSortMode = "name" | "lastContact";
+export type PeopleVisibilityMode = "all" | "hidden";
+export type DocumentSortMode = "updatedAt" | "title" | "owner";
+
+export type FeedCard = {
+ id: string;
+ at: string;
+ contact: string;
+ text: string;
+ proposal: {
+ title: string;
+ details: string[];
+ key: "create_followup" | "open_comm" | "call" | "draft_message" | "run_summary" | "prepare_question";
+ };
+ decision: "pending" | "accepted" | "rejected";
+ decisionNote?: string;
+};
+
+export type Contact = {
+ id: string;
+ name: string;
+ avatar: string;
+ channels: string[];
+ lastContactAt: string;
+ description: string;
+};
+
+export type CalendarEvent = {
+ id: string;
+ title: string;
+ start: string;
+ end: string;
+ contact: string;
+ note: string;
+ isArchived: boolean;
+ createdAt: string;
+ archiveNote: string;
+ archivedAt: string;
+};
+
+export type EventLifecyclePhase = "scheduled" | "due_soon" | "awaiting_outcome" | "closed";
+
+export type CommItem = {
+ id: string;
+ at: string;
+ contact: string;
+ contactInboxId: string;
+ sourceExternalId: string;
+ sourceTitle: string;
+ channel: "Telegram" | "WhatsApp" | "Instagram" | "Phone" | "Email";
+ kind: "message" | "call";
+ direction: "in" | "out";
+ text: string;
+ audioUrl?: string;
+ duration?: string;
+ waveform?: number[];
+ transcript?: string[];
+ deliveryStatus?: "PENDING" | "SENT" | "DELIVERED" | "READ" | "FAILED" | string | null;
+};
+
+export type ContactInbox = {
+ id: string;
+ contactId: string;
+ contactName: string;
+ channel: CommItem["channel"];
+ sourceExternalId: string;
+ title: string;
+ isHidden: boolean;
+ lastMessageAt: string;
+ updatedAt: string;
+};
+
+export type CommPin = {
+ id: string;
+ contact: string;
+ text: string;
+};
+
+export type Deal = {
+ id: string;
+ contact: string;
+ title: string;
+ stage: string;
+ amount: string;
+ nextStep: string;
+ summary: string;
+ currentStepId: string;
+ steps: DealStep[];
+};
+
+export type DealStep = {
+ id: string;
+ title: string;
+ description: string;
+ status: "todo" | "in_progress" | "done" | "blocked" | string;
+ dueAt: string;
+ order: number;
+ completedAt: string;
+};
+
+export type WorkspaceDocument = {
+ id: string;
+ title: string;
+ type: "Regulation" | "Playbook" | "Policy" | "Template";
+ owner: string;
+ scope: string;
+ updatedAt: string;
+ summary: string;
+ body: string;
+};
+
+export type ClientTimelineItem = {
+ id: string;
+ contactId: string;
+ contentType: "message" | "calendar_event" | "document" | "recommendation" | string;
+ contentId: string;
+ datetime: string;
+ message?: CommItem | null;
+ calendarEvent?: CalendarEvent | null;
+ recommendation?: FeedCard | null;
+ document?: WorkspaceDocument | null;
+};
+
+export type PilotMessage = {
+ id: string;
+ role: "user" | "assistant" | "system";
+ text: string;
+ messageKind?: string | null;
+ requestId?: string | null;
+ eventType?: string | null;
+ phase?: string | null;
+ transient?: boolean | null;
+ thinking?: string[] | null;
+ tools?: string[] | null;
+ toolRuns?: Array<{
+ name: string;
+ status: "ok" | "error";
+ input: string;
+ output: string;
+ at: string;
+ }> | null;
+ changeSetId?: string | null;
+ changeStatus?: "pending" | "confirmed" | "rolled_back" | null;
+ changeSummary?: string | null;
+ changeItems?: PilotChangeItem[] | null;
+ createdAt?: string;
+ _live?: boolean;
+};
+
+export type PilotChangeItem = {
+ id: string;
+ entity: string;
+ entityId?: string | null;
+ action: string;
+ title: string;
+ before: string;
+ after: string;
+ rolledBack?: boolean;
+};
+
+export type ContextScope = "summary" | "deal" | "message" | "calendar";
+
+export type PilotContextPayload = {
+ scopes: ContextScope[];
+ summary?: {
+ contactId: string;
+ name: string;
+ };
+ deal?: {
+ dealId: string;
+ title: string;
+ contact: string;
+ };
+ message?: {
+ contactId?: string;
+ contact?: string;
+ intent: "add_message_or_reminder";
+ };
+ calendar?: {
+ view: CalendarView;
+ period: string;
+ selectedDateKey: string;
+ focusedEventId?: string;
+ eventIds: string[];
+ };
+};
+
+export type ChatConversation = {
+ id: string;
+ title: string;
+ createdAt: string;
+ updatedAt: string;
+ lastMessageAt?: string | null;
+ lastMessageText?: string | null;
+};
+
+// ---------------------------------------------------------------------------
+// Utility functions
+// ---------------------------------------------------------------------------
+
+export function safeTrim(value: unknown) {
+ return String(value ?? "").trim();
+}
+
+export function dayKey(date: Date) {
+ const y = date.getFullYear();
+ const m = String(date.getMonth() + 1).padStart(2, "0");
+ const d = String(date.getDate()).padStart(2, "0");
+ return `${y}-${m}-${d}`;
+}
+
+export function formatDay(iso: string) {
+ return new Intl.DateTimeFormat("en-GB", {
+ day: "2-digit",
+ month: "short",
+ year: "numeric",
+ }).format(new Date(iso));
+}
+
+export function formatTime(iso: string) {
+ return new Intl.DateTimeFormat("en-GB", {
+ hour: "2-digit",
+ minute: "2-digit",
+ }).format(new Date(iso));
+}
+
+export function formatThreadTime(iso: string) {
+ return new Intl.DateTimeFormat("en-GB", {
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ })
+ .format(new Date(iso))
+ .replace(":", ".");
+}
+
+export function formatStamp(iso: string) {
+ return new Intl.DateTimeFormat("en-GB", {
+ day: "2-digit",
+ month: "short",
+ hour: "2-digit",
+ minute: "2-digit",
+ }).format(new Date(iso));
+}
+
+export function atOffset(days: number, hour: number, minute: number) {
+ const d = new Date();
+ d.setDate(d.getDate() + days);
+ d.setHours(hour, minute, 0, 0);
+ return d.toISOString();
+}
+
+export function inMinutes(minutes: number) {
+ const d = new Date();
+ d.setMinutes(d.getMinutes() + minutes, 0, 0);
+ return d.toISOString();
+}
+
+export function endAfter(startIso: string, minutes: number) {
+ const d = new Date(startIso);
+ d.setMinutes(d.getMinutes() + minutes);
+ return d.toISOString();
+}
+
+export function isEventFinalStatus(isArchived: boolean) {
+ return Boolean(isArchived);
+}
+
+export function eventPreDueAt(event: CalendarEvent) {
+ return new Date(new Date(event.start).getTime() - 30 * 60 * 1000).toISOString();
+}
+
+export function eventDueAt(event: CalendarEvent) {
+ return event.start;
+}
+
+export function eventLifecyclePhase(event: CalendarEvent, nowMs: number): EventLifecyclePhase {
+ if (event.isArchived) return "closed";
+ const dueMs = new Date(eventDueAt(event)).getTime();
+ const preDueMs = new Date(eventPreDueAt(event)).getTime();
+ if (nowMs >= dueMs) return "awaiting_outcome";
+ if (nowMs >= preDueMs) return "due_soon";
+ return "scheduled";
+}
+
+export function eventTimelineAt(event: CalendarEvent, phase: EventLifecyclePhase) {
+ if (phase === "scheduled") return event.createdAt || event.start;
+ if (phase === "due_soon") return eventPreDueAt(event);
+ return eventDueAt(event);
+}
+
+export function eventRelativeLabel(event: CalendarEvent, nowMs: number) {
+ if (event.isArchived) return "Archived";
+ const diffMs = new Date(event.start).getTime() - nowMs;
+ const minuteMs = 60 * 1000;
+ const hourMs = 60 * minuteMs;
+ const dayMs = 24 * hourMs;
+ const abs = Math.abs(diffMs);
+
+ if (diffMs >= 0) {
+ if (abs >= dayMs) {
+ const days = Math.round(abs / dayMs);
+ return `Event in ${days} day${days === 1 ? "" : "s"}`;
+ }
+ if (abs >= hourMs) {
+ const hours = Math.round(abs / hourMs);
+ return `Event in ${hours} hour${hours === 1 ? "" : "s"}`;
+ }
+ const minutes = Math.max(1, Math.round(abs / minuteMs));
+ return `Event in ${minutes} minute${minutes === 1 ? "" : "s"}`;
+ }
+
+ if (abs >= dayMs) {
+ const days = Math.round(abs / dayMs);
+ return `Overdue by ${days} day${days === 1 ? "" : "s"}`;
+ }
+ if (abs >= hourMs) {
+ const hours = Math.round(abs / hourMs);
+ return `Overdue by ${hours} hour${hours === 1 ? "" : "s"}`;
+ }
+ const minutes = Math.max(1, Math.round(abs / minuteMs));
+ return `Overdue by ${minutes} minute${minutes === 1 ? "" : "s"}`;
+}
+
+export function eventPhaseToneClass(phase: EventLifecyclePhase) {
+ if (phase === "awaiting_outcome") return "border-warning/50 bg-warning/10";
+ if (phase === "due_soon") return "border-info/50 bg-info/10";
+ if (phase === "closed") return "border-success/40 bg-success/10";
+ return "border-base-300 bg-base-100";
+}
+
+export function toInputDate(date: Date) {
+ const y = date.getFullYear();
+ const m = String(date.getMonth() + 1).padStart(2, "0");
+ const d = String(date.getDate()).padStart(2, "0");
+ return `${y}-${m}-${d}`;
+}
+
+export function toInputTime(date: Date) {
+ const hh = String(date.getHours()).padStart(2, "0");
+ const mm = String(date.getMinutes()).padStart(2, "0");
+ return `${hh}:${mm}`;
+}
+
+export function roundToNextQuarter(date = new Date()) {
+ const d = new Date(date);
+ d.setSeconds(0, 0);
+ const minutes = d.getMinutes();
+ const rounded = Math.ceil(minutes / 15) * 15;
+ if (rounded >= 60) {
+ d.setHours(d.getHours() + 1, 0, 0, 0);
+ } else {
+ d.setMinutes(rounded, 0, 0);
+ }
+ return d;
+}
+
+export function roundToPrevQuarter(date = new Date()) {
+ const d = new Date(date);
+ d.setSeconds(0, 0);
+ const minutes = d.getMinutes();
+ const rounded = Math.floor(minutes / 15) * 15;
+ d.setMinutes(rounded, 0, 0);
+ return d;
+}
diff --git a/frontend/app/composables/useAuth.ts b/frontend/app/composables/useAuth.ts
new file mode 100644
index 0000000..779816d
--- /dev/null
+++ b/frontend/app/composables/useAuth.ts
@@ -0,0 +1,205 @@
+import { ref, computed, watch } from "vue";
+import { useQuery, useMutation } from "@vue/apollo-composable";
+import { MeQueryDocument, LogoutMutationDocument } from "~~/graphql/generated";
+
+type TelegramConnectStatus =
+ | "not_connected"
+ | "pending_link"
+ | "pending_business_connection"
+ | "connected"
+ | "disabled"
+ | "no_reply_rights";
+
+type TelegramConnectionSummary = {
+ businessConnectionId: string;
+ isEnabled: boolean | null;
+ canReply: boolean | null;
+ updatedAt: string;
+};
+
+export function useAuth() {
+ // -------------------------------------------------------------------------
+ // Auth state
+ // -------------------------------------------------------------------------
+ const authMe = ref<{
+ user: { id: string; phone: string; name: string };
+ team: { id: string; name: string };
+ conversation: { id: string; title: string };
+ } | null>(null);
+
+ const authResolved = ref(false);
+
+ const apolloAuthReady = computed(() => !!authMe.value);
+
+ // -------------------------------------------------------------------------
+ // Apollo: Me query
+ // -------------------------------------------------------------------------
+ const { result: meResult, refetch: refetchMe, loading: meLoading } = useQuery(
+ MeQueryDocument,
+ null,
+ { fetchPolicy: "network-only" },
+ );
+
+ watch(() => meResult.value?.me, (me) => {
+ if (me) authMe.value = me as typeof authMe.value;
+ }, { immediate: true });
+
+ // -------------------------------------------------------------------------
+ // Apollo: Logout mutation
+ // -------------------------------------------------------------------------
+ const { mutate: doLogout } = useMutation(LogoutMutationDocument);
+
+ // -------------------------------------------------------------------------
+ // loadMe / logout
+ // -------------------------------------------------------------------------
+ async function loadMe() {
+ const result = await refetchMe();
+ const me = result?.data?.me;
+ if (me) authMe.value = me as typeof authMe.value;
+ }
+
+ async function logout() {
+ await doLogout();
+ authMe.value = null;
+ telegramConnectStatus.value = "not_connected";
+ telegramConnections.value = [];
+ telegramConnectUrl.value = "";
+ if (process.client) {
+ await navigateTo("/login", { replace: true });
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Telegram connect state
+ // -------------------------------------------------------------------------
+ const telegramConnectStatus = ref("not_connected");
+ const telegramConnectStatusLoading = ref(false);
+ const telegramConnectBusy = ref(false);
+ const telegramConnectUrl = ref("");
+ const telegramConnections = ref([]);
+ const telegramConnectNotice = ref("");
+
+ const telegramStatusLabel = computed(() => {
+ if (telegramConnectStatusLoading.value) return "Checking";
+ if (telegramConnectStatus.value === "connected") return "Connected";
+ if (telegramConnectStatus.value === "pending_link") return "Pending link";
+ if (telegramConnectStatus.value === "pending_business_connection") return "Waiting business connect";
+ if (telegramConnectStatus.value === "disabled") return "Disabled";
+ if (telegramConnectStatus.value === "no_reply_rights") return "No reply rights";
+ return "Not connected";
+ });
+
+ const telegramStatusBadgeClass = computed(() => {
+ if (telegramConnectStatus.value === "connected") return "badge-success";
+ if (telegramConnectStatus.value === "pending_link" || telegramConnectStatus.value === "pending_business_connection") return "badge-warning";
+ if (telegramConnectStatus.value === "disabled" || telegramConnectStatus.value === "no_reply_rights") return "badge-error";
+ return "badge-ghost";
+ });
+
+ // -------------------------------------------------------------------------
+ // Telegram connect functions
+ // -------------------------------------------------------------------------
+ async function loadTelegramConnectStatus() {
+ if (!authMe.value) {
+ telegramConnectStatus.value = "not_connected";
+ telegramConnections.value = [];
+ telegramConnectUrl.value = "";
+ return;
+ }
+
+ telegramConnectStatusLoading.value = true;
+ try {
+ const result = await $fetch<{
+ ok: boolean;
+ status: TelegramConnectStatus;
+ connections?: TelegramConnectionSummary[];
+ }>("/api/omni/telegram/business/connect/status", {
+ method: "GET",
+ });
+ telegramConnectStatus.value = result?.status ?? "not_connected";
+ telegramConnections.value = result?.connections ?? [];
+ } catch {
+ telegramConnectStatus.value = "not_connected";
+ telegramConnections.value = [];
+ } finally {
+ telegramConnectStatusLoading.value = false;
+ }
+ }
+
+ async function startTelegramBusinessConnect() {
+ if (telegramConnectBusy.value) return;
+ telegramConnectBusy.value = true;
+ try {
+ const result = await $fetch<{
+ ok: boolean;
+ status: TelegramConnectStatus;
+ connectUrl: string;
+ expiresAt: string;
+ }>("/api/omni/telegram/business/connect/start", { method: "POST" });
+ telegramConnectStatus.value = result?.status ?? "pending_link";
+ telegramConnectUrl.value = String(result?.connectUrl ?? "").trim();
+ if (telegramConnectUrl.value && process.client) {
+ window.location.href = telegramConnectUrl.value;
+ }
+ } catch {
+ telegramConnectStatus.value = "not_connected";
+ } finally {
+ telegramConnectBusy.value = false;
+ await loadTelegramConnectStatus();
+ }
+ }
+
+ async function completeTelegramBusinessConnectFromToken(token: string) {
+ const t = String(token || "").trim();
+ if (!t) return;
+
+ try {
+ const result = await $fetch<{
+ ok: boolean;
+ status: string;
+ businessConnectionId?: string;
+ }>("/api/omni/telegram/business/connect/complete", {
+ method: "POST",
+ body: { token: t },
+ });
+
+ if (result?.ok) {
+ telegramConnectStatus.value = "connected";
+ telegramConnectNotice.value = "Telegram успешно привязан.";
+ await loadTelegramConnectStatus();
+ return;
+ }
+
+ if (result?.status === "awaiting_telegram_start") {
+ telegramConnectNotice.value = "Сначала нажмите Start в Telegram, затем нажмите кнопку в боте снова.";
+ } else if (result?.status === "invalid_or_expired_token") {
+ telegramConnectNotice.value = "Ссылка привязки истекла. Нажмите Connect в CRM заново.";
+ } else {
+ telegramConnectNotice.value = "Не удалось завершить привязку. Запустите Connect заново.";
+ }
+ } catch {
+ telegramConnectNotice.value = "Ошибка завершения привязки. Попробуйте снова.";
+ }
+ }
+
+ return {
+ authMe,
+ authResolved,
+ apolloAuthReady,
+ meLoading,
+ loadMe,
+ logout,
+ // telegram
+ telegramConnectStatus,
+ telegramConnectStatusLoading,
+ telegramConnectBusy,
+ telegramConnectUrl,
+ telegramConnections,
+ telegramConnectNotice,
+ telegramStatusLabel,
+ telegramStatusBadgeClass,
+ loadTelegramConnectStatus,
+ startTelegramBusinessConnect,
+ completeTelegramBusinessConnectFromToken,
+ };
+}
diff --git a/frontend/app/composables/useCalendar.ts b/frontend/app/composables/useCalendar.ts
new file mode 100644
index 0000000..31c05d6
--- /dev/null
+++ b/frontend/app/composables/useCalendar.ts
@@ -0,0 +1,1129 @@
+import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick, type ComputedRef } from "vue";
+import { useQuery, useMutation } from "@vue/apollo-composable";
+import {
+ CalendarQueryDocument,
+ CreateCalendarEventMutationDocument,
+ ArchiveCalendarEventMutationDocument,
+} from "~~/graphql/generated";
+import type { CalendarEvent, CalendarView } from "~/composables/crm-types";
+import {
+ dayKey,
+ formatDay,
+ formatTime,
+ toInputDate,
+ toInputTime,
+ roundToNextQuarter,
+ roundToPrevQuarter,
+ isEventFinalStatus,
+} from "~/composables/crm-types";
+
+type CalendarHierarchyView = "year" | "month" | "week" | "day";
+type CalendarRect = { left: number; top: number; width: number; height: number };
+
+export function useCalendar(opts: { apolloAuthReady: ComputedRef }) {
+ // ---------------------------------------------------------------------------
+ // Apollo query & mutation
+ // ---------------------------------------------------------------------------
+ const { result: calendarResult, refetch: refetchCalendar } = useQuery(
+ CalendarQueryDocument,
+ null,
+ { enabled: opts.apolloAuthReady },
+ );
+
+ const { mutate: doCreateCalendarEvent } = useMutation(CreateCalendarEventMutationDocument, {
+ refetchQueries: [{ query: CalendarQueryDocument }],
+ });
+ const { mutate: doArchiveCalendarEvent } = useMutation(ArchiveCalendarEventMutationDocument, {
+ refetchQueries: [{ query: CalendarQueryDocument }],
+ });
+
+ // ---------------------------------------------------------------------------
+ // Core state
+ // ---------------------------------------------------------------------------
+ const calendarEvents = ref([]);
+ const calendarView = ref("year");
+ const calendarCursor = ref(new Date(new Date().getFullYear(), new Date().getMonth(), 1));
+ const selectedDateKey = ref(dayKey(new Date()));
+ const focusedCalendarEventId = ref("");
+
+ const lifecycleNowMs = ref(Date.now());
+ let lifecycleClock: ReturnType | null = null;
+
+ onMounted(() => {
+ lifecycleClock = setInterval(() => {
+ lifecycleNowMs.value = Date.now();
+ }, 15000);
+ });
+
+ onBeforeUnmount(() => {
+ if (lifecycleClock) {
+ clearInterval(lifecycleClock);
+ lifecycleClock = null;
+ }
+ if (calendarViewportResizeObserver) {
+ calendarViewportResizeObserver.disconnect();
+ calendarViewportResizeObserver = null;
+ }
+ clearCalendarZoomPrime();
+ });
+
+ // ---------------------------------------------------------------------------
+ // Apollo → Ref watcher
+ // ---------------------------------------------------------------------------
+ watch(() => calendarResult.value?.calendar, (v) => {
+ if (v) calendarEvents.value = v as CalendarEvent[];
+ }, { immediate: true });
+
+ // ---------------------------------------------------------------------------
+ // Sorted events & derived computeds
+ // ---------------------------------------------------------------------------
+ const sortedEvents = computed(() => [...calendarEvents.value].sort((a, b) => a.start.localeCompare(b.start)));
+ const focusedCalendarEvent = computed(() => {
+ const id = (focusedCalendarEventId.value ?? "").trim();
+ if (!id) return null;
+ return sortedEvents.value.find((event) => event.id === id) ?? null;
+ });
+
+ const eventsByDate = computed(() => {
+ const map = new Map();
+
+ for (const event of sortedEvents.value) {
+ const key = event.start.slice(0, 10);
+ if (!map.has(key)) {
+ map.set(key, []);
+ }
+ map.get(key)?.push(event);
+ }
+
+ return map;
+ });
+
+ function getEventsByDate(key: string) {
+ return eventsByDate.value.get(key) ?? [];
+ }
+
+ const monthLabel = computed(() =>
+ new Intl.DateTimeFormat("en-US", { month: "long", year: "numeric" }).format(calendarCursor.value),
+ );
+
+ const calendarViewOptions: { value: CalendarView; label: string }[] = [
+ { value: "day", label: "Day" },
+ { value: "week", label: "Week" },
+ { value: "month", label: "Month" },
+ { value: "year", label: "Year" },
+ { value: "agenda", label: "Agenda" },
+ ];
+
+ // ---------------------------------------------------------------------------
+ // Zoom / camera state
+ // ---------------------------------------------------------------------------
+ const calendarContentWrapRef = ref(null);
+ const calendarContentScrollRef = ref(null);
+ const calendarSceneRef = ref(null);
+ const calendarViewportHeight = ref(0);
+ const calendarHoveredMonthIndex = ref(null);
+ const calendarHoveredWeekStartKey = ref("");
+ const calendarHoveredDayKey = ref("");
+ let calendarViewportResizeObserver: ResizeObserver | null = null;
+
+ function setCalendarContentWrapRef(element: HTMLElement | null) {
+ calendarContentWrapRef.value = element;
+ }
+
+ function setCalendarContentScrollRef(element: HTMLElement | null) {
+ if (calendarViewportResizeObserver) {
+ calendarViewportResizeObserver.disconnect();
+ calendarViewportResizeObserver = null;
+ }
+ calendarContentScrollRef.value = element;
+ if (element && typeof ResizeObserver !== "undefined") {
+ calendarViewportResizeObserver = new ResizeObserver(() => {
+ calendarViewportHeight.value = Math.max(0, Math.round(element.clientHeight));
+ });
+ calendarViewportResizeObserver.observe(element);
+ calendarViewportHeight.value = Math.max(0, Math.round(element.clientHeight));
+ }
+ }
+
+ function setCalendarSceneRef(element: HTMLElement | null) {
+ calendarSceneRef.value = element;
+ }
+
+ function setCalendarHoveredMonthIndex(value: number | null) {
+ calendarHoveredMonthIndex.value = value;
+ }
+
+ function setCalendarHoveredWeekStartKey(value: string) {
+ calendarHoveredWeekStartKey.value = value;
+ }
+
+ function setCalendarHoveredDayKey(value: string) {
+ calendarHoveredDayKey.value = value;
+ }
+
+ function onCalendarSceneMouseLeave() {
+ calendarHoveredMonthIndex.value = null;
+ calendarHoveredWeekStartKey.value = "";
+ calendarHoveredDayKey.value = "";
+ clearCalendarZoomPrime();
+ }
+
+ const calendarZoomBusy = ref(false);
+ const calendarCameraState = ref({
+ active: false,
+ left: 0,
+ top: 0,
+ scale: 1,
+ durationMs: 0,
+ });
+ const calendarZoomPrimeToken = ref("");
+ const calendarZoomPrimeScale = ref(1);
+ const calendarZoomPrimeTicks = ref(0);
+ let calendarWheelLockUntil = 0;
+ let calendarZoomPrimeTimer: ReturnType | null = null;
+ let calendarZoomPrimeLastAt = 0;
+ const CALENDAR_ZOOM_DURATION_MS = 2400;
+ const CALENDAR_ZOOM_PRIME_STEPS = 2;
+ const CALENDAR_ZOOM_PRIME_MAX_SCALE = 1.05;
+ const CALENDAR_ZOOM_PRIME_RESET_MS = 900;
+ const calendarZoomOrder: CalendarHierarchyView[] = ["year", "month", "week", "day"];
+
+ const normalizedCalendarView = computed(() =>
+ calendarView.value === "agenda" ? "month" : calendarView.value,
+ );
+ const calendarZoomLevelIndex = computed(() => Math.max(0, calendarZoomOrder.indexOf(normalizedCalendarView.value)));
+ const calendarSceneTransformStyle = computed(() => {
+ if (!calendarCameraState.value.active) return undefined;
+ return {
+ transform: `translate(${calendarCameraState.value.left}px, ${calendarCameraState.value.top}px) scale(${calendarCameraState.value.scale})`,
+ transformOrigin: "0 0",
+ transition:
+ calendarCameraState.value.durationMs > 0
+ ? `transform ${calendarCameraState.value.durationMs}ms cubic-bezier(0.16, 0.86, 0.18, 1)`
+ : "none",
+ willChange: "transform",
+ };
+ });
+
+ function clearCalendarZoomPrime() {
+ if (calendarZoomPrimeTimer) {
+ clearTimeout(calendarZoomPrimeTimer);
+ calendarZoomPrimeTimer = null;
+ }
+ calendarZoomPrimeToken.value = "";
+ calendarZoomPrimeScale.value = 1;
+ calendarZoomPrimeTicks.value = 0;
+ calendarZoomPrimeLastAt = 0;
+ }
+
+ function calendarPrimeMonthToken(monthIndex: number) {
+ return `year-month-${monthIndex}`;
+ }
+
+ function calendarPrimeWeekToken(startKey: string) {
+ return `month-week-${startKey}`;
+ }
+
+ function calendarPrimeDayToken(key: string) {
+ return `week-day-${key}`;
+ }
+
+ function calendarPrimeStyle(token: string) {
+ if (calendarZoomPrimeToken.value !== token) return undefined;
+ return {
+ transform: `scale(${calendarZoomPrimeScale.value})`,
+ };
+ }
+
+ function maybePrimeWheelZoom(event: WheelEvent | undefined, token: string) {
+ if (!event || event.deltaY >= 0) return false;
+ const now = Date.now();
+ if (calendarZoomPrimeToken.value !== token || now - calendarZoomPrimeLastAt > CALENDAR_ZOOM_PRIME_RESET_MS) {
+ calendarZoomPrimeTicks.value = 0;
+ }
+
+ calendarZoomPrimeToken.value = token;
+ calendarZoomPrimeTicks.value += 1;
+ calendarZoomPrimeLastAt = now;
+
+ if (calendarZoomPrimeTicks.value <= CALENDAR_ZOOM_PRIME_STEPS) {
+ const ratio = calendarZoomPrimeTicks.value / CALENDAR_ZOOM_PRIME_STEPS;
+ calendarZoomPrimeScale.value = 1 + (CALENDAR_ZOOM_PRIME_MAX_SCALE - 1) * ratio;
+ if (calendarZoomPrimeTimer) clearTimeout(calendarZoomPrimeTimer);
+ calendarZoomPrimeTimer = setTimeout(() => {
+ clearCalendarZoomPrime();
+ }, CALENDAR_ZOOM_PRIME_RESET_MS);
+ return true;
+ }
+
+ clearCalendarZoomPrime();
+ return false;
+ }
+
+ function queryCalendarElement(selector: string) {
+ return calendarContentWrapRef.value?.querySelector(selector) ?? null;
+ }
+
+ function getCalendarViewportRect(): CalendarRect | null {
+ const wrapRect = calendarContentWrapRef.value?.getBoundingClientRect();
+ if (!wrapRect) return null;
+ return {
+ left: 0,
+ top: 0,
+ width: Math.max(24, wrapRect.width),
+ height: Math.max(24, wrapRect.height),
+ };
+ }
+
+ function getCalendarCameraViewportRect() {
+ const viewport = calendarContentScrollRef.value?.getBoundingClientRect();
+ if (!viewport) return null;
+ return {
+ width: Math.max(24, viewport.width),
+ height: Math.max(24, viewport.height),
+ };
+ }
+
+ function getElementRectInCalendar(element: HTMLElement | null): CalendarRect | null {
+ if (!element) return null;
+ const wrapRect = calendarContentWrapRef.value?.getBoundingClientRect();
+ if (!wrapRect) return null;
+ const rect = element.getBoundingClientRect();
+ const left = Math.max(0, Math.min(rect.left - wrapRect.left, wrapRect.width));
+ const top = Math.max(0, Math.min(rect.top - wrapRect.top, wrapRect.height));
+ const right = Math.max(0, Math.min(rect.right - wrapRect.left, wrapRect.width));
+ const bottom = Math.max(0, Math.min(rect.bottom - wrapRect.top, wrapRect.height));
+ const visibleWidth = right - left;
+ const visibleHeight = bottom - top;
+ if (visibleWidth < 2 || visibleHeight < 2) return null;
+ const width = Math.min(Math.max(24, visibleWidth), wrapRect.width - left);
+ const height = Math.min(Math.max(24, visibleHeight), wrapRect.height - top);
+ return { left, top, width, height };
+ }
+
+ function getElementRectInScene(element: HTMLElement | null): CalendarRect | null {
+ if (!element) return null;
+ const sceneRect = calendarSceneRef.value?.getBoundingClientRect();
+ if (!sceneRect) return null;
+ const rect = element.getBoundingClientRect();
+ const left = rect.left - sceneRect.left;
+ const top = rect.top - sceneRect.top;
+ const width = Math.max(24, rect.width);
+ const height = Math.max(24, rect.height);
+ return { left, top, width, height };
+ }
+
+ function fallbackZoomOriginRectInScene(): CalendarRect | null {
+ const viewport = getCalendarCameraViewportRect();
+ const scroll = calendarContentScrollRef.value;
+ if (!viewport || !scroll) return null;
+ const width = Math.max(96, Math.round(viewport.width * 0.28));
+ const height = Math.max(64, Math.round(viewport.height * 0.24));
+ return {
+ left: scroll.scrollLeft + Math.max(0, (viewport.width - width) / 2),
+ top: scroll.scrollTop + Math.max(0, (viewport.height - height) / 2),
+ width,
+ height,
+ };
+ }
+
+ function weekRowStartForDate(key: string) {
+ const date = new Date(`${key}T00:00:00`);
+ date.setDate(date.getDate() - date.getDay());
+ return dayKey(date);
+ }
+
+ function nextAnimationFrame() {
+ return new Promise((resolve) => {
+ requestAnimationFrame(() => resolve());
+ });
+ }
+
+ function waitForTransformTransition(element: HTMLElement) {
+ return new Promise((resolve) => {
+ let settled = false;
+ const finish = () => {
+ if (settled) return;
+ settled = true;
+ element.removeEventListener("transitionend", onTransitionEnd);
+ clearTimeout(fallbackTimer);
+ resolve();
+ };
+ const onTransitionEnd = (event: TransitionEvent) => {
+ if (event.target !== element) return;
+ if (event.propertyName !== "transform") return;
+ finish();
+ };
+ const fallbackTimer = setTimeout(() => finish(), CALENDAR_ZOOM_DURATION_MS + 160);
+ element.addEventListener("transitionend", onTransitionEnd);
+ });
+ }
+
+ function fadeOutCalendarSiblings(sourceElement: HTMLElement) {
+ const scene = calendarSceneRef.value;
+ if (!scene) return () => {};
+ const targets = Array.from(scene.querySelectorAll(".calendar-hover-targetable"));
+ const siblings = targets.filter((element) => {
+ if (element === sourceElement) return false;
+ if (sourceElement.contains(element)) return false;
+ if (element.contains(sourceElement)) return false;
+ return true;
+ });
+ const snapshots = siblings.map((element) => ({
+ element,
+ opacity: element.style.opacity,
+ pointerEvents: element.style.pointerEvents,
+ transition: element.style.transition,
+ }));
+ for (const { element } of snapshots) {
+ element.style.transition = "opacity 180ms ease";
+ element.style.opacity = "0";
+ element.style.pointerEvents = "none";
+ }
+ return () => {
+ for (const snapshot of snapshots) {
+ snapshot.element.style.opacity = snapshot.opacity;
+ snapshot.element.style.pointerEvents = snapshot.pointerEvents;
+ snapshot.element.style.transition = snapshot.transition;
+ }
+ };
+ }
+
+ function isRenderableRect(rect: DOMRect | null) {
+ return Boolean(rect && rect.width >= 2 && rect.height >= 2);
+ }
+
+ async function animateCalendarFlipTransition(
+ sourceElement: HTMLElement | null,
+ apply: () => void,
+ resolveTarget: () => HTMLElement | null,
+ ) {
+ clearCalendarZoomPrime();
+ calendarZoomBusy.value = true;
+ let restoreSiblings = () => {};
+ let animatedElement: HTMLElement | null = null;
+ let snapshot: {
+ transform: string;
+ transition: string;
+ transformOrigin: string;
+ willChange: string;
+ zIndex: string;
+ } | null = null;
+ try {
+ const sourceRect = sourceElement?.getBoundingClientRect() ?? null;
+ apply();
+ await nextTick();
+ const targetElement = resolveTarget();
+ const targetRect = targetElement?.getBoundingClientRect() ?? null;
+ if (!targetElement || !isRenderableRect(sourceRect) || !isRenderableRect(targetRect)) return;
+
+ restoreSiblings = fadeOutCalendarSiblings(targetElement);
+ animatedElement = targetElement;
+ snapshot = {
+ transform: targetElement.style.transform,
+ transition: targetElement.style.transition,
+ transformOrigin: targetElement.style.transformOrigin,
+ willChange: targetElement.style.willChange,
+ zIndex: targetElement.style.zIndex,
+ };
+
+ const dx = sourceRect!.left - targetRect!.left;
+ const dy = sourceRect!.top - targetRect!.top;
+ const sx = Math.max(0.01, sourceRect!.width / targetRect!.width);
+ const sy = Math.max(0.01, sourceRect!.height / targetRect!.height);
+
+ targetElement.style.transformOrigin = "top left";
+ targetElement.style.willChange = "transform";
+ targetElement.style.zIndex = "24";
+ targetElement.style.transition = "none";
+ targetElement.style.transform = `translate3d(${dx}px, ${dy}px, 0px) scale(${sx}, ${sy})`;
+ targetElement.getBoundingClientRect();
+ await nextAnimationFrame();
+
+ targetElement.style.transition = `transform ${CALENDAR_ZOOM_DURATION_MS}ms cubic-bezier(0.16, 0.86, 0.18, 1)`;
+ targetElement.style.transform = "translate3d(0px, 0px, 0px) scale(1, 1)";
+ await waitForTransformTransition(targetElement);
+ } finally {
+ if (animatedElement && snapshot) {
+ animatedElement.style.transform = snapshot.transform;
+ animatedElement.style.transition = snapshot.transition;
+ animatedElement.style.transformOrigin = snapshot.transformOrigin;
+ animatedElement.style.willChange = snapshot.willChange;
+ animatedElement.style.zIndex = snapshot.zIndex;
+ }
+ restoreSiblings();
+ calendarZoomBusy.value = false;
+ }
+ }
+
+ async function animateCalendarZoomIntoSource(
+ sourceElement: HTMLElement | null,
+ apply: () => void,
+ ) {
+ clearCalendarZoomPrime();
+ calendarZoomBusy.value = true;
+ let restoreSiblings = () => {};
+ let snapshot: {
+ transform: string;
+ transition: string;
+ transformOrigin: string;
+ willChange: string;
+ zIndex: string;
+ } | null = null;
+ try {
+ const viewportRect = calendarContentScrollRef.value?.getBoundingClientRect() ?? null;
+ const sourceRect = sourceElement?.getBoundingClientRect() ?? null;
+ if (!sourceElement || !isRenderableRect(viewportRect) || !isRenderableRect(sourceRect)) {
+ apply();
+ return;
+ }
+
+ restoreSiblings = fadeOutCalendarSiblings(sourceElement);
+ snapshot = {
+ transform: sourceElement.style.transform,
+ transition: sourceElement.style.transition,
+ transformOrigin: sourceElement.style.transformOrigin,
+ willChange: sourceElement.style.willChange,
+ zIndex: sourceElement.style.zIndex,
+ };
+
+ const dx = viewportRect!.left - sourceRect!.left;
+ const dy = viewportRect!.top - sourceRect!.top;
+ const sx = Math.max(0.01, viewportRect!.width / sourceRect!.width);
+ const sy = Math.max(0.01, viewportRect!.height / sourceRect!.height);
+
+ sourceElement.style.transformOrigin = "top left";
+ sourceElement.style.willChange = "transform";
+ sourceElement.style.zIndex = "24";
+ sourceElement.style.transition = "none";
+ sourceElement.style.transform = "translate3d(0px, 0px, 0px) scale(1, 1)";
+ sourceElement.getBoundingClientRect();
+ await nextAnimationFrame();
+
+ sourceElement.style.transition = `transform ${CALENDAR_ZOOM_DURATION_MS}ms cubic-bezier(0.16, 0.86, 0.18, 1)`;
+ sourceElement.style.transform = `translate3d(${dx}px, ${dy}px, 0px) scale(${sx}, ${sy})`;
+ await waitForTransformTransition(sourceElement);
+
+ apply();
+ await nextTick();
+ await nextAnimationFrame();
+ } finally {
+ if (sourceElement && snapshot) {
+ sourceElement.style.transform = snapshot.transform;
+ sourceElement.style.transition = snapshot.transition;
+ sourceElement.style.transformOrigin = snapshot.transformOrigin;
+ sourceElement.style.willChange = snapshot.willChange;
+ sourceElement.style.zIndex = snapshot.zIndex;
+ }
+ restoreSiblings();
+ calendarZoomBusy.value = false;
+ }
+ }
+
+ function resolveMonthAnchor(event?: WheelEvent) {
+ const target = event?.target as HTMLElement | null;
+ const monthAttr = target?.closest("[data-calendar-month-index]")?.dataset.calendarMonthIndex;
+ if (monthAttr) {
+ const parsed = Number(monthAttr);
+ if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 11) return parsed;
+ }
+ if (calendarHoveredMonthIndex.value !== null) return calendarHoveredMonthIndex.value;
+ return calendarCursor.value.getMonth();
+ }
+
+ function fallbackMonthGridAnchorKey() {
+ if (monthCells.value.some((cell) => cell.key === selectedDateKey.value)) return selectedDateKey.value;
+ const middle = dayKey(new Date(calendarCursor.value.getFullYear(), calendarCursor.value.getMonth(), 15));
+ if (monthCells.value.some((cell) => cell.key === middle)) return middle;
+ return monthCells.value.find((cell) => cell.inMonth)?.key ?? monthCells.value[0]?.key ?? selectedDateKey.value;
+ }
+
+ function resolveWeekAnchor(event?: WheelEvent) {
+ const target = event?.target as HTMLElement | null;
+ const weekKey = target?.closest("[data-calendar-week-start-key]")?.dataset.calendarWeekStartKey;
+ if (weekKey) return weekKey;
+ if (calendarHoveredWeekStartKey.value) return calendarHoveredWeekStartKey.value;
+ if (calendarHoveredDayKey.value) return calendarHoveredDayKey.value;
+ return fallbackMonthGridAnchorKey();
+ }
+
+ function resolveDayAnchor(event?: WheelEvent) {
+ const target = event?.target as HTMLElement | null;
+ const dayKeyAttr = target?.closest("[data-calendar-day-key]")?.dataset.calendarDayKey;
+ if (dayKeyAttr) return dayKeyAttr;
+ if (calendarHoveredDayKey.value) return calendarHoveredDayKey.value;
+ return weekDays.value[0]?.key ?? selectedDateKey.value;
+ }
+
+ async function zoomInCalendar(event?: Event) {
+ const wheelEvent = event instanceof WheelEvent ? event : undefined;
+ if (calendarView.value === "year") {
+ const monthIndex = resolveMonthAnchor(wheelEvent);
+ const sourceElement =
+ queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`) ??
+ queryCalendarElement("[data-calendar-month-index]");
+ if (maybePrimeWheelZoom(wheelEvent, calendarPrimeMonthToken(monthIndex))) return;
+ await animateCalendarZoomIntoSource(sourceElement, () => {
+ openYearMonth(monthIndex);
+ });
+ return;
+ }
+
+ if (calendarView.value === "month" || calendarView.value === "agenda") {
+ const anchorDayKey = resolveWeekAnchor(wheelEvent);
+ const rowStartKey = weekRowStartForDate(anchorDayKey);
+ const sourceElement =
+ queryCalendarElement(`[data-calendar-layer="month"] [data-calendar-week-start-key="${rowStartKey}"]`) ??
+ queryCalendarElement(`[data-calendar-layer="month"] [data-calendar-day-key="${anchorDayKey}"]`) ??
+ queryCalendarElement(`[data-calendar-layer="month"] [data-calendar-week-start-key]`) ??
+ queryCalendarElement(`[data-calendar-layer="month"] [data-calendar-day-key]`);
+ if (maybePrimeWheelZoom(wheelEvent, calendarPrimeWeekToken(rowStartKey))) return;
+ await animateCalendarZoomIntoSource(sourceElement, () => {
+ openWeekView(anchorDayKey);
+ });
+ return;
+ }
+
+ if (calendarView.value === "week") {
+ const dayAnchor = resolveDayAnchor(wheelEvent);
+ const sourceElement =
+ queryCalendarElement(`[data-calendar-layer="week"] [data-calendar-day-key="${dayAnchor}"]`) ??
+ queryCalendarElement(`[data-calendar-layer="week"] [data-calendar-day-key]`);
+ if (maybePrimeWheelZoom(wheelEvent, calendarPrimeDayToken(dayAnchor))) return;
+ await animateCalendarZoomIntoSource(sourceElement, () => {
+ openDayView(dayAnchor);
+ });
+ }
+ }
+
+ async function zoomToMonth(monthIndex: number) {
+ await animateCalendarZoomIntoSource(queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`), () => {
+ openYearMonth(monthIndex);
+ });
+ }
+
+ async function zoomOutCalendar() {
+ focusedCalendarEventId.value = "";
+ clearCalendarZoomPrime();
+
+ if (calendarView.value === "day") {
+ const targetDayKey = selectedDateKey.value;
+ await animateCalendarFlipTransition(
+ queryCalendarElement(`[data-calendar-month-index="${calendarCursor.value.getMonth()}"]`),
+ () => {
+ calendarView.value = "week";
+ },
+ () =>
+ queryCalendarElement(`[data-calendar-layer="week"] [data-calendar-day-key="${targetDayKey}"]`) ??
+ queryCalendarElement(`[data-calendar-layer="week"] [data-calendar-day-key]`),
+ );
+ return;
+ }
+
+ if (calendarView.value === "week") {
+ const targetRowKey = weekRowStartForDate(selectedDateKey.value);
+ await animateCalendarFlipTransition(
+ queryCalendarElement(`[data-calendar-month-index="${calendarCursor.value.getMonth()}"]`),
+ () => {
+ calendarView.value = "month";
+ },
+ () =>
+ queryCalendarElement(`[data-calendar-layer="month"] [data-calendar-week-start-key="${targetRowKey}"]`) ??
+ queryCalendarElement(`[data-calendar-layer="month"] [data-calendar-day-key="${selectedDateKey.value}"]`) ??
+ queryCalendarElement(`[data-calendar-layer="month"] [data-calendar-week-start-key]`) ??
+ queryCalendarElement(`[data-calendar-layer="month"] [data-calendar-day-key]`),
+ );
+ return;
+ }
+
+ if (calendarView.value === "month" || calendarView.value === "agenda") {
+ const targetMonthIndex = calendarCursor.value.getMonth();
+ await animateCalendarFlipTransition(
+ queryCalendarElement(`[data-calendar-month-index="${targetMonthIndex}"]`),
+ () => {
+ calendarView.value = "year";
+ },
+ () => queryCalendarElement(`[data-calendar-month-index="${targetMonthIndex}"]`),
+ );
+ }
+ }
+
+ function onCalendarHierarchyWheel(event: WheelEvent) {
+ const now = Date.now();
+ if (calendarZoomBusy.value) return;
+ if (now < calendarWheelLockUntil) return;
+ if (Math.abs(event.deltaY) < 5) return;
+ calendarWheelLockUntil = now + 140;
+
+ if (event.deltaY < 0) {
+ void zoomInCalendar(event);
+ return;
+ }
+
+ void zoomOutCalendar();
+ }
+
+ async function setCalendarZoomLevel(targetView: CalendarHierarchyView) {
+ let currentIndex = calendarZoomOrder.indexOf(normalizedCalendarView.value);
+ const targetIndex = calendarZoomOrder.indexOf(targetView);
+ if (currentIndex < 0 || targetIndex < 0 || currentIndex === targetIndex) return;
+
+ while (currentIndex !== targetIndex) {
+ if (targetIndex > currentIndex) {
+ await zoomInCalendar();
+ } else {
+ await zoomOutCalendar();
+ }
+ currentIndex = calendarZoomOrder.indexOf(normalizedCalendarView.value);
+ }
+ }
+
+ function onCalendarZoomSliderInput(event: Event) {
+ const value = Number((event.target as HTMLInputElement | null)?.value ?? NaN);
+ if (!Number.isFinite(value)) return;
+ const targetIndex = Math.max(0, Math.min(3, Math.round(value)));
+ const targetView = calendarZoomOrder[targetIndex];
+ if (!targetView) return;
+ void setCalendarZoomLevel(targetView);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Month cells, rows, week days
+ // ---------------------------------------------------------------------------
+ const monthCells = computed(() => {
+ const year = calendarCursor.value.getFullYear();
+ const month = calendarCursor.value.getMonth();
+ const first = new Date(year, month, 1);
+ const start = new Date(year, month, 1 - first.getDay());
+
+ return Array.from({ length: 42 }, (_, index) => {
+ const d = new Date(start);
+ d.setDate(start.getDate() + index);
+ const key = dayKey(d);
+
+ return {
+ key,
+ day: d.getDate(),
+ inMonth: d.getMonth() === month,
+ events: getEventsByDate(key),
+ };
+ });
+ });
+
+ const monthRows = computed(() => {
+ const rows: Array<{ key: string; startKey: string; cells: typeof monthCells.value }> = [];
+ for (let index = 0; index < monthCells.value.length; index += 7) {
+ const cells = monthCells.value.slice(index, index + 7);
+ if (!cells.length) continue;
+ rows.push({
+ key: `${cells[0]?.key ?? index}-week-row`,
+ startKey: cells[0]?.key ?? selectedDateKey.value,
+ cells,
+ });
+ }
+ return rows;
+ });
+
+ function monthCellHasFocusedEvent(events: CalendarEvent[]) {
+ const id = focusedCalendarEventId.value.trim();
+ if (!id) return false;
+ return events.some((event) => event.id === id);
+ }
+
+ function monthCellEvents(events: CalendarEvent[]) {
+ const id = focusedCalendarEventId.value.trim();
+ if (!id) return events.slice(0, 2);
+ const focused = events.find((event) => event.id === id);
+ if (!focused) return events.slice(0, 2);
+ const rest = events.filter((event) => event.id !== id).slice(0, 1);
+ return [focused, ...rest];
+ }
+
+ const weekDays = computed(() => {
+ const base = new Date(`${selectedDateKey.value}T00:00:00`);
+ const mondayOffset = (base.getDay() + 6) % 7;
+ const monday = new Date(base);
+ monday.setDate(base.getDate() - mondayOffset);
+
+ return Array.from({ length: 7 }, (_, index) => {
+ const d = new Date(monday);
+ d.setDate(monday.getDate() + index);
+ const key = dayKey(d);
+
+ return {
+ key,
+ label: new Intl.DateTimeFormat("en-US", { weekday: "short" }).format(d),
+ day: d.getDate(),
+ events: getEventsByDate(key),
+ };
+ });
+ });
+
+ const calendarPeriodLabel = computed(() => {
+ if (calendarView.value === "month") {
+ return monthLabel.value;
+ }
+
+ if (calendarView.value === "year") {
+ return String(calendarCursor.value.getFullYear());
+ }
+
+ if (calendarView.value === "week") {
+ const first = weekDays.value[0];
+ const last = weekDays.value[weekDays.value.length - 1];
+ if (!first || !last) return "";
+ return `${formatDay(`${first.key}T00:00:00`)} - ${formatDay(`${last.key}T00:00:00`)}`;
+ }
+
+ if (calendarView.value === "day") {
+ return formatDay(`${selectedDateKey.value}T00:00:00`);
+ }
+
+ return `Agenda · ${monthLabel.value}`;
+ });
+
+ const yearMonths = computed(() => {
+ const year = calendarCursor.value.getFullYear();
+ return Array.from({ length: 12 }, (_, monthIndex) => {
+ const monthStart = new Date(year, monthIndex, 1);
+ const monthEnd = new Date(year, monthIndex + 1, 1);
+ const items = sortedEvents.value.filter((event) => {
+ const d = new Date(event.start);
+ return d >= monthStart && d < monthEnd;
+ });
+
+ return {
+ monthIndex,
+ label: new Intl.DateTimeFormat("en-US", { month: "long" }).format(monthStart),
+ count: items.length,
+ first: items[0],
+ };
+ });
+ });
+
+ const selectedDayEvents = computed(() => getEventsByDate(selectedDateKey.value));
+
+ // ---------------------------------------------------------------------------
+ // Navigation helpers
+ // ---------------------------------------------------------------------------
+ function shiftCalendar(step: number) {
+ focusedCalendarEventId.value = "";
+ if (calendarView.value === "year") {
+ const next = new Date(calendarCursor.value);
+ next.setFullYear(next.getFullYear() + step);
+ calendarCursor.value = new Date(next.getFullYear(), next.getMonth(), 1);
+ const selected = new Date(`${selectedDateKey.value}T00:00:00`);
+ selected.setFullYear(selected.getFullYear() + step);
+ selectedDateKey.value = dayKey(selected);
+ return;
+ }
+
+ if (calendarView.value === "month" || calendarView.value === "agenda") {
+ const next = new Date(calendarCursor.value);
+ next.setMonth(next.getMonth() + step);
+ calendarCursor.value = new Date(next.getFullYear(), next.getMonth(), 1);
+ return;
+ }
+
+ const current = new Date(`${selectedDateKey.value}T00:00:00`);
+ const days = calendarView.value === "week" ? 7 : 1;
+ current.setDate(current.getDate() + days * step);
+
+ selectedDateKey.value = dayKey(current);
+ calendarCursor.value = new Date(current.getFullYear(), current.getMonth(), 1);
+ }
+
+ function setToday() {
+ focusedCalendarEventId.value = "";
+ const now = new Date();
+ selectedDateKey.value = dayKey(now);
+ calendarCursor.value = new Date(now.getFullYear(), now.getMonth(), 1);
+ }
+
+ function pickDate(key: string) {
+ focusedCalendarEventId.value = "";
+ selectedDateKey.value = key;
+ const d = new Date(`${key}T00:00:00`);
+ calendarCursor.value = new Date(d.getFullYear(), d.getMonth(), 1);
+ }
+
+ function openDayView(key: string) {
+ pickDate(key);
+ calendarView.value = "day";
+ }
+
+ function openWeekView(key: string) {
+ pickDate(key);
+ calendarView.value = "week";
+ }
+
+ function openYearMonth(monthIndex: number) {
+ focusedCalendarEventId.value = "";
+ const year = calendarCursor.value.getFullYear();
+ calendarCursor.value = new Date(year, monthIndex, 1);
+ selectedDateKey.value = dayKey(new Date(year, monthIndex, 1));
+ calendarView.value = "month";
+ }
+
+ function formatYearMonthFirst(item: { first?: CalendarEvent }) {
+ if (!item.first) return "";
+ return `${formatDay(item.first.start)} · ${item.first.title}`;
+ }
+
+ // ---------------------------------------------------------------------------
+ // Event creation
+ // ---------------------------------------------------------------------------
+ const commEventForm = ref({
+ startDate: "",
+ startTime: "",
+ durationMinutes: 30,
+ });
+ const commEventMode = ref<"planned" | "logged">("planned");
+ const commEventSaving = ref(false);
+ const commEventError = ref("");
+
+ function setDefaultCommEventForm(mode: "planned" | "logged") {
+ const start = mode === "planned"
+ ? roundToNextQuarter(new Date(Date.now() + 15 * 60 * 1000))
+ : roundToPrevQuarter(new Date(Date.now() - 30 * 60 * 1000));
+ commEventForm.value = {
+ startDate: toInputDate(start),
+ startTime: toInputTime(start),
+ durationMinutes: 30,
+ };
+ }
+
+ function buildCommEventTitle(text: string, mode: "planned" | "logged", contact: string) {
+ const cleaned = text.replace(/\s+/g, " ").trim();
+ if (cleaned) {
+ const sentence = cleaned.split(/[.!?\n]/)[0]?.trim() ?? "";
+ if (sentence) return sentence.slice(0, 120);
+ }
+ return mode === "logged" ? `Отчёт по контакту ${contact}` : `Событие с ${contact}`;
+ }
+
+ function openCommEventModal(mode: "planned" | "logged", hasThread: boolean) {
+ if (!hasThread) return;
+ commEventMode.value = mode;
+ setDefaultCommEventForm(mode);
+ commEventError.value = "";
+ }
+
+ function closeCommEventModal() {
+ if (commEventSaving.value) return;
+ commEventError.value = "";
+ }
+
+ async function createCommEvent(contactName: string, draftText: string) {
+ if (!contactName || commEventSaving.value) return;
+
+ const note = draftText.trim();
+ const title = buildCommEventTitle(note, commEventMode.value, contactName);
+ const duration = Number(commEventForm.value.durationMinutes || 0);
+ if (!note) {
+ commEventError.value = "Текст события обязателен";
+ return;
+ }
+
+ if (!commEventForm.value.startDate || !commEventForm.value.startTime) {
+ commEventError.value = "Date and time are required";
+ return;
+ }
+
+ const start = new Date(`${commEventForm.value.startDate}T${commEventForm.value.startTime}:00`);
+ if (Number.isNaN(start.getTime())) {
+ commEventError.value = "Invalid date or time";
+ return;
+ }
+
+ const safeDuration = Number.isFinite(duration) && duration > 0 ? duration : 30;
+ const end = new Date(start);
+ end.setMinutes(end.getMinutes() + safeDuration);
+
+ commEventSaving.value = true;
+ commEventError.value = "";
+ try {
+ const res = await doCreateCalendarEvent({
+ input: {
+ title,
+ start: start.toISOString(),
+ end: end.toISOString(),
+ contact: contactName,
+ note,
+ archived: commEventMode.value === "logged",
+ archiveNote: commEventMode.value === "logged" ? note : undefined,
+ },
+ });
+ if (res?.data?.createCalendarEvent) {
+ calendarEvents.value = [res.data.createCalendarEvent as CalendarEvent, ...calendarEvents.value];
+ }
+ selectedDateKey.value = dayKey(start);
+ calendarCursor.value = new Date(start.getFullYear(), start.getMonth(), 1);
+ commEventError.value = "";
+ return true;
+ } catch (error: any) {
+ commEventError.value = String(error?.message ?? error ?? "Failed to create event");
+ return false;
+ } finally {
+ commEventSaving.value = false;
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Event archival
+ // ---------------------------------------------------------------------------
+ const eventCloseOpen = ref>({});
+ const eventCloseDraft = ref>({});
+ const eventCloseSaving = ref>({});
+ const eventCloseError = ref>({});
+
+ function canManuallyCloseEvent(entry: { kind: string; event?: CalendarEvent; phase?: string }) {
+ if (entry.kind !== "eventLifecycle" || !entry.event) return false;
+ return !isEventFinalStatus(entry.event.isArchived);
+ }
+
+ function isEventCloseOpen(eventId: string) {
+ return Boolean(eventCloseOpen.value[eventId]);
+ }
+
+ function toggleEventClose(eventId: string) {
+ const next = !eventCloseOpen.value[eventId];
+ eventCloseOpen.value = { ...eventCloseOpen.value, [eventId]: next };
+ if (next && !eventCloseDraft.value[eventId]) {
+ eventCloseDraft.value = { ...eventCloseDraft.value, [eventId]: "" };
+ }
+ if (!next && eventCloseError.value[eventId]) {
+ eventCloseError.value = { ...eventCloseError.value, [eventId]: "" };
+ }
+ }
+
+ async function archiveEventManually(event: CalendarEvent) {
+ const eventId = event.id;
+ const archiveNote = String(eventCloseDraft.value[eventId] ?? "").trim();
+ if (eventCloseSaving.value[eventId]) return;
+
+ eventCloseSaving.value = { ...eventCloseSaving.value, [eventId]: true };
+ eventCloseError.value = { ...eventCloseError.value, [eventId]: "" };
+ try {
+ await doArchiveCalendarEvent({
+ input: {
+ id: eventId,
+ archiveNote: archiveNote || undefined,
+ },
+ });
+ eventCloseOpen.value = { ...eventCloseOpen.value, [eventId]: false };
+ eventCloseDraft.value = { ...eventCloseDraft.value, [eventId]: "" };
+ } catch (error: any) {
+ eventCloseError.value = { ...eventCloseError.value, [eventId]: String(error?.message ?? error ?? "Failed to archive event") };
+ } finally {
+ eventCloseSaving.value = { ...eventCloseSaving.value, [eventId]: false };
+ }
+ }
+
+ return {
+ // Core state
+ calendarEvents,
+ calendarView,
+ calendarCursor,
+ selectedDateKey,
+ focusedCalendarEventId,
+ lifecycleNowMs,
+
+ // Computeds
+ sortedEvents,
+ focusedCalendarEvent,
+ eventsByDate,
+ getEventsByDate,
+ monthLabel,
+ calendarViewOptions,
+ monthCells,
+ monthRows,
+ monthCellHasFocusedEvent,
+ monthCellEvents,
+ weekDays,
+ calendarPeriodLabel,
+ yearMonths,
+ selectedDayEvents,
+
+ // Zoom / camera
+ calendarContentWrapRef,
+ calendarContentScrollRef,
+ calendarSceneRef,
+ calendarViewportHeight,
+ calendarHoveredMonthIndex,
+ calendarHoveredWeekStartKey,
+ calendarHoveredDayKey,
+ calendarZoomBusy,
+ calendarCameraState,
+ calendarZoomPrimeToken,
+ calendarZoomPrimeScale,
+ calendarZoomPrimeTicks,
+ normalizedCalendarView,
+ calendarZoomLevelIndex,
+ calendarSceneTransformStyle,
+ calendarZoomOrder,
+
+ // Zoom / camera setters
+ setCalendarContentWrapRef,
+ setCalendarContentScrollRef,
+ setCalendarSceneRef,
+ setCalendarHoveredMonthIndex,
+ setCalendarHoveredWeekStartKey,
+ setCalendarHoveredDayKey,
+ onCalendarSceneMouseLeave,
+ clearCalendarZoomPrime,
+ calendarPrimeMonthToken,
+ calendarPrimeWeekToken,
+ calendarPrimeDayToken,
+ calendarPrimeStyle,
+ maybePrimeWheelZoom,
+ queryCalendarElement,
+ getCalendarViewportRect,
+ getCalendarCameraViewportRect,
+ getElementRectInCalendar,
+ getElementRectInScene,
+ fallbackZoomOriginRectInScene,
+ weekRowStartForDate,
+
+ // Zoom animations
+ zoomInCalendar,
+ zoomToMonth,
+ zoomOutCalendar,
+ onCalendarHierarchyWheel,
+ setCalendarZoomLevel,
+ onCalendarZoomSliderInput,
+
+ // Navigation
+ shiftCalendar,
+ setToday,
+ pickDate,
+ openDayView,
+ openWeekView,
+ openYearMonth,
+ formatYearMonthFirst,
+
+ // Event creation
+ commEventForm,
+ commEventMode,
+ commEventSaving,
+ commEventError,
+ createCommEvent,
+ openCommEventModal,
+ closeCommEventModal,
+ setDefaultCommEventForm,
+ buildCommEventTitle,
+
+ // Event archival
+ eventCloseOpen,
+ eventCloseDraft,
+ eventCloseSaving,
+ eventCloseError,
+ canManuallyCloseEvent,
+ isEventCloseOpen,
+ toggleEventClose,
+ archiveEventManually,
+
+ // Refetch
+ refetchCalendar,
+ };
+}
diff --git a/frontend/app/composables/useCallAudio.ts b/frontend/app/composables/useCallAudio.ts
new file mode 100644
index 0000000..ef1546f
--- /dev/null
+++ b/frontend/app/composables/useCallAudio.ts
@@ -0,0 +1,436 @@
+import { ref, nextTick } from "vue";
+import {
+ UpdateCommunicationTranscriptMutationDocument,
+ CommunicationsQueryDocument,
+} from "~~/graphql/generated";
+import { useMutation } from "@vue/apollo-composable";
+import { isVoiceCaptureSupported, transcribeAudioBlob } from "~/composables/useVoiceTranscription";
+import type { CommItem } from "~/composables/crm-types";
+
+export function useCallAudio() {
+ // ---------------------------------------------------------------------------
+ // State
+ // ---------------------------------------------------------------------------
+ const commCallWaveHosts = new Map();
+ const commCallWaveSurfers = new Map();
+ const commCallPlayableById = ref>({});
+ const commCallPlayingById = ref>({});
+ const callTranscriptOpen = ref>({});
+ const callTranscriptLoading = ref>({});
+ const callTranscriptText = ref>({});
+ const callTranscriptError = ref>({});
+
+ // Event archive recording state
+ const eventArchiveRecordingById = ref>({});
+ const eventArchiveTranscribingById = ref>({});
+ const eventArchiveMicErrorById = ref>({});
+ let eventArchiveMediaRecorder: MediaRecorder | null = null;
+ let eventArchiveRecorderStream: MediaStream | null = null;
+ let eventArchiveRecorderMimeType = "audio/webm";
+ let eventArchiveChunks: Blob[] = [];
+ let eventArchiveTargetEventId = "";
+
+ // WaveSurfer module cache
+ let waveSurferModulesPromise: Promise<{ WaveSurfer: any; RecordPlugin: any }> | null = null;
+
+ // ---------------------------------------------------------------------------
+ // Apollo Mutation
+ // ---------------------------------------------------------------------------
+ const { mutate: doUpdateCommunicationTranscript } = useMutation(UpdateCommunicationTranscriptMutationDocument, {
+ refetchQueries: [{ query: CommunicationsQueryDocument }],
+ });
+
+ // ---------------------------------------------------------------------------
+ // WaveSurfer lazy loading
+ // ---------------------------------------------------------------------------
+ async function loadWaveSurferModules() {
+ if (!waveSurferModulesPromise) {
+ waveSurferModulesPromise = Promise.all([
+ import("wavesurfer.js"),
+ import("wavesurfer.js/dist/plugins/record.esm.js"),
+ ]).then(([ws, rec]) => ({
+ WaveSurfer: ws.default,
+ RecordPlugin: rec.default,
+ }));
+ }
+ return waveSurferModulesPromise;
+ }
+
+ // ---------------------------------------------------------------------------
+ // Call wave helpers
+ // ---------------------------------------------------------------------------
+ function setCommCallPlaying(itemId: string, value: boolean) {
+ commCallPlayingById.value = {
+ ...commCallPlayingById.value,
+ [itemId]: value,
+ };
+ }
+
+ function isCommCallPlaying(itemId: string) {
+ return Boolean(commCallPlayingById.value[itemId]);
+ }
+
+ function getCallAudioUrl(item?: CommItem) {
+ return String(item?.audioUrl ?? "").trim();
+ }
+
+ function isCommCallPlayable(item: CommItem) {
+ const known = commCallPlayableById.value[item.id];
+ if (typeof known === "boolean") return known;
+ return Boolean(getCallAudioUrl(item));
+ }
+
+ function pauseOtherCommCallWaves(currentItemId: string) {
+ for (const [itemId, ws] of commCallWaveSurfers.entries()) {
+ if (itemId === currentItemId) continue;
+ ws.pause?.();
+ setCommCallPlaying(itemId, false);
+ }
+ }
+
+ function parseDurationToSeconds(raw?: string) {
+ if (!raw) return 0;
+ const text = raw.trim().toLowerCase();
+ if (!text) return 0;
+
+ const ms = text.match(/(\d+)\s*m(?:in)?\s*(\d+)?\s*s?/);
+ if (ms) {
+ const m = Number(ms[1] ?? 0);
+ const s = Number(ms[2] ?? 0);
+ return m * 60 + s;
+ }
+ const colon = text.match(/(\d+):(\d+)/);
+ if (colon) {
+ return Number(colon[1] ?? 0) * 60 + Number(colon[2] ?? 0);
+ }
+ const sec = text.match(/(\d+)\s*s/);
+ if (sec) return Number(sec[1] ?? 0);
+ return 0;
+ }
+
+ function buildCallWavePeaks(item: CommItem, size = 320) {
+ const stored = Array.isArray(item.waveform)
+ ? item.waveform.map((value) => Number(value)).filter((value) => Number.isFinite(value) && value > 0)
+ : [];
+ if (stored.length) {
+ const sampled = new Float32Array(size);
+ for (let i = 0; i < size; i += 1) {
+ const t = size <= 1 ? 0 : i / (size - 1);
+ const idx = Math.min(stored.length - 1, Math.round(t * (stored.length - 1)));
+ sampled[i] = Math.max(0.05, Math.min(1, stored[idx] ?? 0.05));
+ }
+ return sampled;
+ }
+
+ const source = `${item.text} ${(item.transcript ?? []).join(" ")}`.trim() || item.contact;
+ let seed = 0;
+ for (let i = 0; i < source.length; i += 1) {
+ seed = (seed * 31 + source.charCodeAt(i)) >>> 0;
+ }
+ const rand = () => {
+ seed = (seed * 1664525 + 1013904223) >>> 0;
+ return seed / 0xffffffff;
+ };
+
+ const out = new Float32Array(size);
+ let smooth = 0;
+ for (let i = 0; i < size; i += 1) {
+ const t = i / Math.max(1, size - 1);
+ const burst = Math.max(0, Math.sin(t * Math.PI * (3 + (source.length % 7))));
+ const noise = (rand() * 2 - 1) * 0.65;
+ smooth = smooth * 0.7 + noise * 0.3;
+ out[i] = Math.max(0.05, Math.min(1, 0.12 + Math.abs(smooth) * 0.48 + burst * 0.4));
+ }
+ return out;
+ }
+
+ function destroyCommCallWave(itemId: string) {
+ const ws = commCallWaveSurfers.get(itemId);
+ if (!ws) return;
+ ws.destroy();
+ commCallWaveSurfers.delete(itemId);
+ const nextPlayable = { ...commCallPlayableById.value };
+ delete nextPlayable[itemId];
+ commCallPlayableById.value = nextPlayable;
+ const nextPlaying = { ...commCallPlayingById.value };
+ delete nextPlaying[itemId];
+ commCallPlayingById.value = nextPlaying;
+ }
+
+ function destroyAllCommCallWaves() {
+ for (const itemId of commCallWaveSurfers.keys()) {
+ destroyCommCallWave(itemId);
+ }
+ commCallWaveHosts.clear();
+ }
+
+ async function ensureCommCallWave(itemId: string, callItem?: CommItem) {
+ const host = commCallWaveHosts.get(itemId);
+ if (!host) return;
+ if (commCallWaveSurfers.has(itemId)) return;
+
+ if (!callItem) return;
+
+ const { WaveSurfer } = await loadWaveSurferModules();
+ const durationSeconds =
+ parseDurationToSeconds(callItem.duration) ||
+ Math.max(8, Math.min(120, Math.round(((callItem.transcript ?? []).join(" ").length || callItem.text.length) / 10)));
+ const peaks = buildCallWavePeaks(callItem, 360);
+ const audioUrl = getCallAudioUrl(callItem);
+
+ const ws = WaveSurfer.create({
+ container: host,
+ height: 30,
+ waveColor: "rgba(180, 206, 255, 0.88)",
+ progressColor: "rgba(118, 157, 248, 0.95)",
+ cursorWidth: 0,
+ interact: Boolean(audioUrl),
+ normalize: true,
+ barWidth: 0,
+ });
+
+ ws.on("play", () => setCommCallPlaying(itemId, true));
+ ws.on("pause", () => setCommCallPlaying(itemId, false));
+ ws.on("finish", () => setCommCallPlaying(itemId, false));
+
+ let playable = false;
+ if (audioUrl) {
+ try {
+ await ws.load(audioUrl, [peaks], durationSeconds);
+ playable = true;
+ } catch {
+ await ws.load("", [peaks], durationSeconds);
+ playable = false;
+ }
+ } else {
+ await ws.load("", [peaks], durationSeconds);
+ }
+
+ commCallPlayableById.value = {
+ ...commCallPlayableById.value,
+ [itemId]: playable,
+ };
+ commCallWaveSurfers.set(itemId, ws);
+ }
+
+ async function syncCommCallWaves(activeCallIds: Set, getCallItem: (id: string) => CommItem | undefined) {
+ await nextTick();
+
+ for (const id of commCallWaveSurfers.keys()) {
+ if (!activeCallIds.has(id) || !commCallWaveHosts.has(id)) {
+ destroyCommCallWave(id);
+ }
+ }
+
+ for (const id of activeCallIds) {
+ if (commCallWaveHosts.has(id)) {
+ await ensureCommCallWave(id, getCallItem(id));
+ }
+ }
+ }
+
+ function setCommCallWaveHost(itemId: string, element: Element | null) {
+ if (!(element instanceof HTMLDivElement)) {
+ commCallWaveHosts.delete(itemId);
+ destroyCommCallWave(itemId);
+ return;
+ }
+ commCallWaveHosts.set(itemId, element);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Call playback toggle
+ // ---------------------------------------------------------------------------
+ async function toggleCommCallPlayback(item: CommItem) {
+ if (!isCommCallPlayable(item)) return;
+ const itemId = item.id;
+ await ensureCommCallWave(itemId, item);
+ const ws = commCallWaveSurfers.get(itemId);
+ if (!ws) return;
+ if (isCommCallPlaying(itemId)) {
+ ws.pause?.();
+ return;
+ }
+ pauseOtherCommCallWaves(itemId);
+ await ws.play?.();
+ }
+
+ // ---------------------------------------------------------------------------
+ // Call transcription
+ // ---------------------------------------------------------------------------
+ async function transcribeCallItem(item: CommItem) {
+ const itemId = item.id;
+ if (callTranscriptLoading.value[itemId]) return;
+ if (callTranscriptText.value[itemId]) return;
+ if (Array.isArray(item.transcript) && item.transcript.length) {
+ const persisted = item.transcript.map((line) => String(line ?? "").trim()).filter(Boolean).join("\n");
+ if (persisted) {
+ callTranscriptText.value[itemId] = persisted;
+ return;
+ }
+ }
+
+ const audioUrl = getCallAudioUrl(item);
+ if (!audioUrl) {
+ callTranscriptError.value[itemId] = "Audio source is missing";
+ return;
+ }
+
+ callTranscriptLoading.value[itemId] = true;
+ callTranscriptError.value[itemId] = "";
+ try {
+ const audioBlob = await fetch(audioUrl).then((res) => {
+ if (!res.ok) throw new Error(`Audio fetch failed: ${res.status}`);
+ return res.blob();
+ });
+ const text = await transcribeAudioBlob(audioBlob);
+ callTranscriptText.value[itemId] = text || "(empty transcript)";
+ await doUpdateCommunicationTranscript({ id: itemId, transcript: text ? [text] : [] });
+ } catch (error: any) {
+ callTranscriptError.value[itemId] = String(error?.message ?? error ?? "Transcription failed");
+ } finally {
+ callTranscriptLoading.value[itemId] = false;
+ }
+ }
+
+ function toggleCallTranscript(item: CommItem) {
+ const itemId = item.id;
+ const next = !callTranscriptOpen.value[itemId];
+ callTranscriptOpen.value[itemId] = next;
+ if (next) {
+ void transcribeCallItem(item);
+ }
+ }
+
+ function isCallTranscriptOpen(itemId: string) {
+ return Boolean(callTranscriptOpen.value[itemId]);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Event archive recording
+ // ---------------------------------------------------------------------------
+ function isEventArchiveRecording(eventId: string) {
+ return Boolean(eventArchiveRecordingById.value[eventId]);
+ }
+
+ function isEventArchiveTranscribing(eventId: string) {
+ return Boolean(eventArchiveTranscribingById.value[eventId]);
+ }
+
+ async function startEventArchiveRecording(
+ eventId: string,
+ opts: {
+ pilotMicSupported: { value: boolean };
+ eventCloseDraft: { value: Record };
+ },
+ ) {
+ if (eventArchiveMediaRecorder || isEventArchiveTranscribing(eventId)) return;
+ eventArchiveMicErrorById.value = { ...eventArchiveMicErrorById.value, [eventId]: "" };
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ const preferredMime = "audio/webm;codecs=opus";
+ const recorder = MediaRecorder.isTypeSupported(preferredMime)
+ ? new MediaRecorder(stream, { mimeType: preferredMime })
+ : new MediaRecorder(stream);
+
+ eventArchiveRecorderStream = stream;
+ eventArchiveRecorderMimeType = recorder.mimeType || "audio/webm";
+ eventArchiveMediaRecorder = recorder;
+ eventArchiveChunks = [];
+ eventArchiveTargetEventId = eventId;
+ eventArchiveRecordingById.value = { ...eventArchiveRecordingById.value, [eventId]: true };
+
+ recorder.ondataavailable = (event: BlobEvent) => {
+ if (event.data?.size) eventArchiveChunks.push(event.data);
+ };
+
+ recorder.onstop = async () => {
+ const targetId = eventArchiveTargetEventId;
+ eventArchiveRecordingById.value = { ...eventArchiveRecordingById.value, [targetId]: false };
+ eventArchiveMediaRecorder = null;
+ eventArchiveTargetEventId = "";
+ if (eventArchiveRecorderStream) {
+ eventArchiveRecorderStream.getTracks().forEach((track) => track.stop());
+ eventArchiveRecorderStream = null;
+ }
+
+ const audioBlob = new Blob(eventArchiveChunks, { type: eventArchiveRecorderMimeType });
+ eventArchiveChunks = [];
+ if (!targetId || audioBlob.size === 0) return;
+ eventArchiveTranscribingById.value = { ...eventArchiveTranscribingById.value, [targetId]: true };
+ try {
+ const text = await transcribeAudioBlob(audioBlob);
+ if (!text) {
+ eventArchiveMicErrorById.value = { ...eventArchiveMicErrorById.value, [targetId]: "Could not recognize speech" };
+ return;
+ }
+ const previous = String(opts.eventCloseDraft.value[targetId] ?? "").trim();
+ const merged = previous ? `${previous} ${text}` : text;
+ opts.eventCloseDraft.value = { ...opts.eventCloseDraft.value, [targetId]: merged };
+ } catch (error: any) {
+ eventArchiveMicErrorById.value = {
+ ...eventArchiveMicErrorById.value,
+ [targetId]: String(error?.data?.message ?? error?.message ?? "Voice transcription failed"),
+ };
+ } finally {
+ eventArchiveTranscribingById.value = { ...eventArchiveTranscribingById.value, [targetId]: false };
+ }
+ };
+
+ recorder.start();
+ } catch {
+ eventArchiveMicErrorById.value = { ...eventArchiveMicErrorById.value, [eventId]: "No microphone access" };
+ }
+ }
+
+ function stopEventArchiveRecording() {
+ if (!eventArchiveMediaRecorder || eventArchiveMediaRecorder.state === "inactive") return;
+ eventArchiveMediaRecorder.stop();
+ }
+
+ function toggleEventArchiveRecording(
+ eventId: string,
+ opts: {
+ pilotMicSupported: { value: boolean };
+ eventCloseDraft: { value: Record };
+ },
+ ) {
+ if (!opts.pilotMicSupported.value) {
+ eventArchiveMicErrorById.value = { ...eventArchiveMicErrorById.value, [eventId]: "Recording is not supported in this browser" };
+ return;
+ }
+ if (isEventArchiveRecording(eventId)) {
+ stopEventArchiveRecording();
+ return;
+ }
+ void startEventArchiveRecording(eventId, opts);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Public API
+ // ---------------------------------------------------------------------------
+ return {
+ commCallWaveHosts,
+ commCallPlayableById,
+ commCallPlayingById,
+ callTranscriptOpen,
+ callTranscriptLoading,
+ callTranscriptText,
+ callTranscriptError,
+ ensureCommCallWave,
+ destroyCommCallWave,
+ destroyAllCommCallWaves,
+ toggleCommCallPlayback,
+ syncCommCallWaves,
+ transcribeCallItem,
+ toggleCallTranscript,
+ isCallTranscriptOpen,
+ eventArchiveRecordingById,
+ eventArchiveTranscribingById,
+ eventArchiveMicErrorById,
+ startEventArchiveRecording,
+ stopEventArchiveRecording,
+ toggleEventArchiveRecording,
+ setCommCallWaveHost,
+ };
+}
diff --git a/frontend/app/composables/useChangeReview.ts b/frontend/app/composables/useChangeReview.ts
new file mode 100644
index 0000000..9588528
--- /dev/null
+++ b/frontend/app/composables/useChangeReview.ts
@@ -0,0 +1,305 @@
+import { ref, computed, watch, type Ref } from "vue";
+import { useMutation } from "@vue/apollo-composable";
+import {
+ ConfirmLatestChangeSetMutationDocument,
+ RollbackLatestChangeSetMutationDocument,
+ RollbackChangeSetItemsMutationDocument,
+ ChatMessagesQueryDocument,
+ ChatConversationsQueryDocument,
+ ContactsQueryDocument,
+ CommunicationsQueryDocument,
+ ContactInboxesQueryDocument,
+ CalendarQueryDocument,
+ DealsQueryDocument,
+ FeedQueryDocument,
+ PinsQueryDocument,
+ DocumentsQueryDocument,
+} from "~~/graphql/generated";
+import type { PilotMessage, PilotChangeItem } from "~/composables/crm-types";
+
+export function useChangeReview(opts: {
+ pilotMessages: Ref;
+ refetchAllCrmQueries: () => Promise;
+ refetchChatMessages: () => Promise;
+ refetchChatConversations: () => Promise;
+}) {
+ // ---------------------------------------------------------------------------
+ // State
+ // ---------------------------------------------------------------------------
+ const activeChangeSetId = ref("");
+ const activeChangeStep = ref(0);
+ const changeActionBusy = ref(false);
+
+ // ---------------------------------------------------------------------------
+ // All CRM query docs for refetch
+ // ---------------------------------------------------------------------------
+ const allCrmQueryDocs = [
+ { query: ContactsQueryDocument },
+ { query: CommunicationsQueryDocument },
+ { query: ContactInboxesQueryDocument },
+ { query: CalendarQueryDocument },
+ { query: DealsQueryDocument },
+ { query: FeedQueryDocument },
+ { query: PinsQueryDocument },
+ { query: DocumentsQueryDocument },
+ ];
+
+ // ---------------------------------------------------------------------------
+ // Apollo Mutations
+ // ---------------------------------------------------------------------------
+ const { mutate: doConfirmLatestChangeSet } = useMutation(ConfirmLatestChangeSetMutationDocument, {
+ refetchQueries: [{ query: ChatMessagesQueryDocument }],
+ });
+ const { mutate: doRollbackLatestChangeSet } = useMutation(RollbackLatestChangeSetMutationDocument, {
+ refetchQueries: [{ query: ChatMessagesQueryDocument }, { query: ChatConversationsQueryDocument }, ...allCrmQueryDocs],
+ });
+ const { mutate: doRollbackChangeSetItems } = useMutation(RollbackChangeSetItemsMutationDocument, {
+ refetchQueries: [{ query: ChatMessagesQueryDocument }, { query: ChatConversationsQueryDocument }, ...allCrmQueryDocs],
+ });
+
+ // ---------------------------------------------------------------------------
+ // Computed
+ // ---------------------------------------------------------------------------
+ const latestChangeMessage = computed(() => {
+ return (
+ [...opts.pilotMessages.value]
+ .reverse()
+ .find((m) => m.role === "assistant" && m.changeSetId && m.changeStatus !== "rolled_back") ?? null
+ );
+ });
+
+ const activeChangeMessage = computed(() => {
+ const targetId = activeChangeSetId.value.trim();
+ if (!targetId) return latestChangeMessage.value;
+ return (
+ [...opts.pilotMessages.value]
+ .reverse()
+ .find((m) => m.role === "assistant" && m.changeSetId === targetId) ?? null
+ );
+ });
+
+ const activeChangeItems = computed(() => activeChangeMessage.value?.changeItems ?? []);
+ const activeChangeIndex = computed(() => {
+ const items = activeChangeItems.value;
+ if (!items.length) return 0;
+ return Math.max(0, Math.min(activeChangeStep.value, items.length - 1));
+ });
+ const activeChangeItem = computed(() => {
+ const items = activeChangeItems.value;
+ if (!items.length) return null;
+ return items[activeChangeIndex.value] ?? null;
+ });
+
+ const reviewActive = computed(() => Boolean(activeChangeSetId.value.trim() && activeChangeItems.value.length > 0));
+
+ const activeReviewCalendarEventId = computed(() => {
+ const item = activeChangeItem.value;
+ if (!item || item.entity !== "calendar_event" || !item.entityId) return "";
+ return item.entityId;
+ });
+ const activeReviewContactId = computed(() => {
+ const item = activeChangeItem.value;
+ if (!item || item.entity !== "contact_note" || !item.entityId) return "";
+ return item.entityId;
+ });
+ const activeReviewDealId = computed(() => {
+ const item = activeChangeItem.value;
+ if (!item || item.entity !== "deal" || !item.entityId) return "";
+ return item.entityId;
+ });
+ const activeReviewMessageId = computed(() => {
+ const item = activeChangeItem.value;
+ if (!item || item.entity !== "message" || !item.entityId) return "";
+ return item.entityId;
+ });
+ const activeReviewContactDiff = computed(() => {
+ const item = activeChangeItem.value;
+ if (!item || item.entity !== "contact_note" || !item.entityId) return null;
+ return {
+ contactId: item.entityId,
+ before: normalizeChangeText(item.before),
+ after: normalizeChangeText(item.after),
+ };
+ });
+
+ // ---------------------------------------------------------------------------
+ // Text helpers
+ // ---------------------------------------------------------------------------
+ function normalizeChangeText(raw: string | null | undefined) {
+ const text = String(raw ?? "").trim();
+ if (!text) return "";
+ try {
+ const parsed = JSON.parse(text) as Record;
+ if (typeof parsed === "object" && parsed) {
+ const candidate = [parsed.description, parsed.summary, parsed.note, parsed.text]
+ .find((value) => typeof value === "string");
+ if (typeof candidate === "string") return candidate.trim();
+ }
+ } catch {
+ // No-op: keep original text when it is not JSON payload.
+ }
+ return text;
+ }
+
+ function describeChangeEntity(entity: string) {
+ if (entity === "contact_note") return "Contact summary";
+ if (entity === "calendar_event") return "Calendar event";
+ if (entity === "message") return "Message";
+ if (entity === "deal") return "Deal";
+ if (entity === "workspace_document") return "Workspace document";
+ return entity || "Change";
+ }
+
+ function describeChangeAction(action: string) {
+ if (action === "created") return "created";
+ if (action === "updated") return "updated";
+ if (action === "deleted") return "archived";
+ return action || "changed";
+ }
+
+ // ---------------------------------------------------------------------------
+ // Review navigation
+ // ---------------------------------------------------------------------------
+ function openChangeReview(changeSetId: string, step = 0) {
+ const targetId = String(changeSetId ?? "").trim();
+ if (!targetId) return;
+ activeChangeSetId.value = targetId;
+ const items = activeChangeMessage.value?.changeItems ?? [];
+ activeChangeStep.value = items.length ? Math.max(0, Math.min(step, items.length - 1)) : 0;
+ }
+
+ function goToChangeStep(step: number) {
+ const items = activeChangeItems.value;
+ if (!items.length) return;
+ activeChangeStep.value = Math.max(0, Math.min(step, items.length - 1));
+ }
+
+ function goToPreviousChangeStep() {
+ goToChangeStep(activeChangeIndex.value - 1);
+ }
+
+ function goToNextChangeStep() {
+ goToChangeStep(activeChangeIndex.value + 1);
+ }
+
+ function finishReview() {
+ activeChangeSetId.value = "";
+ activeChangeStep.value = 0;
+ }
+
+ // ---------------------------------------------------------------------------
+ // Highlight helpers
+ // ---------------------------------------------------------------------------
+ function isReviewHighlightedEvent(eventId: string) {
+ return Boolean(reviewActive.value && activeReviewCalendarEventId.value && activeReviewCalendarEventId.value === eventId);
+ }
+
+ function isReviewHighlightedContact(contactId: string) {
+ return Boolean(reviewActive.value && activeReviewContactId.value && activeReviewContactId.value === contactId);
+ }
+
+ function isReviewHighlightedDeal(dealId: string) {
+ return Boolean(reviewActive.value && activeReviewDealId.value && activeReviewDealId.value === dealId);
+ }
+
+ function isReviewHighlightedMessage(messageId: string) {
+ return Boolean(reviewActive.value && activeReviewMessageId.value && activeReviewMessageId.value === messageId);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Change execution
+ // ---------------------------------------------------------------------------
+ async function confirmLatestChangeSet() {
+ if (changeActionBusy.value || !latestChangeMessage.value?.changeSetId) return;
+ changeActionBusy.value = true;
+ try {
+ await doConfirmLatestChangeSet();
+ } finally {
+ changeActionBusy.value = false;
+ }
+ }
+
+ async function rollbackLatestChangeSet() {
+ if (changeActionBusy.value || !latestChangeMessage.value?.changeSetId) return;
+ changeActionBusy.value = true;
+ try {
+ await doRollbackLatestChangeSet();
+ activeChangeSetId.value = "";
+ activeChangeStep.value = 0;
+ } finally {
+ changeActionBusy.value = false;
+ }
+ }
+
+ async function rollbackSelectedChangeItems() {
+ const targetChangeSetId = activeChangeMessage.value?.changeSetId?.trim() || activeChangeSetId.value.trim();
+ const itemIds = activeChangeItems.value.filter((item) => !item.rolledBack).map((item) => item.id);
+ if (changeActionBusy.value || !targetChangeSetId || itemIds.length === 0) return;
+
+ changeActionBusy.value = true;
+ try {
+ await doRollbackChangeSetItems({ changeSetId: targetChangeSetId, itemIds });
+ } finally {
+ changeActionBusy.value = false;
+ }
+ }
+
+ async function rollbackChangeItemById(itemId: string) {
+ const item = activeChangeItems.value.find((entry) => entry.id === itemId);
+ const targetChangeSetId = activeChangeMessage.value?.changeSetId?.trim() || activeChangeSetId.value.trim();
+ if (!item || item.rolledBack || !targetChangeSetId || changeActionBusy.value) return;
+
+ changeActionBusy.value = true;
+ try {
+ await doRollbackChangeSetItems({ changeSetId: targetChangeSetId, itemIds: [itemId] });
+ } finally {
+ changeActionBusy.value = false;
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Watcher: clamp step when change items list changes
+ // ---------------------------------------------------------------------------
+ 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;
+ },
+ );
+
+ // ---------------------------------------------------------------------------
+ // Public API
+ // ---------------------------------------------------------------------------
+ return {
+ activeChangeSetId,
+ activeChangeStep,
+ changeActionBusy,
+ reviewActive,
+ activeChangeItems,
+ activeChangeItem,
+ activeChangeIndex,
+ openChangeReview,
+ goToChangeStep,
+ goToPreviousChangeStep,
+ goToNextChangeStep,
+ finishReview,
+ isReviewHighlightedEvent,
+ isReviewHighlightedContact,
+ isReviewHighlightedDeal,
+ isReviewHighlightedMessage,
+ activeReviewCalendarEventId,
+ activeReviewContactId,
+ activeReviewDealId,
+ activeReviewMessageId,
+ activeReviewContactDiff,
+ confirmLatestChangeSet,
+ rollbackLatestChangeSet,
+ rollbackSelectedChangeItems,
+ rollbackChangeItemById,
+ describeChangeEntity,
+ describeChangeAction,
+ normalizeChangeText,
+ };
+}
diff --git a/frontend/app/composables/useContactInboxes.ts b/frontend/app/composables/useContactInboxes.ts
new file mode 100644
index 0000000..d3782d2
--- /dev/null
+++ b/frontend/app/composables/useContactInboxes.ts
@@ -0,0 +1,85 @@
+import { ref, watch, type ComputedRef } from "vue";
+import { useQuery, useMutation } from "@vue/apollo-composable";
+import {
+ ContactInboxesQueryDocument,
+ SetContactInboxHiddenDocument,
+} from "~~/graphql/generated";
+import type { ContactInbox } from "~/composables/crm-types";
+
+export function useContactInboxes(opts: { apolloAuthReady: ComputedRef }) {
+ const { result: contactInboxesResult, refetch: refetchContactInboxes } = useQuery(
+ ContactInboxesQueryDocument,
+ null,
+ { enabled: opts.apolloAuthReady },
+ );
+
+ const { mutate: doSetContactInboxHidden } = useMutation(SetContactInboxHiddenDocument, {
+ refetchQueries: [{ query: ContactInboxesQueryDocument }],
+ update: (cache, _result, { variables }) => {
+ if (!variables) return;
+ const existing = cache.readQuery({ query: ContactInboxesQueryDocument }) as { contactInboxes?: ContactInbox[] } | null;
+ if (!existing?.contactInboxes) return;
+ cache.writeQuery({
+ query: ContactInboxesQueryDocument,
+ data: {
+ contactInboxes: existing.contactInboxes.map((inbox) =>
+ inbox.id === variables.inboxId ? { ...inbox, isHidden: variables.hidden } : inbox,
+ ),
+ },
+ });
+ },
+ });
+
+ const contactInboxes = ref([]);
+ const inboxToggleLoadingById = ref>({});
+
+ watch(() => contactInboxesResult.value?.contactInboxes, (v) => {
+ if (v) contactInboxes.value = v as ContactInbox[];
+ }, { immediate: true });
+
+ function isInboxToggleLoading(inboxId: string) {
+ return Boolean(inboxToggleLoadingById.value[inboxId]);
+ }
+
+ async function setInboxHidden(inboxId: string, hidden: boolean) {
+ const id = String(inboxId ?? "").trim();
+ if (!id || isInboxToggleLoading(id)) return;
+ inboxToggleLoadingById.value = { ...inboxToggleLoadingById.value, [id]: true };
+ try {
+ await doSetContactInboxHidden({ inboxId: id, hidden });
+ } catch (e: unknown) {
+ console.error("[setInboxHidden] mutation failed:", e);
+ } finally {
+ inboxToggleLoadingById.value = { ...inboxToggleLoadingById.value, [id]: false };
+ }
+ }
+
+ function threadInboxes(thread: { id: string }) {
+ return contactInboxes.value
+ .filter((inbox) => inbox.contactId === thread.id)
+ .sort((a, b) => {
+ const aTime = a.lastMessageAt || a.updatedAt;
+ const bTime = b.lastMessageAt || b.updatedAt;
+ return bTime.localeCompare(aTime);
+ });
+ }
+
+ function formatInboxLabel(inbox: ContactInbox) {
+ const title = String(inbox.title ?? "").trim();
+ if (title) return `${inbox.channel} · ${title}`;
+ const source = String(inbox.sourceExternalId ?? "").trim();
+ if (!source) return inbox.channel;
+ const tail = source.length > 18 ? source.slice(-18) : source;
+ return `${inbox.channel} · ${tail}`;
+ }
+
+ return {
+ contactInboxes,
+ inboxToggleLoadingById,
+ setInboxHidden,
+ isInboxToggleLoading,
+ threadInboxes,
+ formatInboxLabel,
+ refetchContactInboxes,
+ };
+}
diff --git a/frontend/app/composables/useContacts.ts b/frontend/app/composables/useContacts.ts
new file mode 100644
index 0000000..0222020
--- /dev/null
+++ b/frontend/app/composables/useContacts.ts
@@ -0,0 +1,158 @@
+import { ref, computed, watch, watchEffect, type ComputedRef } from "vue";
+import { useQuery } from "@vue/apollo-composable";
+import {
+ ContactsQueryDocument,
+ CommunicationsQueryDocument,
+} from "~~/graphql/generated";
+import type { Contact, CommItem, SortMode } from "~/composables/crm-types";
+
+export function useContacts(opts: { apolloAuthReady: ComputedRef }) {
+ const { result: contactsResult, refetch: refetchContacts } = useQuery(
+ ContactsQueryDocument,
+ null,
+ { enabled: opts.apolloAuthReady },
+ );
+
+ const { result: communicationsResult, refetch: refetchCommunications } = useQuery(
+ CommunicationsQueryDocument,
+ null,
+ { enabled: opts.apolloAuthReady },
+ );
+
+ const contacts = ref([]);
+ const commItems = ref([]);
+
+ watch(
+ [() => contactsResult.value?.contacts, () => communicationsResult.value?.communications],
+ ([rawContacts, rawComms]) => {
+ if (!rawContacts) return;
+ const contactsList = [...rawContacts] as Contact[];
+ const commsList = (rawComms ?? []) as CommItem[];
+
+ const byName = new Map>();
+ for (const item of commsList) {
+ if (!byName.has(item.contact)) byName.set(item.contact, new Set());
+ byName.get(item.contact)?.add(item.channel);
+ }
+ contacts.value = contactsList.map((c) => ({
+ ...c,
+ channels: Array.from(byName.get(c.name) ?? c.channels ?? []),
+ }));
+ commItems.value = commsList;
+ },
+ { immediate: true },
+ );
+
+ const contactSearch = ref("");
+ const selectedChannel = ref("All");
+ const sortMode = ref("name");
+
+ const channels = computed(() => ["All", ...new Set(contacts.value.flatMap((c) => c.channels))].sort());
+
+ function resetContactFilters() {
+ contactSearch.value = "";
+ selectedChannel.value = "All";
+ sortMode.value = "name";
+ }
+
+ const filteredContacts = computed(() => {
+ const query = contactSearch.value.trim().toLowerCase();
+ const data = contacts.value.filter((contact) => {
+ if (selectedChannel.value !== "All" && !contact.channels.includes(selectedChannel.value)) return false;
+ if (query) {
+ const haystack = [contact.name, contact.description, contact.channels.join(" ")]
+ .join(" ")
+ .toLowerCase();
+ if (!haystack.includes(query)) return false;
+ }
+ return true;
+ });
+
+ return data.sort((a, b) => {
+ if (sortMode.value === "lastContact") {
+ return b.lastContactAt.localeCompare(a.lastContactAt);
+ }
+ return a.name.localeCompare(b.name);
+ });
+ });
+
+ const groupedContacts = computed(() => {
+ if (sortMode.value === "lastContact") {
+ return [["Recent", filteredContacts.value]] as [string, Contact[]][];
+ }
+
+ const map = new Map();
+
+ for (const contact of filteredContacts.value) {
+ const key = (contact.name[0] ?? "#").toUpperCase();
+ if (!map.has(key)) {
+ map.set(key, []);
+ }
+ map.get(key)?.push(contact);
+ }
+
+ return [...map.entries()].sort((a, b) => a[0].localeCompare(b[0]));
+ });
+
+ const selectedContactId = ref(contacts.value[0]?.id ?? "");
+
+ watchEffect(() => {
+ if (!filteredContacts.value.length) {
+ selectedContactId.value = "";
+ return;
+ }
+ if (!filteredContacts.value.some((item) => item.id === selectedContactId.value)) {
+ const first = filteredContacts.value[0];
+ if (first) selectedContactId.value = first.id;
+ }
+ });
+
+ const selectedContact = computed(() => contacts.value.find((item) => item.id === selectedContactId.value));
+
+ const brokenAvatarByContactId = ref>({});
+
+ function contactInitials(name: string) {
+ const words = String(name ?? "")
+ .trim()
+ .split(/\s+/)
+ .filter(Boolean);
+ if (!words.length) return "?";
+ return words
+ .slice(0, 2)
+ .map((part) => part[0]?.toUpperCase() ?? "")
+ .join("");
+ }
+
+ function avatarSrcForThread(thread: { id: string; avatar: string }) {
+ if (brokenAvatarByContactId.value[thread.id]) return "";
+ return String(thread.avatar ?? "").trim();
+ }
+
+ function markAvatarBroken(contactId: string) {
+ if (!contactId) return;
+ brokenAvatarByContactId.value = {
+ ...brokenAvatarByContactId.value,
+ [contactId]: true,
+ };
+ }
+
+ return {
+ contacts,
+ commItems,
+ contactSearch,
+ selectedChannel,
+ sortMode,
+ selectedContactId,
+ selectedContact,
+ filteredContacts,
+ groupedContacts,
+ channels,
+ resetContactFilters,
+ brokenAvatarByContactId,
+ avatarSrcForThread,
+ markAvatarBroken,
+ contactInitials,
+ refetchContacts,
+ refetchCommunications,
+ };
+}
diff --git a/frontend/app/composables/useCrmRealtime.ts b/frontend/app/composables/useCrmRealtime.ts
new file mode 100644
index 0000000..cedbe97
--- /dev/null
+++ b/frontend/app/composables/useCrmRealtime.ts
@@ -0,0 +1,130 @@
+import { ref } from "vue";
+
+export function useCrmRealtime(opts: {
+ isAuthenticated: () => boolean;
+ onDashboardChanged: () => Promise;
+}) {
+ const crmRealtimeState = ref<"idle" | "connecting" | "open" | "error">("idle");
+ let crmRealtimeSocket: WebSocket | null = null;
+ let crmRealtimeReconnectTimer: ReturnType | null = null;
+ let crmRealtimeRefreshTimer: ReturnType | null = null;
+ let crmRealtimeRefreshInFlight = false;
+ let crmRealtimeReconnectAttempt = 0;
+
+ function clearCrmRealtimeReconnectTimer() {
+ if (!crmRealtimeReconnectTimer) return;
+ clearTimeout(crmRealtimeReconnectTimer);
+ crmRealtimeReconnectTimer = null;
+ }
+
+ function clearCrmRealtimeRefreshTimer() {
+ if (!crmRealtimeRefreshTimer) return;
+ clearTimeout(crmRealtimeRefreshTimer);
+ crmRealtimeRefreshTimer = null;
+ }
+
+ async function runCrmRealtimeRefresh() {
+ if (!opts.isAuthenticated() || crmRealtimeRefreshInFlight) return;
+ crmRealtimeRefreshInFlight = true;
+ try {
+ await opts.onDashboardChanged();
+ } catch {
+ // ignore transient realtime refresh errors
+ } finally {
+ crmRealtimeRefreshInFlight = false;
+ }
+ }
+
+ function scheduleCrmRealtimeRefresh(delayMs = 250) {
+ clearCrmRealtimeRefreshTimer();
+ crmRealtimeRefreshTimer = setTimeout(() => {
+ crmRealtimeRefreshTimer = null;
+ void runCrmRealtimeRefresh();
+ }, delayMs);
+ }
+
+ function scheduleCrmRealtimeReconnect() {
+ clearCrmRealtimeReconnectTimer();
+ const attempt = Math.min(crmRealtimeReconnectAttempt + 1, 8);
+ crmRealtimeReconnectAttempt = attempt;
+ const delayMs = Math.min(1000 * 2 ** (attempt - 1), 15000);
+ crmRealtimeReconnectTimer = setTimeout(() => {
+ crmRealtimeReconnectTimer = null;
+ startCrmRealtime();
+ }, delayMs);
+ }
+
+ function stopCrmRealtime() {
+ clearCrmRealtimeReconnectTimer();
+ clearCrmRealtimeRefreshTimer();
+
+ if (crmRealtimeSocket) {
+ const socket = crmRealtimeSocket;
+ crmRealtimeSocket = null;
+ socket.onopen = null;
+ socket.onmessage = null;
+ socket.onerror = null;
+ socket.onclose = null;
+ try {
+ socket.close(1000, "client stop");
+ } catch {
+ // ignore socket close errors
+ }
+ }
+
+ crmRealtimeState.value = "idle";
+ }
+
+ function startCrmRealtime() {
+ if (process.server || !opts.isAuthenticated()) return;
+ if (crmRealtimeSocket) {
+ const state = crmRealtimeSocket.readyState;
+ if (state === WebSocket.OPEN || state === WebSocket.CONNECTING) return;
+ }
+
+ clearCrmRealtimeReconnectTimer();
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
+ const url = `${protocol}//${window.location.host}/ws/crm-updates`;
+
+ const socket = new WebSocket(url);
+ crmRealtimeSocket = socket;
+ crmRealtimeState.value = "connecting";
+
+ socket.onopen = () => {
+ crmRealtimeState.value = "open";
+ crmRealtimeReconnectAttempt = 0;
+ };
+
+ socket.onmessage = (event) => {
+ const raw = typeof event.data === "string" ? event.data : "";
+ if (!raw) return;
+ try {
+ const payload = JSON.parse(raw) as { type?: string };
+ if (payload.type === "dashboard.changed") {
+ scheduleCrmRealtimeRefresh();
+ }
+ } catch {
+ // ignore malformed realtime payloads
+ }
+ };
+
+ socket.onerror = () => {
+ crmRealtimeState.value = "error";
+ };
+
+ socket.onclose = () => {
+ const wasActive = crmRealtimeSocket === socket;
+ if (wasActive) {
+ crmRealtimeSocket = null;
+ }
+ if (!opts.isAuthenticated()) {
+ crmRealtimeState.value = "idle";
+ return;
+ }
+ crmRealtimeState.value = "error";
+ scheduleCrmRealtimeReconnect();
+ };
+ }
+
+ return { crmRealtimeState, startCrmRealtime, stopCrmRealtime };
+}
diff --git a/frontend/app/composables/useDeals.ts b/frontend/app/composables/useDeals.ts
new file mode 100644
index 0000000..fe49dd4
--- /dev/null
+++ b/frontend/app/composables/useDeals.ts
@@ -0,0 +1,200 @@
+import { ref, computed, watch, type ComputedRef, type Ref } from "vue";
+import { useQuery } from "@vue/apollo-composable";
+import { DealsQueryDocument } from "~~/graphql/generated";
+import type { Deal, DealStep, CalendarEvent, Contact } from "~/composables/crm-types";
+import { safeTrim, formatDay } from "~/composables/crm-types";
+
+export function useDeals(opts: {
+ apolloAuthReady: ComputedRef;
+ contacts: Ref;
+ calendarEvents: Ref;
+}) {
+ const { result: dealsResult, refetch: refetchDeals } = useQuery(
+ DealsQueryDocument,
+ null,
+ { enabled: opts.apolloAuthReady },
+ );
+
+ const deals = ref([]);
+ const selectedDealId = ref(deals.value[0]?.id ?? "");
+ const selectedDealStepsExpanded = ref(false);
+
+ watch(() => dealsResult.value?.deals, (v) => {
+ if (v) deals.value = v as Deal[];
+ }, { immediate: true });
+
+ const sortedEvents = computed(() => [...opts.calendarEvents.value].sort((a, b) => a.start.localeCompare(b.start)));
+
+ const selectedWorkspaceDeal = computed(() => {
+ const explicit = deals.value.find((deal) => deal.id === selectedDealId.value);
+ if (explicit) return explicit;
+
+ const contactName = opts.contacts.value[0]?.name;
+ if (contactName) {
+ const linked = deals.value.find((deal) => deal.contact === contactName);
+ if (linked) return linked;
+ }
+ return null;
+ });
+
+ function formatDealHeadline(deal: Deal) {
+ const title = safeTrim(deal.title);
+ const amountRaw = safeTrim(deal.amount);
+ if (!amountRaw) return title;
+
+ const normalized = amountRaw.replace(/\s+/g, "").replace(",", ".");
+ if (/^\d+(\.\d+)?$/.test(normalized)) {
+ return `${title} за ${new Intl.NumberFormat("ru-RU").format(Number(normalized))} $`;
+ }
+
+ return `${title} за ${amountRaw}`;
+ }
+
+ function getDealCurrentStep(deal: Deal) {
+ if (!deal.steps?.length) return null;
+ if (deal.currentStepId) {
+ const explicit = deal.steps.find((step) => step.id === deal.currentStepId);
+ if (explicit) return explicit;
+ }
+ const inProgress = deal.steps.find((step) => step.status === "in_progress");
+ if (inProgress) return inProgress;
+ const nextTodo = deal.steps.find((step) => step.status !== "done");
+ return nextTodo ?? deal.steps[deal.steps.length - 1];
+ }
+
+ function getDealCurrentStepLabel(deal: Deal) {
+ return safeTrim(getDealCurrentStep(deal)?.title) || safeTrim(deal.nextStep) || safeTrim(deal.stage) || "Без шага";
+ }
+
+ function parseDateFromText(input: string) {
+ const text = input.trim();
+ if (!text) return null;
+
+ const isoMatch = text.match(/\b(\d{4})-(\d{2})-(\d{2})\b/);
+ if (isoMatch) {
+ const [, y, m, d] = isoMatch;
+ const parsed = new Date(Number(y), Number(m) - 1, Number(d));
+ if (!Number.isNaN(parsed.getTime())) return parsed;
+ }
+
+ const ruMatch = text.match(/\b(\d{1,2})[./](\d{1,2})[./](\d{4})\b/);
+ if (ruMatch) {
+ const [, d, m, y] = ruMatch;
+ const parsed = new Date(Number(y), Number(m) - 1, Number(d));
+ if (!Number.isNaN(parsed.getTime())) return parsed;
+ }
+
+ return null;
+ }
+
+ function pluralizeRuDays(days: number) {
+ const mod10 = days % 10;
+ const mod100 = days % 100;
+ if (mod10 === 1 && mod100 !== 11) return "день";
+ if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return "дня";
+ return "дней";
+ }
+
+ function formatDealDeadline(dueDate: Date) {
+ const today = new Date();
+ const startOfToday = new Date(today.getFullYear(), today.getMonth(), today.getDate());
+ const startOfDue = new Date(dueDate.getFullYear(), dueDate.getMonth(), dueDate.getDate());
+ const dayDiff = Math.round((startOfDue.getTime() - startOfToday.getTime()) / 86_400_000);
+
+ if (dayDiff < 0) {
+ const overdue = Math.abs(dayDiff);
+ return `просрочено на ${overdue} ${pluralizeRuDays(overdue)}`;
+ }
+ if (dayDiff === 0) return "сегодня";
+ if (dayDiff === 1) return "завтра";
+ return `через ${dayDiff} ${pluralizeRuDays(dayDiff)}`;
+ }
+
+ function isDealStepDone(step: DealStep) {
+ return step.status === "done";
+ }
+
+ function formatDealStepMeta(step: DealStep) {
+ if (step.status === "done") return "выполнено";
+ if (step.status === "blocked") return "заблокировано";
+ if (!step.dueAt) {
+ if (step.status === "in_progress") return "в работе";
+ return "без дедлайна";
+ }
+ const parsed = new Date(step.dueAt);
+ if (Number.isNaN(parsed.getTime())) return "без дедлайна";
+ return formatDealDeadline(parsed);
+ }
+
+ function getDealCurrentStepMeta(deal: Deal) {
+ const step = getDealCurrentStep(deal);
+ if (!step) return "";
+ return formatDealStepMeta(step);
+ }
+
+ const selectedWorkspaceDealDueDate = computed(() => {
+ const deal = selectedWorkspaceDeal.value;
+ if (!deal) return null;
+
+ const currentStep = getDealCurrentStep(deal);
+ if (currentStep?.dueAt) {
+ const parsed = new Date(currentStep.dueAt);
+ if (!Number.isNaN(parsed.getTime())) return parsed;
+ }
+
+ const fromNextStep = parseDateFromText(currentStep?.title || deal.nextStep);
+ if (fromNextStep) return fromNextStep;
+
+ const now = Date.now();
+ const contactEvents = sortedEvents.value
+ .filter((event) => event.contact === deal.contact)
+ .map((event) => new Date(event.start))
+ .filter((date) => !Number.isNaN(date.getTime()))
+ .sort((a, b) => a.getTime() - b.getTime());
+
+ const nextUpcoming = contactEvents.find((date) => date.getTime() >= now);
+ if (nextUpcoming) return nextUpcoming;
+
+ return contactEvents.length ? contactEvents[contactEvents.length - 1] : null;
+ });
+
+ const selectedWorkspaceDealSubtitle = computed(() => {
+ const deal = selectedWorkspaceDeal.value;
+ if (!deal) return "";
+ const stepLabel = getDealCurrentStepLabel(deal);
+ const dueDate = selectedWorkspaceDealDueDate.value;
+ if (!dueDate) return `${stepLabel} · без дедлайна`;
+ return `${stepLabel} · ${formatDealDeadline(dueDate)}`;
+ });
+
+ const selectedWorkspaceDealSteps = computed(() => {
+ const deal = selectedWorkspaceDeal.value;
+ if (!deal?.steps?.length) return [];
+ return [...deal.steps].sort((a, b) => a.order - b.order);
+ });
+
+ watch(
+ () => selectedWorkspaceDeal.value?.id ?? "",
+ () => {
+ selectedDealStepsExpanded.value = false;
+ },
+ );
+
+ return {
+ deals,
+ selectedDealId,
+ selectedDealStepsExpanded,
+ selectedWorkspaceDeal,
+ selectedWorkspaceDealDueDate,
+ selectedWorkspaceDealSubtitle,
+ selectedWorkspaceDealSteps,
+ formatDealHeadline,
+ getDealCurrentStep,
+ getDealCurrentStepLabel,
+ getDealCurrentStepMeta,
+ formatDealDeadline,
+ isDealStepDone,
+ formatDealStepMeta,
+ refetchDeals,
+ };
+}
diff --git a/frontend/app/composables/useDocuments.ts b/frontend/app/composables/useDocuments.ts
new file mode 100644
index 0000000..fa74ef2
--- /dev/null
+++ b/frontend/app/composables/useDocuments.ts
@@ -0,0 +1,189 @@
+import { ref, computed, watch, watchEffect, type ComputedRef } from "vue";
+import { useQuery, useMutation } from "@vue/apollo-composable";
+import {
+ DocumentsQueryDocument,
+ CreateWorkspaceDocumentDocument,
+ DeleteWorkspaceDocumentDocument,
+} from "~~/graphql/generated";
+import type { WorkspaceDocument, DocumentSortMode, ClientTimelineItem } from "~/composables/crm-types";
+import { safeTrim } from "~/composables/crm-types";
+import { formatDocumentScope } from "~/composables/useWorkspaceDocuments";
+
+export function useDocuments(opts: { apolloAuthReady: ComputedRef }) {
+ const { result: documentsResult, refetch: refetchDocuments } = useQuery(
+ DocumentsQueryDocument,
+ null,
+ { enabled: opts.apolloAuthReady },
+ );
+
+ const { mutate: doCreateWorkspaceDocument } = useMutation(CreateWorkspaceDocumentDocument, {
+ refetchQueries: [{ query: DocumentsQueryDocument }],
+ });
+ const { mutate: doDeleteWorkspaceDocument } = useMutation(DeleteWorkspaceDocumentDocument, {
+ refetchQueries: [{ query: DocumentsQueryDocument }],
+ });
+
+ const documents = ref([]);
+ const documentSearch = ref("");
+ const documentSortMode = ref("updatedAt");
+ const selectedDocumentId = ref(documents.value[0]?.id ?? "");
+ const documentDeletingId = ref("");
+
+ const documentSortOptions: Array<{ value: DocumentSortMode; label: string }> = [
+ { value: "updatedAt", label: "Updated" },
+ { value: "title", label: "Title" },
+ { value: "owner", label: "Owner" },
+ ];
+
+ watch(() => documentsResult.value?.documents, (v) => {
+ if (v) documents.value = v as WorkspaceDocument[];
+ }, { immediate: true });
+
+ const filteredDocuments = computed(() => {
+ const query = documentSearch.value.trim().toLowerCase();
+
+ const list = documents.value
+ .filter((item) => {
+ if (!query) return true;
+ const haystack = [item.title, item.summary, item.owner, formatDocumentScope(item.scope), item.body].join(" ").toLowerCase();
+ return haystack.includes(query);
+ })
+ .sort((a, b) => {
+ if (documentSortMode.value === "title") return a.title.localeCompare(b.title);
+ if (documentSortMode.value === "owner") return a.owner.localeCompare(b.owner);
+ return b.updatedAt.localeCompare(a.updatedAt);
+ });
+
+ return list;
+ });
+
+ watchEffect(() => {
+ if (!filteredDocuments.value.length) {
+ selectedDocumentId.value = "";
+ return;
+ }
+
+ if (!filteredDocuments.value.some((item) => item.id === selectedDocumentId.value)) {
+ const first = filteredDocuments.value[0];
+ if (first) selectedDocumentId.value = first.id;
+ }
+ });
+
+ const selectedDocument = computed(() => documents.value.find((item) => item.id === selectedDocumentId.value));
+
+ function updateSelectedDocumentBody(value: string) {
+ if (!selectedDocument.value) return;
+ selectedDocument.value.body = value;
+ }
+
+ function openDocumentsTab(opts2: { setTab: (tab: string) => void; syncPath: (push: boolean) => void }, push = false) {
+ opts2.setTab("documents");
+ if (!selectedDocumentId.value && filteredDocuments.value.length) {
+ const first = filteredDocuments.value[0];
+ if (first) selectedDocumentId.value = first.id;
+ }
+ opts2.syncPath(push);
+ }
+
+ async function deleteWorkspaceDocumentById(
+ documentIdInput: string,
+ clientTimelineItems: { value: ClientTimelineItem[] },
+ ) {
+ const documentId = safeTrim(documentIdInput);
+ if (!documentId) return;
+ if (documentDeletingId.value === documentId) return;
+
+ const target = documents.value.find((doc) => doc.id === documentId);
+ const targetLabel = safeTrim(target?.title) || "this document";
+ if (process.client && !window.confirm(`Delete ${targetLabel}?`)) return;
+
+ documentDeletingId.value = documentId;
+ try {
+ await doDeleteWorkspaceDocument({ id: documentId });
+ documents.value = documents.value.filter((doc) => doc.id !== documentId);
+ clientTimelineItems.value = clientTimelineItems.value.filter((item) => {
+ const isDocumentEntry = String(item.contentType).toLowerCase() === "document";
+ if (!isDocumentEntry) return true;
+ return item.contentId !== documentId && item.document?.id !== documentId;
+ });
+ if (selectedDocumentId.value === documentId) {
+ selectedDocumentId.value = "";
+ }
+ } finally {
+ if (documentDeletingId.value === documentId) {
+ documentDeletingId.value = "";
+ }
+ }
+ }
+
+ async function createCommDocument(
+ threadContact: { id: string; contact: string } | undefined,
+ draftText: string,
+ commDocumentForm: { value: { title: string } },
+ authDisplayName: string,
+ additionalCallbacks: {
+ buildScope: (contactId: string, contactName: string) => string;
+ onSuccess: (created: WorkspaceDocument | null) => void;
+ },
+ ) {
+ if (!threadContact) return false;
+
+ const summary = draftText.trim();
+ if (!summary) return false;
+
+ const title = safeTrim(commDocumentForm.value.title)
+ || buildCommDocumentTitle(summary, threadContact.contact);
+ const scope = additionalCallbacks.buildScope(threadContact.id, threadContact.contact);
+ const body = summary;
+
+ try {
+ const res = await doCreateWorkspaceDocument({
+ input: {
+ title,
+ owner: authDisplayName,
+ scope,
+ summary,
+ body,
+ },
+ });
+
+ const created = res?.data?.createWorkspaceDocument;
+ if (created) {
+ documents.value = [created as WorkspaceDocument, ...documents.value.filter((doc) => doc.id !== created.id)];
+ selectedDocumentId.value = created.id;
+ } else {
+ selectedDocumentId.value = "";
+ }
+ additionalCallbacks.onSuccess((created as WorkspaceDocument) ?? null);
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ function buildCommDocumentTitle(text: string, contact: string) {
+ const cleaned = text.replace(/\s+/g, " ").trim();
+ if (cleaned) {
+ const sentence = cleaned.split(/[.!?\n]/)[0]?.trim() ?? "";
+ if (sentence) return sentence.slice(0, 120);
+ }
+ return `Документ для ${contact}`;
+ }
+
+ return {
+ documents,
+ documentSearch,
+ documentSortMode,
+ selectedDocumentId,
+ documentDeletingId,
+ documentSortOptions,
+ selectedDocument,
+ filteredDocuments,
+ updateSelectedDocumentBody,
+ createCommDocument,
+ buildCommDocumentTitle,
+ deleteWorkspaceDocumentById,
+ openDocumentsTab,
+ refetchDocuments,
+ };
+}
diff --git a/frontend/app/composables/useFeed.ts b/frontend/app/composables/useFeed.ts
new file mode 100644
index 0000000..0a13c90
--- /dev/null
+++ b/frontend/app/composables/useFeed.ts
@@ -0,0 +1,157 @@
+import { ref, watch, type ComputedRef } from "vue";
+import { useQuery, useMutation } from "@vue/apollo-composable";
+import {
+ FeedQueryDocument,
+ UpdateFeedDecisionMutationDocument,
+ CreateCalendarEventMutationDocument,
+ CreateCommunicationMutationDocument,
+ LogPilotNoteMutationDocument,
+ CalendarQueryDocument,
+ CommunicationsQueryDocument,
+ ContactInboxesQueryDocument,
+ ChatMessagesQueryDocument,
+ ChatConversationsQueryDocument,
+} from "~~/graphql/generated";
+import type { FeedCard, CalendarEvent } from "~/composables/crm-types";
+import { dayKey, formatDay, formatTime } from "~/composables/crm-types";
+
+export function useFeed(opts: {
+ apolloAuthReady: ComputedRef;
+ onCreateFollowup: (card: FeedCard, event: CalendarEvent) => void;
+ onOpenComm: (card: FeedCard) => void;
+}) {
+ const { result: feedResult, refetch: refetchFeed } = useQuery(
+ FeedQueryDocument,
+ null,
+ { enabled: opts.apolloAuthReady },
+ );
+
+ const { mutate: doUpdateFeedDecision } = useMutation(UpdateFeedDecisionMutationDocument, {
+ refetchQueries: [{ query: FeedQueryDocument }],
+ });
+ const { mutate: doCreateCalendarEvent } = useMutation(CreateCalendarEventMutationDocument, {
+ refetchQueries: [{ query: CalendarQueryDocument }],
+ });
+ const { mutate: doCreateCommunication } = useMutation(CreateCommunicationMutationDocument, {
+ refetchQueries: [{ query: CommunicationsQueryDocument }, { query: ContactInboxesQueryDocument }],
+ });
+ const { mutate: doLogPilotNote } = useMutation(LogPilotNoteMutationDocument);
+
+ const feedCards = ref([]);
+
+ watch(() => feedResult.value?.feed, (v) => {
+ if (v) feedCards.value = v as FeedCard[];
+ }, { immediate: true });
+
+ function pushPilotNote(text: string) {
+ doLogPilotNote({ text })
+ .then(() => {})
+ .catch(() => {});
+ }
+
+ async function executeFeedAction(card: FeedCard) {
+ const key = card.proposal.key;
+ if (key === "create_followup") {
+ const start = new Date();
+ start.setMinutes(start.getMinutes() + 30);
+ start.setSeconds(0, 0);
+ const end = new Date(start);
+ end.setMinutes(end.getMinutes() + 30);
+
+ const res = await doCreateCalendarEvent({
+ input: {
+ title: `Follow-up: ${card.contact.split(" ")[0] ?? "Contact"}`,
+ start: start.toISOString(),
+ end: end.toISOString(),
+ contact: card.contact,
+ note: "Created from feed action.",
+ },
+ });
+ const created = res?.data?.createCalendarEvent as CalendarEvent | undefined;
+ if (created) {
+ opts.onCreateFollowup(card, created);
+ }
+
+ return `Event created: Follow-up · ${formatDay(start.toISOString())} ${formatTime(start.toISOString())} · ${card.contact}`;
+ }
+
+ if (key === "open_comm") {
+ opts.onOpenComm(card);
+ return `Opened ${card.contact} communication thread.`;
+ }
+
+ if (key === "call") {
+ await doCreateCommunication({
+ input: {
+ contact: card.contact,
+ channel: "Phone",
+ kind: "call",
+ direction: "out",
+ text: "Call started from feed",
+ durationSec: 0,
+ },
+ });
+ opts.onOpenComm(card);
+ return `Call event created and ${card.contact} chat opened.`;
+ }
+
+ if (key === "draft_message") {
+ await doCreateCommunication({
+ input: {
+ contact: card.contact,
+ channel: "Email",
+ kind: "message",
+ direction: "out",
+ text: "Draft: onboarding plan + two slots for tomorrow.",
+ },
+ });
+ opts.onOpenComm(card);
+ return `Draft message added to ${card.contact} communications.`;
+ }
+
+ if (key === "run_summary") {
+ return "Call summary prepared: 5 next steps sent to Pilot.";
+ }
+
+ if (key === "prepare_question") {
+ await doCreateCommunication({
+ input: {
+ contact: card.contact,
+ channel: "Telegram",
+ kind: "message",
+ direction: "out",
+ text: "Draft: can you confirm your decision date for this cycle?",
+ },
+ });
+ opts.onOpenComm(card);
+ return `Question about decision date added to ${card.contact} chat.`;
+ }
+
+ return "Action completed.";
+ }
+
+ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected") {
+ card.decision = decision;
+
+ if (decision === "rejected") {
+ const note = "Rejected. Nothing created.";
+ card.decisionNote = note;
+ await doUpdateFeedDecision({ id: card.id, decision: "rejected", decisionNote: note });
+ pushPilotNote(`[${card.contact}] recommendation rejected: ${card.proposal.title}`);
+ return;
+ }
+
+ const result = await executeFeedAction(card);
+ card.decisionNote = result;
+ await doUpdateFeedDecision({ id: card.id, decision: "accepted", decisionNote: result });
+ pushPilotNote(`[${card.contact}] ${result}`);
+ }
+
+ return {
+ feedCards,
+ decideFeedCard,
+ executeFeedAction,
+ pushPilotNote,
+ refetchFeed,
+ };
+}
diff --git a/frontend/app/composables/usePilotChat.ts b/frontend/app/composables/usePilotChat.ts
new file mode 100644
index 0000000..06f9d93
--- /dev/null
+++ b/frontend/app/composables/usePilotChat.ts
@@ -0,0 +1,626 @@
+import { ref, computed, watch, watchEffect, nextTick, type Ref, type ComputedRef } from "vue";
+import { useQuery, useMutation } from "@vue/apollo-composable";
+import {
+ ChatMessagesQueryDocument,
+ ChatConversationsQueryDocument,
+ CreateChatConversationMutationDocument,
+ SelectChatConversationMutationDocument,
+ ArchiveChatConversationMutationDocument,
+ LogPilotNoteMutationDocument,
+ MeQueryDocument,
+} from "~~/graphql/generated";
+import { Chat as AiChat } from "@ai-sdk/vue";
+import { DefaultChatTransport, isTextUIPart, type UIMessage } from "ai";
+import { isVoiceCaptureSupported, transcribeAudioBlob } from "~/composables/useVoiceTranscription";
+import type {
+ PilotMessage,
+ ChatConversation,
+ ContextScope,
+ PilotContextPayload,
+ CalendarView,
+ Contact,
+ CalendarEvent,
+ Deal,
+} from "~/composables/crm-types";
+import { safeTrim } from "~/composables/crm-types";
+
+export function usePilotChat(opts: {
+ apolloAuthReady: ComputedRef;
+ authMe: Ref;
+ selectedContact: ComputedRef;
+ selectedDeal: ComputedRef;
+ calendarView: Ref;
+ calendarPeriodLabel: ComputedRef;
+ selectedDateKey: Ref;
+ focusedCalendarEvent: ComputedRef;
+ calendarEvents: Ref;
+ refetchAllCrmQueries: () => Promise;
+}) {
+ // ---------------------------------------------------------------------------
+ // State refs
+ // ---------------------------------------------------------------------------
+ const pilotMessages = ref([]);
+ const pilotInput = ref("");
+ const pilotSending = ref(false);
+ const pilotRecording = ref(false);
+ const pilotTranscribing = ref(false);
+ const pilotMicSupported = ref(false);
+ const pilotMicError = ref(null);
+ const pilotWaveContainer = ref(null);
+ function setPilotWaveContainerRef(element: HTMLDivElement | null) {
+ pilotWaveContainer.value = element;
+ }
+ const livePilotUserText = ref("");
+ const livePilotAssistantText = ref("");
+ const contextPickerEnabled = ref(false);
+ const contextScopes = ref([]);
+ const pilotLiveLogs = ref>([]);
+ const PILOT_LIVE_LOGS_PREVIEW_LIMIT = 5;
+ const pilotLiveLogsExpanded = ref(false);
+ const pilotLiveLogHiddenCount = computed(() => {
+ const hidden = pilotLiveLogs.value.length - PILOT_LIVE_LOGS_PREVIEW_LIMIT;
+ return hidden > 0 ? hidden : 0;
+ });
+ const pilotVisibleLiveLogs = computed(() => {
+ if (pilotLiveLogsExpanded.value || pilotLiveLogHiddenCount.value === 0) return pilotLiveLogs.value;
+ return pilotLiveLogs.value.slice(-PILOT_LIVE_LOGS_PREVIEW_LIMIT);
+ });
+ const pilotVisibleLogCount = computed(() =>
+ Math.min(pilotLiveLogs.value.length, PILOT_LIVE_LOGS_PREVIEW_LIMIT),
+ );
+
+ const chatConversations = ref([]);
+ const chatThreadsLoading = ref(false);
+ const chatSwitching = ref(false);
+ const chatCreating = ref(false);
+ const chatArchivingId = ref("");
+ const chatThreadPickerOpen = ref(false);
+ const selectedChatId = ref("");
+ let pilotBackgroundPoll: ReturnType | null = null;
+
+ // ---------------------------------------------------------------------------
+ // Media recorder vars (non-reactive)
+ // ---------------------------------------------------------------------------
+ let pilotMediaRecorder: MediaRecorder | null = null;
+ let pilotRecorderStream: MediaStream | null = null;
+ let pilotRecordingChunks: Blob[] = [];
+ let pilotRecorderMimeType = "audio/webm";
+ let pilotRecordingFinishMode: "fill" | "send" = "fill";
+ let waveSurferModulesPromise: Promise<{ WaveSurfer: any; RecordPlugin: any }> | null = null;
+ let pilotWaveSurfer: any = null;
+ let pilotWaveRecordPlugin: any = null;
+ let pilotWaveMicSession: { onDestroy: () => void; onEnd: () => void } | null = null;
+
+ // ---------------------------------------------------------------------------
+ // Apollo Queries
+ // ---------------------------------------------------------------------------
+ const { result: chatMessagesResult, refetch: refetchChatMessages } = useQuery(
+ ChatMessagesQueryDocument,
+ null,
+ { enabled: opts.apolloAuthReady },
+ );
+
+ const { result: chatConversationsResult, refetch: refetchChatConversations } = useQuery(
+ ChatConversationsQueryDocument,
+ null,
+ { enabled: opts.apolloAuthReady },
+ );
+
+ // ---------------------------------------------------------------------------
+ // Apollo Mutations
+ // ---------------------------------------------------------------------------
+ const { mutate: doLogPilotNote } = useMutation(LogPilotNoteMutationDocument);
+ const { mutate: doCreateChatConversation } = useMutation(CreateChatConversationMutationDocument, {
+ refetchQueries: [{ query: MeQueryDocument }, { query: ChatMessagesQueryDocument }, { query: ChatConversationsQueryDocument }],
+ });
+ const { mutate: doSelectChatConversation } = useMutation(SelectChatConversationMutationDocument, {
+ refetchQueries: [{ query: MeQueryDocument }, { query: ChatMessagesQueryDocument }, { query: ChatConversationsQueryDocument }],
+ });
+ const { mutate: doArchiveChatConversation } = useMutation(ArchiveChatConversationMutationDocument, {
+ refetchQueries: [{ query: MeQueryDocument }, { query: ChatMessagesQueryDocument }, { query: ChatConversationsQueryDocument }],
+ });
+
+ // ---------------------------------------------------------------------------
+ // AI SDK chat instance
+ // ---------------------------------------------------------------------------
+ const pilotChat = new AiChat({
+ transport: new DefaultChatTransport({
+ api: "/api/pilot-chat",
+ }),
+ onData: (part: any) => {
+ if (part?.type !== "data-agent-log") return;
+ const text = String(part?.data?.text ?? "").trim();
+ if (!text) return;
+ const at = String(part?.data?.at ?? new Date().toISOString());
+ pilotLiveLogs.value = [...pilotLiveLogs.value, { id: `${Date.now()}-${Math.random()}`, text, at }];
+ },
+ onFinish: async () => {
+ livePilotUserText.value = "";
+ livePilotAssistantText.value = "";
+ pilotLiveLogs.value = [];
+ await Promise.all([refetchChatMessages(), refetchChatConversations(), opts.refetchAllCrmQueries()]);
+ },
+ onError: () => {
+ if (livePilotUserText.value) {
+ pilotInput.value = livePilotUserText.value;
+ }
+ livePilotUserText.value = "";
+ livePilotAssistantText.value = "";
+ pilotLiveLogs.value = [];
+ },
+ });
+
+ // ---------------------------------------------------------------------------
+ // Apollo → Ref Watchers (bridge Apollo reactive results to existing refs)
+ // ---------------------------------------------------------------------------
+ watch(() => chatMessagesResult.value?.chatMessages, (v) => {
+ if (v) {
+ pilotMessages.value = v as PilotMessage[];
+ syncPilotChatFromHistory(pilotMessages.value);
+ }
+ }, { immediate: true });
+
+ watch(() => chatConversationsResult.value?.chatConversations, (v) => {
+ if (v) chatConversations.value = v as ChatConversation[];
+ }, { immediate: true });
+
+ watch(
+ () => pilotLiveLogs.value.length,
+ (len) => {
+ if (len === 0 || len <= PILOT_LIVE_LOGS_PREVIEW_LIMIT) {
+ pilotLiveLogsExpanded.value = false;
+ }
+ },
+ );
+
+ // Live assistant text watcher
+ watchEffect(() => {
+ if (!pilotSending.value) return;
+ const latestAssistant = [...pilotChat.messages]
+ .reverse()
+ .find((message) => message.role === "assistant");
+ if (!latestAssistant) return;
+
+ const textPart = latestAssistant.parts.find(isTextUIPart);
+ livePilotAssistantText.value = textPart?.text ?? "";
+ });
+
+ // ---------------------------------------------------------------------------
+ // Context picker
+ // ---------------------------------------------------------------------------
+ function toggleContextPicker() {
+ contextPickerEnabled.value = !contextPickerEnabled.value;
+ }
+
+ function hasContextScope(scope: ContextScope) {
+ return contextScopes.value.includes(scope);
+ }
+
+ function toggleContextScope(scope: ContextScope) {
+ if (!contextPickerEnabled.value) return;
+ if (hasContextScope(scope)) {
+ contextScopes.value = contextScopes.value.filter((item) => item !== scope);
+ return;
+ }
+ contextScopes.value = [...contextScopes.value, scope];
+ }
+
+ function removeContextScope(scope: ContextScope) {
+ contextScopes.value = contextScopes.value.filter((item) => item !== scope);
+ }
+
+ function togglePilotLiveLogsExpanded() {
+ pilotLiveLogsExpanded.value = !pilotLiveLogsExpanded.value;
+ }
+
+ // ---------------------------------------------------------------------------
+ // Pilot ↔ UIMessage bridge
+ // ---------------------------------------------------------------------------
+ function pilotToUiMessage(message: PilotMessage): UIMessage {
+ return {
+ id: message.id,
+ role: message.role,
+ parts: [{ type: "text", text: message.text }],
+ metadata: {
+ createdAt: message.createdAt ?? null,
+ },
+ };
+ }
+
+ function syncPilotChatFromHistory(messages: PilotMessage[]) {
+ pilotChat.messages = messages.filter((m) => m.role !== "system").map(pilotToUiMessage);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Context payload builder
+ // ---------------------------------------------------------------------------
+ function buildContextPayload(): PilotContextPayload | null {
+ const scopes = [...contextScopes.value];
+ if (!scopes.length) return null;
+
+ const payload: PilotContextPayload = { scopes };
+
+ if (hasContextScope("summary") && opts.selectedContact.value) {
+ payload.summary = {
+ contactId: opts.selectedContact.value.id,
+ name: opts.selectedContact.value.name,
+ };
+ }
+
+ if (hasContextScope("deal") && opts.selectedDeal.value) {
+ payload.deal = {
+ dealId: opts.selectedDeal.value.id,
+ title: opts.selectedDeal.value.title,
+ contact: opts.selectedDeal.value.contact,
+ };
+ }
+
+ if (hasContextScope("message")) {
+ payload.message = {
+ contactId: opts.selectedContact.value?.id || undefined,
+ contact: opts.selectedContact.value?.name || undefined,
+ intent: "add_message_or_reminder",
+ };
+ }
+
+ if (hasContextScope("calendar")) {
+ const eventIds = (() => {
+ if (opts.focusedCalendarEvent.value) return [opts.focusedCalendarEvent.value.id];
+ return opts.calendarEvents.value.map((event) => event.id);
+ })();
+
+ payload.calendar = {
+ view: opts.calendarView.value,
+ period: opts.calendarPeriodLabel.value,
+ selectedDateKey: opts.selectedDateKey.value,
+ focusedEventId: opts.focusedCalendarEvent.value?.id || undefined,
+ eventIds,
+ };
+ }
+
+ return payload;
+ }
+
+ // ---------------------------------------------------------------------------
+ // Send pilot message
+ // ---------------------------------------------------------------------------
+ async function sendPilotText(rawText: string) {
+ const text = safeTrim(rawText);
+ if (!text || pilotSending.value) return;
+ const contextPayload = buildContextPayload();
+
+ pilotSending.value = true;
+ pilotInput.value = "";
+ livePilotUserText.value = text;
+ livePilotAssistantText.value = "";
+ pilotLiveLogsExpanded.value = false;
+ pilotLiveLogs.value = [];
+ try {
+ await pilotChat.sendMessage(
+ { text },
+ contextPayload
+ ? {
+ body: {
+ contextPayload,
+ },
+ }
+ : undefined,
+ );
+ } catch {
+ pilotInput.value = text;
+ } finally {
+ const latestAssistant = [...pilotChat.messages]
+ .reverse()
+ .find((message) => message.role === "assistant");
+
+ if (latestAssistant) {
+ const textPart = latestAssistant.parts.find(isTextUIPart);
+ livePilotAssistantText.value = textPart?.text ?? "";
+ }
+
+ livePilotUserText.value = "";
+ livePilotAssistantText.value = "";
+ pilotSending.value = false;
+ await Promise.all([refetchChatMessages(), refetchChatConversations(), opts.refetchAllCrmQueries()]);
+ }
+ }
+
+ async function sendPilotMessage() {
+ await sendPilotText(pilotInput.value);
+ }
+
+ // ---------------------------------------------------------------------------
+ // WaveSurfer lazy loading for mic
+ // ---------------------------------------------------------------------------
+ async function loadWaveSurferModules() {
+ if (!waveSurferModulesPromise) {
+ waveSurferModulesPromise = Promise.all([
+ import("wavesurfer.js"),
+ import("wavesurfer.js/dist/plugins/record.esm.js"),
+ ]).then(([ws, rec]) => ({
+ WaveSurfer: ws.default,
+ RecordPlugin: rec.default,
+ }));
+ }
+ return waveSurferModulesPromise;
+ }
+
+ async function ensurePilotWaveSurfer() {
+ if (pilotWaveSurfer && pilotWaveRecordPlugin) return;
+ if (!pilotWaveContainer.value) return;
+
+ const { WaveSurfer, RecordPlugin } = await loadWaveSurferModules();
+
+ pilotWaveSurfer = WaveSurfer.create({
+ container: pilotWaveContainer.value,
+ height: 22,
+ waveColor: "rgba(208, 226, 255, 0.95)",
+ progressColor: "rgba(141, 177, 255, 0.95)",
+ cursorWidth: 0,
+ normalize: true,
+ interact: false,
+ });
+
+ pilotWaveRecordPlugin = pilotWaveSurfer.registerPlugin(
+ RecordPlugin.create({
+ renderRecordedAudio: false,
+ scrollingWaveform: true,
+ scrollingWaveformWindow: 10,
+ mediaRecorderTimeslice: 250,
+ }),
+ );
+ }
+
+ async function stopPilotMeter() {
+ if (pilotWaveMicSession) {
+ pilotWaveMicSession.onDestroy();
+ pilotWaveMicSession = null;
+ }
+ }
+
+ async function startPilotMeter(stream: MediaStream) {
+ await nextTick();
+ await ensurePilotWaveSurfer();
+ await stopPilotMeter();
+ if (!pilotWaveRecordPlugin) return;
+ pilotWaveMicSession = pilotWaveRecordPlugin.renderMicStream(stream);
+ }
+
+ function destroyPilotWaveSurfer() {
+ stopPilotMeter();
+ if (pilotWaveSurfer) {
+ pilotWaveSurfer.destroy();
+ pilotWaveSurfer = null;
+ pilotWaveRecordPlugin = null;
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Audio recording & transcription
+ // ---------------------------------------------------------------------------
+ function appendPilotTranscript(text: string) {
+ const next = safeTrim(text);
+ if (!next) return "";
+ const merged = pilotInput.value.trim() ? `${pilotInput.value.trim()} ${next}` : next;
+ pilotInput.value = merged;
+ return merged;
+ }
+
+ async function transcribeRecordedPilotAudio(blob: Blob) {
+ pilotMicError.value = null;
+ pilotTranscribing.value = true;
+ try {
+ const text = await transcribeAudioBlob(blob);
+ if (!text) {
+ pilotMicError.value = "Не удалось распознать речь, попробуйте еще раз.";
+ return null;
+ }
+ return text;
+ } catch (error: any) {
+ pilotMicError.value = String(error?.data?.message ?? error?.message ?? "Ошибка распознавания аудио");
+ return null;
+ } finally {
+ pilotTranscribing.value = false;
+ }
+ }
+
+ async function startPilotRecording() {
+ if (pilotRecording.value || pilotTranscribing.value) return;
+ pilotMicError.value = null;
+ if (!pilotMicSupported.value) {
+ pilotMicError.value = "Запись не поддерживается в этом браузере.";
+ return;
+ }
+
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+ const preferredMime = "audio/webm;codecs=opus";
+ const recorder = MediaRecorder.isTypeSupported(preferredMime)
+ ? new MediaRecorder(stream, { mimeType: preferredMime })
+ : new MediaRecorder(stream);
+ pilotRecorderStream = stream;
+ pilotRecorderMimeType = recorder.mimeType || "audio/webm";
+ pilotMediaRecorder = recorder;
+ pilotRecordingFinishMode = "fill";
+ pilotRecordingChunks = [];
+ pilotRecording.value = true;
+ void startPilotMeter(stream);
+
+ recorder.ondataavailable = (event: BlobEvent) => {
+ if (event.data?.size) pilotRecordingChunks.push(event.data);
+ };
+
+ recorder.onstop = async () => {
+ pilotRecording.value = false;
+ await stopPilotMeter();
+ const mode = pilotRecordingFinishMode;
+ pilotRecordingFinishMode = "fill";
+ const audioBlob = new Blob(pilotRecordingChunks, { type: pilotRecorderMimeType });
+ pilotRecordingChunks = [];
+ pilotMediaRecorder = null;
+ if (pilotRecorderStream) {
+ pilotRecorderStream.getTracks().forEach((track) => track.stop());
+ pilotRecorderStream = null;
+ }
+ if (audioBlob.size > 0) {
+ const transcript = await transcribeRecordedPilotAudio(audioBlob);
+ if (!transcript) return;
+ const mergedText = appendPilotTranscript(transcript);
+ if (mode === "send" && !pilotSending.value && mergedText.trim()) {
+ await sendPilotText(mergedText);
+ return;
+ }
+ }
+ };
+
+ recorder.start();
+ } catch {
+ pilotMicError.value = "Нет доступа к микрофону.";
+ pilotRecording.value = false;
+ }
+ }
+
+ function stopPilotRecording(mode: "fill" | "send" = "fill") {
+ if (!pilotMediaRecorder || pilotMediaRecorder.state === "inactive") return;
+ pilotRecordingFinishMode = mode;
+ pilotRecording.value = false;
+ pilotMediaRecorder.stop();
+ }
+
+ function togglePilotRecording() {
+ if (pilotRecording.value) {
+ stopPilotRecording("fill");
+ } else {
+ startPilotRecording();
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Chat conversation management
+ // ---------------------------------------------------------------------------
+ function toggleChatThreadPicker() {
+ if (chatSwitching.value || chatThreadsLoading.value || chatConversations.value.length === 0) return;
+ chatThreadPickerOpen.value = !chatThreadPickerOpen.value;
+ }
+
+ function closeChatThreadPicker() {
+ chatThreadPickerOpen.value = false;
+ }
+
+ async function createNewChatConversation() {
+ if (chatCreating.value) return;
+ chatThreadPickerOpen.value = false;
+ chatCreating.value = true;
+ try {
+ await doCreateChatConversation();
+ } finally {
+ chatCreating.value = false;
+ }
+ }
+
+ async function switchChatConversation(id: string) {
+ if (!id || chatSwitching.value || opts.authMe.value?.conversation.id === id) return;
+ chatThreadPickerOpen.value = false;
+ chatSwitching.value = true;
+ try {
+ await doSelectChatConversation({ id });
+ } finally {
+ chatSwitching.value = false;
+ }
+ }
+
+ async function archiveChatConversation(id: string) {
+ if (!id || chatArchivingId.value) return;
+ chatArchivingId.value = id;
+ try {
+ await doArchiveChatConversation({ id });
+ } finally {
+ chatArchivingId.value = "";
+ }
+ }
+
+ // ---------------------------------------------------------------------------
+ // Background polling
+ // ---------------------------------------------------------------------------
+ async function loadPilotMessages() {
+ await refetchChatMessages();
+ }
+
+ function startPilotBackgroundPolling() {
+ if (pilotBackgroundPoll) return;
+ pilotBackgroundPoll = setInterval(() => {
+ if (!opts.authMe.value) return;
+ loadPilotMessages().catch(() => {});
+ }, 2000);
+ }
+
+ function stopPilotBackgroundPolling() {
+ if (!pilotBackgroundPoll) return;
+ clearInterval(pilotBackgroundPoll);
+ pilotBackgroundPoll = null;
+ }
+
+ // ---------------------------------------------------------------------------
+ // Fire-and-forget pilot note
+ // ---------------------------------------------------------------------------
+ function pushPilotNote(text: string) {
+ // Fire-and-forget: log assistant note to the same conversation.
+ doLogPilotNote({ text })
+ .then(() => Promise.all([refetchChatMessages(), refetchChatConversations()]))
+ .catch(() => {});
+ }
+
+ // ---------------------------------------------------------------------------
+ // Public API
+ // ---------------------------------------------------------------------------
+ return {
+ pilotMessages,
+ pilotInput,
+ pilotSending,
+ pilotRecording,
+ pilotTranscribing,
+ pilotMicSupported,
+ pilotMicError,
+ pilotWaveContainer,
+ setPilotWaveContainerRef,
+ livePilotUserText,
+ livePilotAssistantText,
+ contextPickerEnabled,
+ contextScopes,
+ pilotLiveLogs,
+ pilotLiveLogsExpanded,
+ pilotLiveLogHiddenCount,
+ pilotVisibleLiveLogs,
+ pilotVisibleLogCount,
+ chatConversations,
+ chatThreadsLoading,
+ chatSwitching,
+ chatCreating,
+ selectedChatId,
+ chatThreadPickerOpen,
+ chatArchivingId,
+ toggleContextPicker,
+ hasContextScope,
+ toggleContextScope,
+ removeContextScope,
+ togglePilotLiveLogsExpanded,
+ sendPilotText,
+ sendPilotMessage,
+ startPilotRecording,
+ stopPilotRecording,
+ togglePilotRecording,
+ createNewChatConversation,
+ switchChatConversation,
+ archiveChatConversation,
+ toggleChatThreadPicker,
+ closeChatThreadPicker,
+ startPilotBackgroundPolling,
+ stopPilotBackgroundPolling,
+ buildContextPayload,
+ pushPilotNote,
+ refetchChatMessages,
+ refetchChatConversations,
+ // cleanup
+ destroyPilotWaveSurfer,
+ };
+}
diff --git a/frontend/app/composables/usePins.ts b/frontend/app/composables/usePins.ts
new file mode 100644
index 0000000..531d936
--- /dev/null
+++ b/frontend/app/composables/usePins.ts
@@ -0,0 +1,222 @@
+import { ref, computed, watch, type ComputedRef, type Ref } from "vue";
+import { useQuery, useMutation } from "@vue/apollo-composable";
+import { PinsQueryDocument, ToggleContactPinMutationDocument } from "~~/graphql/generated";
+import type { CommPin, CalendarEvent, CommItem, ClientTimelineItem } from "~/composables/crm-types";
+import {
+ formatDay,
+ isEventFinalStatus,
+ eventLifecyclePhase,
+} from "~/composables/crm-types";
+import type { EventLifecyclePhase } from "~/composables/crm-types";
+
+export function usePins(opts: {
+ apolloAuthReady: ComputedRef;
+ selectedCommThread: ComputedRef<{ id: string; contact: string; items: CommItem[] } | undefined>;
+ selectedCommLifecycleEvents: ComputedRef>;
+ visibleThreadItems: ComputedRef;
+}) {
+ const { result: pinsResult, refetch: refetchPins } = useQuery(
+ PinsQueryDocument,
+ null,
+ { enabled: opts.apolloAuthReady },
+ );
+
+ const { mutate: doToggleContactPin } = useMutation(ToggleContactPinMutationDocument, {
+ refetchQueries: [{ query: PinsQueryDocument }],
+ });
+
+ const commPins = ref([]);
+ const commPinToggling = ref(false);
+ const commPinContextMenu = ref<{
+ open: boolean;
+ x: number;
+ y: number;
+ entry: any | null;
+ }>({
+ open: false,
+ x: 0,
+ y: 0,
+ entry: null,
+ });
+
+ watch(() => pinsResult.value?.pins, (v) => {
+ if (v) commPins.value = v as CommPin[];
+ }, { immediate: true });
+
+ const selectedCommPins = computed(() => {
+ if (!opts.selectedCommThread.value) return [];
+ return commPins.value.filter((item) => item.contact === opts.selectedCommThread.value?.contact);
+ });
+
+ function normalizePinText(value: string) {
+ return String(value ?? "").replace(/\s+/g, " ").trim();
+ }
+
+ function stripPinnedPrefix(value: string) {
+ return String(value ?? "").replace(/^\s*(закреплено|pinned)\s*:\s*/i, "").trim();
+ }
+
+ function isPinnedText(contact: string, value: string) {
+ const contactName = String(contact ?? "").trim();
+ const text = normalizePinText(value);
+ if (!contactName || !text) return false;
+ return commPins.value.some((pin) => pin.contact === contactName && normalizePinText(pin.text) === text);
+ }
+
+ function entryPinText(entry: any): string {
+ if (!entry) return "";
+ if (entry.kind === "pin") return normalizePinText(stripPinnedPrefix(entry.text ?? ""));
+ if (entry.kind === "recommendation") return normalizePinText(entry.card?.text ?? "");
+ if (entry.kind === "eventLifecycle") {
+ return normalizePinText(entry.event?.note || entry.event?.title || "");
+ }
+ if (entry.kind === "call") return normalizePinText(entry.item?.text || "");
+ return normalizePinText(entry.item?.text || "");
+ }
+
+ async function togglePinnedText(contact: string, value: string) {
+ if (commPinToggling.value) return;
+ const contactName = String(contact ?? "").trim();
+ const text = normalizePinText(value);
+ if (!contactName || !text) return;
+ commPinToggling.value = true;
+ try {
+ await doToggleContactPin({ contact: contactName, text });
+ } finally {
+ commPinToggling.value = false;
+ }
+ }
+
+ async function togglePinForEntry(entry: any) {
+ const contact = opts.selectedCommThread.value?.contact ?? "";
+ const text = entryPinText(entry);
+ await togglePinnedText(contact, text);
+ }
+
+ function isPinnedEntry(entry: any) {
+ const contact = opts.selectedCommThread.value?.contact ?? "";
+ const text = entryPinText(entry);
+ return isPinnedText(contact, text);
+ }
+
+ function closeCommPinContextMenu() {
+ commPinContextMenu.value = {
+ open: false,
+ x: 0,
+ y: 0,
+ entry: null,
+ };
+ }
+
+ function openCommPinContextMenu(event: MouseEvent, entry: any) {
+ const text = entryPinText(entry);
+ if (!text) return;
+ const menuWidth = 136;
+ const menuHeight = 46;
+ const padding = 8;
+ const maxX = Math.max(padding, window.innerWidth - menuWidth - padding);
+ const maxY = Math.max(padding, window.innerHeight - menuHeight - padding);
+ const x = Math.min(maxX, Math.max(padding, event.clientX));
+ const y = Math.min(maxY, Math.max(padding, event.clientY));
+ commPinContextMenu.value = {
+ open: true,
+ x,
+ y,
+ entry,
+ };
+ }
+
+ const commPinContextActionLabel = computed(() => {
+ const entry = commPinContextMenu.value.entry;
+ if (!entry) return "Pin";
+ return isPinnedEntry(entry) ? "Unpin" : "Pin";
+ });
+
+ async function applyCommPinContextAction() {
+ const entry = commPinContextMenu.value.entry;
+ if (!entry) return;
+ closeCommPinContextMenu();
+ await togglePinForEntry(entry);
+ }
+
+ function onWindowPointerDownForCommPinMenu(event: PointerEvent) {
+ if (!commPinContextMenu.value.open) return;
+ const target = event.target as HTMLElement | null;
+ if (target?.closest(".comm-pin-context-menu")) return;
+ closeCommPinContextMenu();
+ }
+
+ function onWindowKeyDownForCommPinMenu(event: KeyboardEvent) {
+ if (!commPinContextMenu.value.open) return;
+ if (event.key === "Escape") {
+ closeCommPinContextMenu();
+ }
+ }
+
+ const selectedCommPinnedStream = computed(() => {
+ const pins = selectedCommPins.value.map((pin) => {
+ const normalizedText = normalizePinText(stripPinnedPrefix(pin.text));
+ const sourceItem =
+ [...opts.visibleThreadItems.value]
+ .filter((item) => normalizePinText(item.text) === normalizedText)
+ .sort((a, b) => b.at.localeCompare(a.at))[0] ?? null;
+ return {
+ id: `pin-${pin.id}`,
+ kind: "pin" as const,
+ text: pin.text,
+ sourceItem,
+ };
+ });
+
+ const rank = (phase: EventLifecyclePhase) => {
+ if (phase === "awaiting_outcome") return 0;
+ if (phase === "due_soon") return 1;
+ if (phase === "scheduled") return 2;
+ return 3;
+ };
+
+ const events = opts.selectedCommLifecycleEvents.value
+ .filter((item) => !isEventFinalStatus(item.event.isArchived))
+ .sort((a, b) => rank(a.phase) - rank(b.phase) || a.event.start.localeCompare(b.event.start))
+ .map((item) => ({
+ id: `event-${item.event.id}`,
+ kind: "eventLifecycle" as const,
+ event: item.event,
+ phase: item.phase,
+ }));
+
+ return [...pins, ...events];
+ });
+
+ const latestPinnedItem = computed(() => selectedCommPinnedStream.value[0] ?? null);
+
+ const latestPinnedLabel = computed(() => {
+ if (!latestPinnedItem.value) return "No pinned items yet";
+ if (latestPinnedItem.value.kind === "pin") return stripPinnedPrefix(latestPinnedItem.value.text);
+ return `${latestPinnedItem.value.event.title} · ${formatDay(latestPinnedItem.value.event.start)}`;
+ });
+
+ return {
+ commPins,
+ commPinToggling,
+ commPinContextMenu,
+ selectedCommPins,
+ selectedCommPinnedStream,
+ togglePinnedText,
+ togglePinForEntry,
+ isPinnedText,
+ isPinnedEntry,
+ entryPinText,
+ normalizePinText,
+ stripPinnedPrefix,
+ latestPinnedItem,
+ latestPinnedLabel,
+ closeCommPinContextMenu,
+ openCommPinContextMenu,
+ commPinContextActionLabel,
+ applyCommPinContextAction,
+ onWindowPointerDownForCommPinMenu,
+ onWindowKeyDownForCommPinMenu,
+ refetchPins,
+ };
+}
diff --git a/frontend/app/composables/useTimeline.ts b/frontend/app/composables/useTimeline.ts
new file mode 100644
index 0000000..4696fa9
--- /dev/null
+++ b/frontend/app/composables/useTimeline.ts
@@ -0,0 +1,52 @@
+import { ref, computed, watch, type ComputedRef } from "vue";
+import { useQuery } from "@vue/apollo-composable";
+import { GetClientTimelineQueryDocument } from "~~/graphql/generated";
+import type { ClientTimelineItem } from "~/composables/crm-types";
+
+export function useTimeline(opts: { apolloAuthReady: ComputedRef }) {
+ const timelineContactId = ref("");
+ const timelineLimit = ref(500);
+
+ const { result: timelineResult, refetch: refetchTimeline } = useQuery(
+ GetClientTimelineQueryDocument,
+ () => ({ contactId: timelineContactId.value, limit: timelineLimit.value }),
+ { enabled: computed(() => !!timelineContactId.value && opts.apolloAuthReady.value) },
+ );
+
+ const clientTimelineItems = ref([]);
+
+ watch(() => timelineResult.value?.getClientTimeline, (v) => {
+ if (v) clientTimelineItems.value = v as ClientTimelineItem[];
+ }, { immediate: true });
+
+ async function loadClientTimeline(contactId: string, limit = 500) {
+ const normalizedContactId = String(contactId ?? "").trim();
+ if (!normalizedContactId) {
+ clientTimelineItems.value = [];
+ timelineContactId.value = "";
+ return;
+ }
+
+ timelineContactId.value = normalizedContactId;
+ timelineLimit.value = limit;
+ await refetchTimeline();
+ }
+
+ async function refreshSelectedClientTimeline(selectedCommThreadId: string) {
+ const contactId = String(selectedCommThreadId ?? "").trim();
+ if (!contactId) {
+ clientTimelineItems.value = [];
+ return;
+ }
+ await loadClientTimeline(contactId);
+ }
+
+ return {
+ clientTimelineItems,
+ timelineContactId,
+ timelineLimit,
+ loadClientTimeline,
+ refreshSelectedClientTimeline,
+ refetchTimeline,
+ };
+}
diff --git a/frontend/app/composables/useWorkspaceRouting.ts b/frontend/app/composables/useWorkspaceRouting.ts
new file mode 100644
index 0000000..b759174
--- /dev/null
+++ b/frontend/app/composables/useWorkspaceRouting.ts
@@ -0,0 +1,404 @@
+import { ref, type Ref, type ComputedRef } from "vue";
+import type { TabId, CalendarView, PeopleLeftMode, CalendarEvent, PilotChangeItem } from "~/composables/crm-types";
+import { safeTrim, dayKey } from "~/composables/crm-types";
+
+export function useWorkspaceRouting(opts: {
+ selectedTab: Ref;
+ peopleLeftMode: Ref;
+ peopleListMode: Ref<"contacts" | "deals">;
+ selectedContactId: Ref;
+ selectedCommThreadId: Ref;
+ selectedDealId: Ref;
+ selectedChatId: Ref;
+ calendarView: Ref;
+ calendarCursor: Ref;
+ selectedDateKey: Ref;
+ selectedDocumentId: Ref;
+ focusedCalendarEventId: Ref;
+ activeChangeSetId: Ref;
+ activeChangeStep: Ref;
+ // computed refs
+ sortedEvents: ComputedRef;
+ commThreads: ComputedRef<{ id: string; [key: string]: any }[]>;
+ contacts: Ref<{ id: string; name: string; [key: string]: any }[]>;
+ deals: Ref<{ id: string; contact: string; [key: string]: any }[]>;
+ commItems: Ref<{ id: string; contact: string; [key: string]: any }[]>;
+ activeChangeMessage: ComputedRef<{ changeSetId?: string | null; changeItems?: PilotChangeItem[] | null } | null>;
+ activeChangeItem: ComputedRef;
+ activeChangeItems: ComputedRef;
+ activeChangeIndex: ComputedRef;
+ authMe: Ref<{ conversation: { id: string } } | null>;
+ // functions from outside
+ pickDate: (key: string) => void;
+ openCommunicationThread: (contact: string) => void;
+ completeTelegramBusinessConnectFromToken: (token: string) => void;
+}) {
+ const uiPathSyncLocked = ref(false);
+ let popstateHandler: (() => void) | null = null;
+
+ // ---------------------------------------------------------------------------
+ // Calendar route helpers (internal)
+ // ---------------------------------------------------------------------------
+ function calendarCursorToken(date: Date) {
+ const y = date.getFullYear();
+ const m = String(date.getMonth() + 1).padStart(2, "0");
+ return `${y}-${m}`;
+ }
+
+ function calendarRouteToken(view: CalendarView) {
+ if (view === "day" || view === "week") {
+ return opts.selectedDateKey.value;
+ }
+ if (view === "year") {
+ return String(opts.calendarCursor.value.getFullYear());
+ }
+ return calendarCursorToken(opts.calendarCursor.value);
+ }
+
+ function parseCalendarCursorToken(token: string | null | undefined) {
+ const text = String(token ?? "").trim();
+ const m = text.match(/^(\d{4})-(\d{2})$/);
+ if (!m) return null;
+ const year = Number(m[1]);
+ const month = Number(m[2]);
+ if (!Number.isFinite(year) || !Number.isFinite(month) || month < 1 || month > 12) return null;
+ return new Date(year, month - 1, 1);
+ }
+
+ function parseCalendarDateToken(token: string | null | undefined) {
+ const text = String(token ?? "").trim();
+ const m = text.match(/^(\d{4})-(\d{2})-(\d{2})$/);
+ if (!m) return null;
+ const year = Number(m[1]);
+ const month = Number(m[2]);
+ const day = Number(m[3]);
+ if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return null;
+ if (month < 1 || month > 12 || day < 1 || day > 31) return null;
+ const parsed = new Date(year, month - 1, day);
+ if (Number.isNaN(parsed.getTime())) return null;
+ return parsed;
+ }
+
+ function parseCalendarYearToken(token: string | null | undefined) {
+ const text = String(token ?? "").trim();
+ const m = text.match(/^(\d{4})$/);
+ if (!m) return null;
+ const year = Number(m[1]);
+ if (!Number.isFinite(year)) return null;
+ return year;
+ }
+
+ // ---------------------------------------------------------------------------
+ // Core routing functions
+ // ---------------------------------------------------------------------------
+ function normalizedConversationId() {
+ return safeTrim(opts.selectedChatId.value || opts.authMe.value?.conversation.id || "pilot");
+ }
+
+ function withReviewQuery(path: string) {
+ const reviewSet = opts.activeChangeSetId.value.trim();
+ if (!reviewSet) return path;
+ const params = new URLSearchParams();
+ params.set("reviewSet", reviewSet);
+ params.set("reviewStep", String(Math.max(1, opts.activeChangeStep.value + 1)));
+ return `${path}?${params.toString()}`;
+ }
+
+ function currentUiPath() {
+ if (opts.selectedTab.value === "documents") {
+ const docId = opts.selectedDocumentId.value.trim();
+ if (docId) {
+ return withReviewQuery(`/documents/${encodeURIComponent(docId)}`);
+ }
+ return withReviewQuery("/documents");
+ }
+
+ if (opts.peopleLeftMode.value === "calendar") {
+ if (opts.focusedCalendarEventId.value.trim()) {
+ return withReviewQuery(`/calendar/event/${encodeURIComponent(opts.focusedCalendarEventId.value.trim())}`);
+ }
+ return withReviewQuery(`/calendar/${encodeURIComponent(opts.calendarView.value)}/${encodeURIComponent(calendarRouteToken(opts.calendarView.value))}`);
+ }
+
+ if (opts.peopleListMode.value === "deals" && opts.selectedDealId.value.trim()) {
+ return withReviewQuery(`/deal/${encodeURIComponent(opts.selectedDealId.value.trim())}`);
+ }
+
+ if (opts.selectedContactId.value.trim()) {
+ return withReviewQuery(`/contact/${encodeURIComponent(opts.selectedContactId.value.trim())}`);
+ }
+
+ return withReviewQuery(`/chat/${encodeURIComponent(normalizedConversationId())}`);
+ }
+
+ function syncPathFromUi(push = false) {
+ if (process.server) return;
+ const nextPath = currentUiPath();
+ const currentPath = `${window.location.pathname}${window.location.search}`;
+ if (nextPath === currentPath) return;
+ if (push) {
+ window.history.pushState({}, "", nextPath);
+ } else {
+ window.history.replaceState({}, "", nextPath);
+ }
+ }
+
+ function applyPathToUi(pathname: string, search = "") {
+ const path = String(pathname || "/").trim() || "/";
+ const params = new URLSearchParams(String(search || ""));
+ const reviewSet = (params.get("reviewSet") ?? "").trim();
+ const reviewStep = Number(params.get("reviewStep") ?? "1");
+
+ if (reviewSet) {
+ opts.activeChangeSetId.value = reviewSet;
+ opts.activeChangeStep.value = Number.isFinite(reviewStep) && reviewStep > 0 ? reviewStep - 1 : 0;
+ } else {
+ opts.activeChangeSetId.value = "";
+ opts.activeChangeStep.value = 0;
+ }
+
+ const calendarEventMatch = path.match(/^\/calendar\/event\/([^/]+)\/?$/i);
+ if (calendarEventMatch) {
+ const rawEventId = decodeURIComponent(calendarEventMatch[1] ?? "").trim();
+ opts.selectedTab.value = "communications";
+ opts.peopleLeftMode.value = "calendar";
+ const event = opts.sortedEvents.value.find((x) => x.id === rawEventId);
+ if (event) {
+ opts.pickDate(event.start.slice(0, 10));
+ }
+ opts.focusedCalendarEventId.value = rawEventId;
+ return;
+ }
+
+ const calendarMatch = path.match(/^\/calendar\/([^/]+)\/([^/]+)\/?$/i);
+ if (calendarMatch) {
+ const rawView = decodeURIComponent(calendarMatch[1] ?? "").trim();
+ const rawCursor = decodeURIComponent(calendarMatch[2] ?? "").trim();
+ const view = (["day", "week", "month", "year", "agenda"] as CalendarView[]).includes(rawView as CalendarView)
+ ? (rawView as CalendarView)
+ : "month";
+ const cursorByMonth = parseCalendarCursorToken(rawCursor);
+ const cursorByDate = parseCalendarDateToken(rawCursor);
+ const cursorByYear = parseCalendarYearToken(rawCursor);
+ opts.selectedTab.value = "communications";
+ opts.peopleLeftMode.value = "calendar";
+ opts.focusedCalendarEventId.value = "";
+ opts.calendarView.value = view;
+ if (view === "day" || view === "week") {
+ const parsed = cursorByDate;
+ if (parsed) {
+ opts.selectedDateKey.value = dayKey(parsed);
+ opts.calendarCursor.value = new Date(parsed.getFullYear(), parsed.getMonth(), 1);
+ }
+ } else if (view === "year") {
+ if (cursorByYear) {
+ opts.calendarCursor.value = new Date(cursorByYear, 0, 1);
+ opts.selectedDateKey.value = dayKey(new Date(cursorByYear, 0, 1));
+ }
+ } else if (cursorByMonth) {
+ opts.calendarCursor.value = cursorByMonth;
+ opts.selectedDateKey.value = dayKey(cursorByMonth);
+ }
+ return;
+ }
+
+ const documentsMatch = path.match(/^\/documents(?:\/([^/]+))?\/?$/i);
+ if (documentsMatch) {
+ const rawDocumentId = decodeURIComponent(documentsMatch[1] ?? "").trim();
+ opts.selectedTab.value = "documents";
+ opts.focusedCalendarEventId.value = "";
+ if (rawDocumentId) opts.selectedDocumentId.value = rawDocumentId;
+ return;
+ }
+
+ const contactMatch = path.match(/^\/contact\/([^/]+)\/?$/i);
+ if (contactMatch) {
+ const rawContactId = decodeURIComponent(contactMatch[1] ?? "").trim();
+ opts.selectedTab.value = "communications";
+ opts.peopleLeftMode.value = "contacts";
+ opts.peopleListMode.value = "contacts";
+ if (rawContactId) {
+ opts.selectedContactId.value = rawContactId;
+ const linkedThread = opts.commThreads.value.find((thread) => thread.id === rawContactId);
+ if (linkedThread) opts.selectedCommThreadId.value = linkedThread.id;
+ }
+ opts.focusedCalendarEventId.value = "";
+ return;
+ }
+
+ const dealMatch = path.match(/^\/deal\/([^/]+)\/?$/i);
+ if (dealMatch) {
+ const rawDealId = decodeURIComponent(dealMatch[1] ?? "").trim();
+ opts.selectedTab.value = "communications";
+ opts.peopleLeftMode.value = "contacts";
+ opts.peopleListMode.value = "deals";
+ if (rawDealId) {
+ opts.selectedDealId.value = rawDealId;
+ const linkedDeal = opts.deals.value.find((deal) => deal.id === rawDealId);
+ const linkedContact = linkedDeal
+ ? opts.contacts.value.find((contact) => contact.name === linkedDeal.contact)
+ : null;
+ if (linkedContact) {
+ opts.selectedContactId.value = linkedContact.id;
+ opts.selectedCommThreadId.value = linkedContact.id;
+ }
+ }
+ opts.focusedCalendarEventId.value = "";
+ return;
+ }
+
+ const chatMatch = path.match(/^\/chat\/([^/]+)\/?$/i);
+ if (chatMatch) {
+ const rawChatId = decodeURIComponent(chatMatch[1] ?? "").trim();
+ opts.selectedTab.value = "communications";
+ opts.peopleLeftMode.value = "contacts";
+ opts.peopleListMode.value = "contacts";
+ opts.focusedCalendarEventId.value = "";
+ if (rawChatId) opts.selectedChatId.value = rawChatId;
+ return;
+ }
+
+ const changesMatch = path.match(/^\/changes\/([^/]+)(?:\/step\/(\d+))?\/?$/i);
+ if (changesMatch) {
+ const rawId = decodeURIComponent(changesMatch[1] ?? "").trim();
+ const rawStep = Number(changesMatch[2] ?? "1");
+ if (rawId) {
+ opts.activeChangeSetId.value = rawId;
+ opts.activeChangeStep.value = Number.isFinite(rawStep) && rawStep > 0 ? rawStep - 1 : 0;
+ }
+ opts.selectedTab.value = "communications";
+ opts.peopleLeftMode.value = "contacts";
+ opts.peopleListMode.value = "contacts";
+ opts.focusedCalendarEventId.value = "";
+ return;
+ }
+
+ opts.selectedTab.value = "communications";
+ opts.peopleLeftMode.value = "contacts";
+ opts.peopleListMode.value = "contacts";
+ opts.focusedCalendarEventId.value = "";
+ }
+
+ function applyReviewStepToUi(push = false) {
+ const item = opts.activeChangeItem.value;
+ if (!item) {
+ syncPathFromUi(push);
+ return;
+ }
+
+ opts.selectedTab.value = "communications";
+
+ if (item.entity === "calendar_event" && item.entityId) {
+ opts.peopleLeftMode.value = "calendar";
+ opts.calendarView.value = "month";
+ const event = opts.sortedEvents.value.find((x) => x.id === item.entityId);
+ if (event) {
+ opts.pickDate(event.start.slice(0, 10));
+ }
+ opts.focusedCalendarEventId.value = item.entityId;
+ syncPathFromUi(push);
+ return;
+ }
+
+ if (item.entity === "contact_note" && item.entityId) {
+ opts.peopleLeftMode.value = "contacts";
+ opts.peopleListMode.value = "contacts";
+ opts.selectedContactId.value = item.entityId;
+ const thread = opts.commThreads.value.find((entry) => entry.id === item.entityId);
+ if (thread) opts.selectedCommThreadId.value = thread.id;
+ opts.focusedCalendarEventId.value = "";
+ syncPathFromUi(push);
+ return;
+ }
+
+ if (item.entity === "deal" && item.entityId) {
+ opts.peopleLeftMode.value = "contacts";
+ opts.peopleListMode.value = "deals";
+ opts.selectedDealId.value = item.entityId;
+ const deal = opts.deals.value.find((entry) => entry.id === item.entityId);
+ if (deal) {
+ const contact = opts.contacts.value.find((entry) => entry.name === deal.contact);
+ if (contact) {
+ opts.selectedContactId.value = contact.id;
+ opts.selectedCommThreadId.value = contact.id;
+ }
+ }
+ opts.focusedCalendarEventId.value = "";
+ syncPathFromUi(push);
+ return;
+ }
+
+ if (item.entity === "message" && item.entityId) {
+ opts.peopleLeftMode.value = "contacts";
+ opts.peopleListMode.value = "contacts";
+ const message = opts.commItems.value.find((entry) => entry.id === item.entityId);
+ if (message?.contact) {
+ opts.openCommunicationThread(message.contact);
+ }
+ opts.focusedCalendarEventId.value = "";
+ syncPathFromUi(push);
+ return;
+ }
+
+ if (item.entity === "workspace_document" && item.entityId) {
+ opts.selectedTab.value = "documents";
+ opts.selectedDocumentId.value = item.entityId;
+ opts.focusedCalendarEventId.value = "";
+ syncPathFromUi(push);
+ return;
+ }
+
+ opts.peopleLeftMode.value = "contacts";
+ opts.focusedCalendarEventId.value = "";
+ syncPathFromUi(push);
+ }
+
+ // ---------------------------------------------------------------------------
+ // Lifecycle init / cleanup
+ // ---------------------------------------------------------------------------
+ function initRouting() {
+ uiPathSyncLocked.value = true;
+ try {
+ const params = new URLSearchParams(window.location.search);
+ const tgLinkToken = String(params.get("tg_link_token") ?? "").trim();
+ if (tgLinkToken) {
+ void opts.completeTelegramBusinessConnectFromToken(tgLinkToken);
+ params.delete("tg_link_token");
+ const nextSearch = params.toString();
+ window.history.replaceState({}, "", `${window.location.pathname}${nextSearch ? `?${nextSearch}` : ""}`);
+ }
+ applyPathToUi(window.location.pathname, window.location.search);
+ } finally {
+ uiPathSyncLocked.value = false;
+ }
+ syncPathFromUi(false);
+
+ popstateHandler = () => {
+ uiPathSyncLocked.value = true;
+ try {
+ applyPathToUi(window.location.pathname, window.location.search);
+ } finally {
+ uiPathSyncLocked.value = false;
+ }
+ };
+ window.addEventListener("popstate", popstateHandler);
+ }
+
+ function cleanupRouting() {
+ if (popstateHandler) {
+ window.removeEventListener("popstate", popstateHandler);
+ popstateHandler = null;
+ }
+ }
+
+ return {
+ uiPathSyncLocked,
+ currentUiPath,
+ applyPathToUi,
+ syncPathFromUi,
+ applyReviewStepToUi,
+ withReviewQuery,
+ initRouting,
+ cleanupRouting,
+ };
+}