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:
File diff suppressed because it is too large
Load Diff
373
frontend/app/composables/crm-types.ts
Normal file
373
frontend/app/composables/crm-types.ts
Normal 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;
|
||||
}
|
||||
205
frontend/app/composables/useAuth.ts
Normal file
205
frontend/app/composables/useAuth.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
1129
frontend/app/composables/useCalendar.ts
Normal file
1129
frontend/app/composables/useCalendar.ts
Normal file
File diff suppressed because it is too large
Load Diff
436
frontend/app/composables/useCallAudio.ts
Normal file
436
frontend/app/composables/useCallAudio.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
305
frontend/app/composables/useChangeReview.ts
Normal file
305
frontend/app/composables/useChangeReview.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
85
frontend/app/composables/useContactInboxes.ts
Normal file
85
frontend/app/composables/useContactInboxes.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
158
frontend/app/composables/useContacts.ts
Normal file
158
frontend/app/composables/useContacts.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
130
frontend/app/composables/useCrmRealtime.ts
Normal file
130
frontend/app/composables/useCrmRealtime.ts
Normal 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 };
|
||||
}
|
||||
200
frontend/app/composables/useDeals.ts
Normal file
200
frontend/app/composables/useDeals.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
189
frontend/app/composables/useDocuments.ts
Normal file
189
frontend/app/composables/useDocuments.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
157
frontend/app/composables/useFeed.ts
Normal file
157
frontend/app/composables/useFeed.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
626
frontend/app/composables/usePilotChat.ts
Normal file
626
frontend/app/composables/usePilotChat.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
222
frontend/app/composables/usePins.ts
Normal file
222
frontend/app/composables/usePins.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
52
frontend/app/composables/useTimeline.ts
Normal file
52
frontend/app/composables/useTimeline.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
404
frontend/app/composables/useWorkspaceRouting.ts
Normal file
404
frontend/app/composables/useWorkspaceRouting.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user