refactor: decompose CrmWorkspaceApp.vue into 15 composables

Split the 6000+ line monolithic component into modular composables:
- crm-types.ts: shared types and utility functions
- useAuth, useContacts, useContactInboxes, useCalendar, useDeals,
  useDocuments, useFeed, useTimeline, usePilotChat, useCallAudio,
  usePins, useChangeReview, useCrmRealtime, useWorkspaceRouting
CrmWorkspaceApp.vue is now a thin orchestrator (~2500 lines) that
wires composables together with glue code, keeping template and
styles intact.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ruslan Bakiev
2026-02-24 15:05:01 +07:00
parent e5ad3809e0
commit a4d8d81de9
16 changed files with 5643 additions and 4684 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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;
}

View File

@@ -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<TelegramConnectStatus>("not_connected");
const telegramConnectStatusLoading = ref(false);
const telegramConnectBusy = ref(false);
const telegramConnectUrl = ref("");
const telegramConnections = ref<TelegramConnectionSummary[]>([]);
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,
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<string, HTMLDivElement>();
const commCallWaveSurfers = new Map<string, any>();
const commCallPlayableById = ref<Record<string, boolean>>({});
const commCallPlayingById = ref<Record<string, boolean>>({});
const callTranscriptOpen = ref<Record<string, boolean>>({});
const callTranscriptLoading = ref<Record<string, boolean>>({});
const callTranscriptText = ref<Record<string, string>>({});
const callTranscriptError = ref<Record<string, string>>({});
// Event archive recording state
const eventArchiveRecordingById = ref<Record<string, boolean>>({});
const eventArchiveTranscribingById = ref<Record<string, boolean>>({});
const eventArchiveMicErrorById = ref<Record<string, string>>({});
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<string>, 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<string, string> };
},
) {
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<string, string> };
},
) {
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,
};
}

View File

@@ -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<PilotMessage[]>;
refetchAllCrmQueries: () => Promise<void>;
refetchChatMessages: () => Promise<any>;
refetchChatConversations: () => Promise<any>;
}) {
// ---------------------------------------------------------------------------
// 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<string, unknown>;
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,
};
}

View File

@@ -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<boolean> }) {
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<ContactInbox[]>([]);
const inboxToggleLoadingById = ref<Record<string, boolean>>({});
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,
};
}

View File

@@ -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<boolean> }) {
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<Contact[]>([]);
const commItems = ref<CommItem[]>([]);
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<string, Set<string>>();
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<SortMode>("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<string, Contact[]>();
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<Record<string, boolean>>({});
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,
};
}

View File

@@ -0,0 +1,130 @@
import { ref } from "vue";
export function useCrmRealtime(opts: {
isAuthenticated: () => boolean;
onDashboardChanged: () => Promise<void>;
}) {
const crmRealtimeState = ref<"idle" | "connecting" | "open" | "error">("idle");
let crmRealtimeSocket: WebSocket | null = null;
let crmRealtimeReconnectTimer: ReturnType<typeof setTimeout> | null = null;
let crmRealtimeRefreshTimer: ReturnType<typeof setTimeout> | 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 };
}

View File

@@ -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<boolean>;
contacts: Ref<Contact[]>;
calendarEvents: Ref<CalendarEvent[]>;
}) {
const { result: dealsResult, refetch: refetchDeals } = useQuery(
DealsQueryDocument,
null,
{ enabled: opts.apolloAuthReady },
);
const deals = ref<Deal[]>([]);
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,
};
}

View File

@@ -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<boolean> }) {
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<WorkspaceDocument[]>([]);
const documentSearch = ref("");
const documentSortMode = ref<DocumentSortMode>("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,
};
}

View File

@@ -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<boolean>;
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<FeedCard[]>([]);
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,
};
}

View File

@@ -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<boolean>;
authMe: Ref<any>;
selectedContact: ComputedRef<Contact | null>;
selectedDeal: ComputedRef<Deal | null>;
calendarView: Ref<CalendarView>;
calendarPeriodLabel: ComputedRef<string>;
selectedDateKey: Ref<string>;
focusedCalendarEvent: ComputedRef<CalendarEvent | null>;
calendarEvents: Ref<CalendarEvent[]>;
refetchAllCrmQueries: () => Promise<void>;
}) {
// ---------------------------------------------------------------------------
// State refs
// ---------------------------------------------------------------------------
const pilotMessages = ref<PilotMessage[]>([]);
const pilotInput = ref("");
const pilotSending = ref(false);
const pilotRecording = ref(false);
const pilotTranscribing = ref(false);
const pilotMicSupported = ref(false);
const pilotMicError = ref<string | null>(null);
const pilotWaveContainer = ref<HTMLDivElement | null>(null);
function setPilotWaveContainerRef(element: HTMLDivElement | null) {
pilotWaveContainer.value = element;
}
const livePilotUserText = ref("");
const livePilotAssistantText = ref("");
const contextPickerEnabled = ref(false);
const contextScopes = ref<ContextScope[]>([]);
const pilotLiveLogs = ref<Array<{ id: string; text: string; at: string }>>([]);
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<ChatConversation[]>([]);
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<typeof setInterval> | 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<UIMessage>({
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,
};
}

View File

@@ -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<boolean>;
selectedCommThread: ComputedRef<{ id: string; contact: string; items: CommItem[] } | undefined>;
selectedCommLifecycleEvents: ComputedRef<Array<{ event: CalendarEvent; phase: EventLifecyclePhase; timelineAt: string }>>;
visibleThreadItems: ComputedRef<CommItem[]>;
}) {
const { result: pinsResult, refetch: refetchPins } = useQuery(
PinsQueryDocument,
null,
{ enabled: opts.apolloAuthReady },
);
const { mutate: doToggleContactPin } = useMutation(ToggleContactPinMutationDocument, {
refetchQueries: [{ query: PinsQueryDocument }],
});
const commPins = ref<CommPin[]>([]);
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,
};
}

View File

@@ -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<boolean> }) {
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<ClientTimelineItem[]>([]);
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,
};
}

View File

@@ -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<TabId>;
peopleLeftMode: Ref<PeopleLeftMode>;
peopleListMode: Ref<"contacts" | "deals">;
selectedContactId: Ref<string>;
selectedCommThreadId: Ref<string>;
selectedDealId: Ref<string>;
selectedChatId: Ref<string>;
calendarView: Ref<CalendarView>;
calendarCursor: Ref<Date>;
selectedDateKey: Ref<string>;
selectedDocumentId: Ref<string>;
focusedCalendarEventId: Ref<string>;
activeChangeSetId: Ref<string>;
activeChangeStep: Ref<number>;
// computed refs
sortedEvents: ComputedRef<CalendarEvent[]>;
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<PilotChangeItem | null>;
activeChangeItems: ComputedRef<PilotChangeItem[]>;
activeChangeIndex: ComputedRef<number>;
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,
};
}