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