Replace custom gqlFetch() with proper Apollo useQuery/useMutation hooks powered by codegen-generated TypedDocumentNode types. Key changes: - Add GraphQL SDL schema file and codegen config for typescript-vue-apollo - Replace all 28 raw .graphql imports with generated typed documents - Add 12 useQuery() hooks with cache-and-network fetch policy - Add 17 useMutation() hooks with surgical refetchQueries per mutation - Optimistic cache update for setContactInboxHidden (instant archive UX) - Fix contact list subtitle: show lastText instead of channel name - Migrate login page from gqlFetch to useMutation - WebSocket realtime now calls Apollo refetch instead of full data reload Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
6007 lines
212 KiB
Vue
6007 lines
212 KiB
Vue
<script setup lang="ts">
|
||
import { nextTick, onBeforeUnmount, onMounted } from "vue";
|
||
import CrmAuthLoading from "~~/app/components/workspace/auth/CrmAuthLoading.vue";
|
||
import CrmCalendarPanel from "~~/app/components/workspace/calendar/CrmCalendarPanel.vue";
|
||
import CrmCommunicationsContextSidebar from "~~/app/components/workspace/communications/CrmCommunicationsContextSidebar.vue";
|
||
import CrmCommunicationsListSidebar from "~~/app/components/workspace/communications/CrmCommunicationsListSidebar.vue";
|
||
import CrmVoiceDictationButton from "~~/app/components/workspace/communications/CrmVoiceDictationButton.client.vue";
|
||
import CrmDocumentsPanel from "~~/app/components/workspace/documents/CrmDocumentsPanel.vue";
|
||
import CrmWorkspaceTopbar from "~~/app/components/workspace/header/CrmWorkspaceTopbar.vue";
|
||
import CrmPilotSidebar from "~~/app/components/workspace/pilot/CrmPilotSidebar.vue";
|
||
import CrmChangeReviewOverlay from "~~/app/components/workspace/review/CrmChangeReviewOverlay.vue";
|
||
import { useQuery, useMutation } from "@vue/apollo-composable";
|
||
import {
|
||
MeQueryDocument,
|
||
ChatMessagesQueryDocument,
|
||
ChatConversationsQueryDocument,
|
||
ContactsQueryDocument,
|
||
CommunicationsQueryDocument,
|
||
ContactInboxesQueryDocument,
|
||
CalendarQueryDocument,
|
||
DealsQueryDocument,
|
||
FeedQueryDocument,
|
||
PinsQueryDocument,
|
||
DocumentsQueryDocument,
|
||
GetClientTimelineQueryDocument,
|
||
LogoutMutationDocument,
|
||
LogPilotNoteMutationDocument,
|
||
CreateCalendarEventMutationDocument,
|
||
ArchiveCalendarEventMutationDocument,
|
||
CreateCommunicationMutationDocument,
|
||
CreateWorkspaceDocumentDocument,
|
||
DeleteWorkspaceDocumentDocument,
|
||
UpdateCommunicationTranscriptMutationDocument,
|
||
UpdateFeedDecisionMutationDocument,
|
||
CreateChatConversationMutationDocument,
|
||
SelectChatConversationMutationDocument,
|
||
ArchiveChatConversationMutationDocument,
|
||
ToggleContactPinMutationDocument,
|
||
SetContactInboxHiddenDocument,
|
||
ConfirmLatestChangeSetMutationDocument,
|
||
RollbackLatestChangeSetMutationDocument,
|
||
RollbackChangeSetItemsMutationDocument,
|
||
} from "~~/graphql/generated";
|
||
import {
|
||
buildContactDocumentScope,
|
||
formatDocumentScope,
|
||
isDocumentLinkedToContact,
|
||
} from "~~/app/composables/useWorkspaceDocuments";
|
||
import { isVoiceCaptureSupported, transcribeAudioBlob } from "~~/app/composables/useVoiceTranscription";
|
||
import { Chat as AiChat } from "@ai-sdk/vue";
|
||
import { DefaultChatTransport, isTextUIPart, type UIMessage } from "ai";
|
||
type TabId = "communications" | "documents";
|
||
type CalendarView = "day" | "week" | "month" | "year" | "agenda";
|
||
type SortMode = "name" | "lastContact";
|
||
type PeopleLeftMode = "contacts" | "calendar";
|
||
type PeopleSortMode = "name" | "lastContact";
|
||
type PeopleVisibilityMode = "all" | "hidden";
|
||
type DocumentSortMode = "updatedAt" | "title" | "owner";
|
||
|
||
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;
|
||
};
|
||
|
||
type Contact = {
|
||
id: string;
|
||
name: string;
|
||
avatar: string;
|
||
channels: string[];
|
||
lastContactAt: string;
|
||
description: string;
|
||
};
|
||
|
||
type CalendarEvent = {
|
||
id: string;
|
||
title: string;
|
||
start: string;
|
||
end: string;
|
||
contact: string;
|
||
note: string;
|
||
isArchived: boolean;
|
||
createdAt: string;
|
||
archiveNote: string;
|
||
archivedAt: string;
|
||
};
|
||
|
||
type EventLifecyclePhase = "scheduled" | "due_soon" | "awaiting_outcome" | "closed";
|
||
|
||
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;
|
||
};
|
||
|
||
type ContactInbox = {
|
||
id: string;
|
||
contactId: string;
|
||
contactName: string;
|
||
channel: CommItem["channel"];
|
||
sourceExternalId: string;
|
||
title: string;
|
||
isHidden: boolean;
|
||
lastMessageAt: string;
|
||
updatedAt: string;
|
||
};
|
||
|
||
type CommPin = {
|
||
id: string;
|
||
contact: string;
|
||
text: string;
|
||
};
|
||
|
||
type Deal = {
|
||
id: string;
|
||
contact: string;
|
||
title: string;
|
||
stage: string;
|
||
amount: string;
|
||
nextStep: string;
|
||
summary: string;
|
||
currentStepId: string;
|
||
steps: DealStep[];
|
||
};
|
||
|
||
type DealStep = {
|
||
id: string;
|
||
title: string;
|
||
description: string;
|
||
status: "todo" | "in_progress" | "done" | "blocked" | string;
|
||
dueAt: string;
|
||
order: number;
|
||
completedAt: string;
|
||
};
|
||
|
||
type WorkspaceDocument = {
|
||
id: string;
|
||
title: string;
|
||
type: "Regulation" | "Playbook" | "Policy" | "Template";
|
||
owner: string;
|
||
scope: string;
|
||
updatedAt: string;
|
||
summary: string;
|
||
body: string;
|
||
};
|
||
|
||
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;
|
||
};
|
||
|
||
const selectedTab = ref<TabId>("communications");
|
||
const peopleLeftMode = ref<PeopleLeftMode>("contacts");
|
||
|
||
function safeTrim(value: unknown) {
|
||
return String(value ?? "").trim();
|
||
}
|
||
|
||
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}`;
|
||
}
|
||
|
||
function formatDay(iso: string) {
|
||
return new Intl.DateTimeFormat("en-GB", {
|
||
day: "2-digit",
|
||
month: "short",
|
||
year: "numeric",
|
||
}).format(new Date(iso));
|
||
}
|
||
|
||
function formatTime(iso: string) {
|
||
return new Intl.DateTimeFormat("en-GB", {
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
}).format(new Date(iso));
|
||
}
|
||
|
||
function formatThreadTime(iso: string) {
|
||
return new Intl.DateTimeFormat("en-GB", {
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
hour12: false,
|
||
})
|
||
.format(new Date(iso))
|
||
.replace(":", ".");
|
||
}
|
||
|
||
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));
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
function inMinutes(minutes: number) {
|
||
const d = new Date();
|
||
d.setMinutes(d.getMinutes() + minutes, 0, 0);
|
||
return d.toISOString();
|
||
}
|
||
|
||
function endAfter(startIso: string, minutes: number) {
|
||
const d = new Date(startIso);
|
||
d.setMinutes(d.getMinutes() + minutes);
|
||
return d.toISOString();
|
||
}
|
||
|
||
function isEventFinalStatus(isArchived: boolean) {
|
||
return Boolean(isArchived);
|
||
}
|
||
|
||
function eventPreDueAt(event: CalendarEvent) {
|
||
return new Date(new Date(event.start).getTime() - 30 * 60 * 1000).toISOString();
|
||
}
|
||
|
||
function eventDueAt(event: CalendarEvent) {
|
||
return event.start;
|
||
}
|
||
|
||
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";
|
||
}
|
||
|
||
function eventTimelineAt(event: CalendarEvent, phase: EventLifecyclePhase) {
|
||
if (phase === "scheduled") return event.createdAt || event.start;
|
||
if (phase === "due_soon") return eventPreDueAt(event);
|
||
return eventDueAt(event);
|
||
}
|
||
|
||
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"}`;
|
||
}
|
||
|
||
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";
|
||
}
|
||
|
||
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}`;
|
||
}
|
||
|
||
function toInputTime(date: Date) {
|
||
const hh = String(date.getHours()).padStart(2, "0");
|
||
const mm = String(date.getMinutes()).padStart(2, "0");
|
||
return `${hh}:${mm}`;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
const feedCards = ref<FeedCard[]>([]);
|
||
|
||
const contacts = ref<Contact[]>([]);
|
||
|
||
const calendarEvents = ref<CalendarEvent[]>([]);
|
||
|
||
const commItems = ref<CommItem[]>([]);
|
||
const contactInboxes = ref<ContactInbox[]>([]);
|
||
const clientTimelineItems = ref<ClientTimelineItem[]>([]);
|
||
|
||
const commPins = ref<CommPin[]>([]);
|
||
|
||
const deals = ref<Deal[]>([]);
|
||
|
||
const documents = ref<WorkspaceDocument[]>([]);
|
||
|
||
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?: Array<{
|
||
id: string;
|
||
entity: string;
|
||
entityId?: string | null;
|
||
action: string;
|
||
title: string;
|
||
before: string;
|
||
after: string;
|
||
rolledBack?: boolean;
|
||
}> | null;
|
||
createdAt?: string;
|
||
_live?: boolean;
|
||
};
|
||
|
||
type PilotChangeItem = NonNullable<PilotMessage["changeItems"]>[number];
|
||
type ContextScope = "summary" | "deal" | "message" | "calendar";
|
||
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[];
|
||
};
|
||
};
|
||
|
||
type ChatConversation = {
|
||
id: string;
|
||
title: string;
|
||
createdAt: string;
|
||
updatedAt: string;
|
||
lastMessageAt?: string | null;
|
||
lastMessageText?: string | null;
|
||
};
|
||
|
||
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),
|
||
);
|
||
function togglePilotLiveLogsExpanded() {
|
||
pilotLiveLogsExpanded.value = !pilotLiveLogsExpanded.value;
|
||
}
|
||
|
||
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);
|
||
}
|
||
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;
|
||
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>>({});
|
||
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(), refetchAllCrmQueries()]);
|
||
},
|
||
onError: () => {
|
||
if (livePilotUserText.value) {
|
||
pilotInput.value = livePilotUserText.value;
|
||
}
|
||
livePilotUserText.value = "";
|
||
livePilotAssistantText.value = "";
|
||
pilotLiveLogs.value = [];
|
||
},
|
||
});
|
||
const authMe = ref<{
|
||
user: { id: string; phone: string; name: string };
|
||
team: { id: string; name: string };
|
||
conversation: { id: string; title: string };
|
||
} | null>(
|
||
null,
|
||
);
|
||
const chatConversations = ref<ChatConversation[]>([]);
|
||
const chatThreadsLoading = ref(false);
|
||
const chatSwitching = ref(false);
|
||
const chatCreating = ref(false);
|
||
const chatArchivingId = ref("");
|
||
const chatThreadPickerOpen = ref(false);
|
||
const commPinToggling = ref(false);
|
||
const selectedChatId = ref("");
|
||
let pilotBackgroundPoll: ReturnType<typeof setInterval> | null = null;
|
||
const lifecycleNowMs = ref(Date.now());
|
||
let lifecycleClock: ReturnType<typeof setInterval> | null = null;
|
||
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;
|
||
let clientTimelineRequestToken = 0;
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Apollo Queries
|
||
// ---------------------------------------------------------------------------
|
||
const apolloAuthReady = computed(() => !!authMe.value);
|
||
|
||
const { result: meResult, refetch: refetchMe, loading: meLoading } = useQuery(
|
||
MeQueryDocument,
|
||
null,
|
||
{ fetchPolicy: "network-only" },
|
||
);
|
||
|
||
const { result: chatMessagesResult, refetch: refetchChatMessages } = useQuery(
|
||
ChatMessagesQueryDocument,
|
||
null,
|
||
{ enabled: apolloAuthReady },
|
||
);
|
||
|
||
const { result: chatConversationsResult, refetch: refetchChatConversations } = useQuery(
|
||
ChatConversationsQueryDocument,
|
||
null,
|
||
{ enabled: apolloAuthReady },
|
||
);
|
||
|
||
const { result: contactsResult, refetch: refetchContacts } = useQuery(
|
||
ContactsQueryDocument,
|
||
null,
|
||
{ enabled: apolloAuthReady },
|
||
);
|
||
|
||
const { result: communicationsResult, refetch: refetchCommunications } = useQuery(
|
||
CommunicationsQueryDocument,
|
||
null,
|
||
{ enabled: apolloAuthReady },
|
||
);
|
||
|
||
const { result: contactInboxesResult, refetch: refetchContactInboxes } = useQuery(
|
||
ContactInboxesQueryDocument,
|
||
null,
|
||
{ enabled: apolloAuthReady },
|
||
);
|
||
|
||
const { result: calendarResult, refetch: refetchCalendar } = useQuery(
|
||
CalendarQueryDocument,
|
||
null,
|
||
{ enabled: apolloAuthReady },
|
||
);
|
||
|
||
const { result: dealsResult, refetch: refetchDeals } = useQuery(
|
||
DealsQueryDocument,
|
||
null,
|
||
{ enabled: apolloAuthReady },
|
||
);
|
||
|
||
const { result: feedResult, refetch: refetchFeed } = useQuery(
|
||
FeedQueryDocument,
|
||
null,
|
||
{ enabled: apolloAuthReady },
|
||
);
|
||
|
||
const { result: pinsResult, refetch: refetchPins } = useQuery(
|
||
PinsQueryDocument,
|
||
null,
|
||
{ enabled: apolloAuthReady },
|
||
);
|
||
|
||
const { result: documentsResult, refetch: refetchDocuments } = useQuery(
|
||
DocumentsQueryDocument,
|
||
null,
|
||
{ enabled: apolloAuthReady },
|
||
);
|
||
|
||
const timelineContactId = ref("");
|
||
const timelineLimit = ref(500);
|
||
|
||
const { result: timelineResult, refetch: refetchTimeline } = useQuery(
|
||
GetClientTimelineQueryDocument,
|
||
() => ({ contactId: timelineContactId.value, limit: timelineLimit.value }),
|
||
{ enabled: computed(() => !!timelineContactId.value && apolloAuthReady.value) },
|
||
);
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Apollo Mutations
|
||
// ---------------------------------------------------------------------------
|
||
const allCrmQueryDocs = [
|
||
{ query: ContactsQueryDocument },
|
||
{ query: CommunicationsQueryDocument },
|
||
{ query: ContactInboxesQueryDocument },
|
||
{ query: CalendarQueryDocument },
|
||
{ query: DealsQueryDocument },
|
||
{ query: FeedQueryDocument },
|
||
{ query: PinsQueryDocument },
|
||
{ query: DocumentsQueryDocument },
|
||
];
|
||
|
||
const { mutate: doLogout } = useMutation(LogoutMutationDocument);
|
||
const { mutate: doLogPilotNote } = useMutation(LogPilotNoteMutationDocument);
|
||
const { mutate: doCreateCalendarEvent } = useMutation(CreateCalendarEventMutationDocument, {
|
||
refetchQueries: [{ query: CalendarQueryDocument }],
|
||
});
|
||
const { mutate: doArchiveCalendarEvent } = useMutation(ArchiveCalendarEventMutationDocument, {
|
||
refetchQueries: [{ query: CalendarQueryDocument }],
|
||
});
|
||
const { mutate: doCreateCommunication } = useMutation(CreateCommunicationMutationDocument, {
|
||
refetchQueries: [{ query: CommunicationsQueryDocument }, { query: ContactInboxesQueryDocument }],
|
||
});
|
||
const { mutate: doCreateWorkspaceDocument } = useMutation(CreateWorkspaceDocumentDocument, {
|
||
refetchQueries: [{ query: DocumentsQueryDocument }],
|
||
});
|
||
const { mutate: doDeleteWorkspaceDocument } = useMutation(DeleteWorkspaceDocumentDocument, {
|
||
refetchQueries: [{ query: DocumentsQueryDocument }],
|
||
});
|
||
const { mutate: doUpdateCommunicationTranscript } = useMutation(UpdateCommunicationTranscriptMutationDocument, {
|
||
refetchQueries: [{ query: CommunicationsQueryDocument }],
|
||
});
|
||
const { mutate: doUpdateFeedDecision } = useMutation(UpdateFeedDecisionMutationDocument, {
|
||
refetchQueries: [{ query: FeedQueryDocument }],
|
||
});
|
||
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 }],
|
||
});
|
||
const { mutate: doToggleContactPin } = useMutation(ToggleContactPinMutationDocument, {
|
||
refetchQueries: [{ query: PinsQueryDocument }],
|
||
});
|
||
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 { 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],
|
||
});
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Apollo → Ref Watchers (bridge Apollo reactive results to existing refs)
|
||
// ---------------------------------------------------------------------------
|
||
function syncPilotChatFromHistoryBridge(messages: PilotMessage[]) {
|
||
// forward-declared; actual function is defined later
|
||
syncPilotChatFromHistory(messages);
|
||
}
|
||
|
||
watch(() => meResult.value?.me, (me) => {
|
||
if (me) authMe.value = me as typeof authMe.value;
|
||
}, { immediate: true });
|
||
|
||
watch(() => chatMessagesResult.value?.chatMessages, (v) => {
|
||
if (v) {
|
||
pilotMessages.value = v as PilotMessage[];
|
||
syncPilotChatFromHistoryBridge(pilotMessages.value);
|
||
}
|
||
}, { immediate: true });
|
||
|
||
watch(() => chatConversationsResult.value?.chatConversations, (v) => {
|
||
if (v) chatConversations.value = v as ChatConversation[];
|
||
}, { immediate: true });
|
||
|
||
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 },
|
||
);
|
||
|
||
watch(() => contactInboxesResult.value?.contactInboxes, (v) => {
|
||
if (v) contactInboxes.value = v as ContactInbox[];
|
||
}, { immediate: true });
|
||
|
||
watch(() => calendarResult.value?.calendar, (v) => {
|
||
if (v) calendarEvents.value = v as CalendarEvent[];
|
||
}, { immediate: true });
|
||
|
||
watch(() => dealsResult.value?.deals, (v) => {
|
||
if (v) deals.value = v as Deal[];
|
||
}, { immediate: true });
|
||
|
||
watch(() => feedResult.value?.feed, (v) => {
|
||
if (v) feedCards.value = v as FeedCard[];
|
||
}, { immediate: true });
|
||
|
||
watch(() => pinsResult.value?.pins, (v) => {
|
||
if (v) commPins.value = v as CommPin[];
|
||
}, { immediate: true });
|
||
|
||
watch(() => documentsResult.value?.documents, (v) => {
|
||
if (v) documents.value = v as WorkspaceDocument[];
|
||
}, { immediate: true });
|
||
|
||
watch(() => timelineResult.value?.getClientTimeline, (v) => {
|
||
if (v) clientTimelineItems.value = v as ClientTimelineItem[];
|
||
}, { immediate: true });
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Refetch helpers (replace old gqlFetch-based functions)
|
||
// ---------------------------------------------------------------------------
|
||
async function refetchAllCrmQueries() {
|
||
await Promise.all([
|
||
refetchContacts(),
|
||
refetchCommunications(),
|
||
refetchContactInboxes(),
|
||
refetchCalendar(),
|
||
refetchDeals(),
|
||
refetchFeed(),
|
||
refetchPins(),
|
||
refetchDocuments(),
|
||
]);
|
||
await refreshSelectedClientTimeline();
|
||
}
|
||
|
||
watch(
|
||
() => pilotLiveLogs.value.length,
|
||
(len) => {
|
||
if (len === 0 || len <= PILOT_LIVE_LOGS_PREVIEW_LIMIT) {
|
||
pilotLiveLogsExpanded.value = false;
|
||
}
|
||
},
|
||
);
|
||
|
||
watch(
|
||
() => authMe.value?.conversation.id,
|
||
(id) => {
|
||
if (id) selectedChatId.value = id;
|
||
},
|
||
{ immediate: true },
|
||
);
|
||
|
||
function pilotRoleName(role: PilotMessage["role"]) {
|
||
if (role === "user") return authMe.value?.user.name ?? "You";
|
||
if (role === "system") return "Agent status";
|
||
return "Pilot";
|
||
}
|
||
|
||
function pilotRoleBadge(role: PilotMessage["role"]) {
|
||
if (role === "user") return "You";
|
||
if (role === "system") return "...";
|
||
return "AI";
|
||
}
|
||
|
||
function summarizeChangeActions(items: PilotMessage["changeItems"] | null | undefined) {
|
||
const totals = { created: 0, updated: 0, deleted: 0 };
|
||
for (const item of items ?? []) {
|
||
if (item.action === "created") totals.created += 1;
|
||
else if (item.action === "updated") totals.updated += 1;
|
||
else if (item.action === "deleted") totals.deleted += 1;
|
||
}
|
||
return totals;
|
||
}
|
||
|
||
function summarizeChangeEntities(items: PilotMessage["changeItems"] | null | undefined) {
|
||
const map = new Map<string, number>();
|
||
for (const item of items ?? []) {
|
||
const key = item.entity || "unknown";
|
||
map.set(key, (map.get(key) ?? 0) + 1);
|
||
}
|
||
return [...map.entries()]
|
||
.map(([entity, count]) => ({ entity, count }))
|
||
.sort((a, b) => b.count - a.count);
|
||
}
|
||
|
||
function formatPilotStamp(iso?: string) {
|
||
if (!iso) return "";
|
||
return new Intl.DateTimeFormat("en-GB", {
|
||
day: "2-digit",
|
||
month: "short",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
}).format(new Date(iso));
|
||
}
|
||
|
||
function formatChatThreadMeta(conversation: ChatConversation) {
|
||
const when = conversation.lastMessageAt ?? conversation.updatedAt ?? conversation.createdAt;
|
||
if (!when) return "";
|
||
return new Intl.DateTimeFormat("en-GB", {
|
||
day: "2-digit",
|
||
month: "short",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
}).format(new Date(when));
|
||
}
|
||
|
||
function toggleChatThreadPicker() {
|
||
if (chatSwitching.value || chatThreadsLoading.value || chatConversations.value.length === 0) return;
|
||
chatThreadPickerOpen.value = !chatThreadPickerOpen.value;
|
||
}
|
||
|
||
function closeChatThreadPicker() {
|
||
chatThreadPickerOpen.value = false;
|
||
}
|
||
|
||
const authDisplayName = computed(() => authMe.value?.user.name ?? "User");
|
||
const authInitials = computed(() => {
|
||
const parts = authDisplayName.value
|
||
.trim()
|
||
.split(/\s+/)
|
||
.filter(Boolean)
|
||
.slice(0, 2);
|
||
if (parts.length === 0) return "U";
|
||
return parts.map((part) => part[0]?.toUpperCase() ?? "").join("");
|
||
});
|
||
|
||
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;
|
||
};
|
||
|
||
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";
|
||
});
|
||
|
||
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 = "Ошибка завершения привязки. Попробуйте снова.";
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
function normalizePilotTimeline(messages: PilotMessage[]) {
|
||
const sorted = [...messages].sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""));
|
||
const finalizedRequestIds = new Set(
|
||
sorted
|
||
.filter((m) => m.role === "assistant" && m.phase === "final" && m.requestId)
|
||
.map((m) => m.requestId as string),
|
||
);
|
||
const latestAssistantAt = [...sorted].reverse().find((m) => m.role === "assistant")?.createdAt ?? null;
|
||
const out: PilotMessage[] = [];
|
||
const traceIndexByRequestId = new Map<string, number>();
|
||
|
||
for (const message of sorted) {
|
||
const requestId = (message.requestId ?? "").trim();
|
||
const isTrace = message.role === "system" || message.eventType === "trace";
|
||
const isTransient = message.transient === true || isTrace;
|
||
|
||
if (isTransient) {
|
||
if (requestId && finalizedRequestIds.has(requestId)) {
|
||
continue;
|
||
}
|
||
if (!requestId && latestAssistantAt && (message.createdAt ?? "") <= latestAssistantAt) {
|
||
continue;
|
||
}
|
||
}
|
||
|
||
if (isTrace && requestId) {
|
||
const existingIdx = traceIndexByRequestId.get(requestId);
|
||
if (typeof existingIdx === "number") {
|
||
out[existingIdx] = message;
|
||
continue;
|
||
}
|
||
traceIndexByRequestId.set(requestId, out.length);
|
||
} else if (requestId) {
|
||
traceIndexByRequestId.delete(requestId);
|
||
}
|
||
|
||
out.push(message);
|
||
}
|
||
|
||
return out;
|
||
}
|
||
|
||
const renderedPilotMessages = computed<PilotMessage[]>(() => {
|
||
const items = normalizePilotTimeline(pilotMessages.value).filter((m) => m.role !== "system");
|
||
const hasPersistedLiveUser = items.some(
|
||
(m) => m.role === "user" && safeTrim(m.text) === livePilotUserText.value.trim(),
|
||
);
|
||
|
||
if (livePilotUserText.value && !hasPersistedLiveUser) {
|
||
items.push({
|
||
id: "pilot-live-user",
|
||
role: "user",
|
||
text: livePilotUserText.value,
|
||
createdAt: new Date().toISOString(),
|
||
_live: true,
|
||
});
|
||
}
|
||
|
||
if (livePilotAssistantText.value) {
|
||
items.push({
|
||
id: "pilot-live-assistant",
|
||
role: "assistant",
|
||
text: livePilotAssistantText.value,
|
||
createdAt: new Date().toISOString(),
|
||
_live: true,
|
||
});
|
||
}
|
||
|
||
return items;
|
||
});
|
||
|
||
async function loadPilotMessages() {
|
||
await refetchChatMessages();
|
||
}
|
||
|
||
async function loadChatConversations() {
|
||
chatThreadsLoading.value = true;
|
||
try {
|
||
await refetchChatConversations();
|
||
} finally {
|
||
chatThreadsLoading.value = false;
|
||
}
|
||
}
|
||
|
||
async function loadMe() {
|
||
const result = await refetchMe();
|
||
const me = result?.data?.me;
|
||
if (me) authMe.value = me as typeof authMe.value;
|
||
}
|
||
|
||
const authResolved = ref(false);
|
||
|
||
async function bootstrapSession() {
|
||
const resetAuthState = () => {
|
||
stopCrmRealtime();
|
||
authMe.value = null;
|
||
pilotMessages.value = [];
|
||
chatConversations.value = [];
|
||
clientTimelineItems.value = [];
|
||
telegramConnectStatus.value = "not_connected";
|
||
telegramConnections.value = [];
|
||
telegramConnectUrl.value = "";
|
||
};
|
||
|
||
try {
|
||
await loadMe();
|
||
if (!authMe.value) {
|
||
resetAuthState();
|
||
if (process.client) {
|
||
await navigateTo("/login", { replace: true });
|
||
}
|
||
return;
|
||
}
|
||
await Promise.all([loadPilotMessages(), loadChatConversations(), refreshCrmData(), loadTelegramConnectStatus()]);
|
||
if (process.client) {
|
||
startCrmRealtime();
|
||
}
|
||
} catch {
|
||
resetAuthState();
|
||
if (process.client) {
|
||
await navigateTo("/login", { replace: true });
|
||
}
|
||
} finally {
|
||
authResolved.value = true;
|
||
}
|
||
}
|
||
|
||
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 || 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 = "";
|
||
}
|
||
}
|
||
|
||
async function logout() {
|
||
await doLogout();
|
||
stopCrmRealtime();
|
||
stopPilotBackgroundPolling();
|
||
authMe.value = null;
|
||
pilotMessages.value = [];
|
||
livePilotUserText.value = "";
|
||
livePilotAssistantText.value = "";
|
||
pilotChat.messages = [];
|
||
chatConversations.value = [];
|
||
clientTimelineItems.value = [];
|
||
telegramConnectStatus.value = "not_connected";
|
||
telegramConnections.value = [];
|
||
telegramConnectUrl.value = "";
|
||
if (process.client) {
|
||
await navigateTo("/login", { replace: true });
|
||
}
|
||
}
|
||
|
||
async function refreshCrmData() {
|
||
await refetchAllCrmQueries();
|
||
}
|
||
|
||
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() {
|
||
const contactId = String(selectedCommThreadId.value ?? "").trim();
|
||
if (!contactId) {
|
||
clientTimelineItems.value = [];
|
||
return;
|
||
}
|
||
await loadClientTimeline(contactId);
|
||
}
|
||
|
||
function clearCrmRealtimeReconnectTimer() {
|
||
if (!crmRealtimeReconnectTimer) return;
|
||
clearTimeout(crmRealtimeReconnectTimer);
|
||
crmRealtimeReconnectTimer = null;
|
||
}
|
||
|
||
function clearCrmRealtimeRefreshTimer() {
|
||
if (!crmRealtimeRefreshTimer) return;
|
||
clearTimeout(crmRealtimeRefreshTimer);
|
||
crmRealtimeRefreshTimer = null;
|
||
}
|
||
|
||
async function runCrmRealtimeRefresh() {
|
||
if (!authMe.value || crmRealtimeRefreshInFlight) return;
|
||
crmRealtimeRefreshInFlight = true;
|
||
try {
|
||
await Promise.all([refetchAllCrmQueries(), loadTelegramConnectStatus()]);
|
||
} 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 || !authMe.value) 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 (!authMe.value) {
|
||
crmRealtimeState.value = "idle";
|
||
return;
|
||
}
|
||
crmRealtimeState.value = "error";
|
||
scheduleCrmRealtimeReconnect();
|
||
};
|
||
}
|
||
|
||
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(), refetchAllCrmQueries()]);
|
||
}
|
||
}
|
||
|
||
async function sendPilotMessage() {
|
||
await sendPilotText(pilotInput.value);
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
function setCommCallPlaying(itemId: string, value: boolean) {
|
||
commCallPlayingById.value = {
|
||
...commCallPlayingById.value,
|
||
[itemId]: value,
|
||
};
|
||
}
|
||
|
||
function isCommCallPlaying(itemId: string) {
|
||
return Boolean(commCallPlayingById.value[itemId]);
|
||
}
|
||
|
||
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 getCallAudioUrl(item?: CommItem) {
|
||
return String(item?.audioUrl ?? "").trim();
|
||
}
|
||
|
||
async function ensureCommCallWave(itemId: string) {
|
||
const host = commCallWaveHosts.get(itemId);
|
||
if (!host) return;
|
||
if (commCallWaveSurfers.has(itemId)) return;
|
||
|
||
const callItem = visibleThreadItems.value.find((item) => item.id === itemId && item.kind === "call");
|
||
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() {
|
||
await nextTick();
|
||
const activeCallIds = new Set(
|
||
threadStreamItems.value.filter((entry) => entry.kind === "call").map((entry: any) => entry.item.id as string),
|
||
);
|
||
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
function setCommCallWaveHost(itemId: string, element: Element | null) {
|
||
if (!(element instanceof HTMLDivElement)) {
|
||
commCallWaveHosts.delete(itemId);
|
||
destroyCommCallWave(itemId);
|
||
return;
|
||
}
|
||
commCallWaveHosts.set(itemId, element);
|
||
void ensureCommCallWave(itemId);
|
||
}
|
||
|
||
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 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();
|
||
}
|
||
}
|
||
|
||
function handlePilotSendAction() {
|
||
if (pilotRecording.value) {
|
||
stopPilotRecording("send");
|
||
return;
|
||
}
|
||
void sendPilotMessage();
|
||
}
|
||
|
||
function handlePilotComposerEnter(event: KeyboardEvent) {
|
||
if (event.shiftKey) return;
|
||
event.preventDefault();
|
||
handlePilotSendAction();
|
||
}
|
||
|
||
function startPilotBackgroundPolling() {
|
||
if (pilotBackgroundPoll) return;
|
||
pilotBackgroundPoll = setInterval(() => {
|
||
if (!authMe.value) return;
|
||
loadPilotMessages().catch(() => {});
|
||
}, 2000);
|
||
}
|
||
|
||
function stopPilotBackgroundPolling() {
|
||
if (!pilotBackgroundPoll) return;
|
||
clearInterval(pilotBackgroundPoll);
|
||
pilotBackgroundPoll = null;
|
||
}
|
||
|
||
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 ?? "";
|
||
});
|
||
|
||
const changeActionBusy = ref(false);
|
||
const activeChangeSetId = ref("");
|
||
const activeChangeStep = ref(0);
|
||
const focusedCalendarEventId = ref("");
|
||
const uiPathSyncLocked = ref(false);
|
||
let popstateHandler: (() => void) | null = null;
|
||
const pilotHeaderPhrases = [
|
||
"Every step moves you forward",
|
||
"Focus first, results follow",
|
||
"Break down hard things into simple moves",
|
||
"Finish what matters today",
|
||
"Less noise, more action",
|
||
"Systems beat chaos",
|
||
"Important before urgent",
|
||
"The best moment to start is now",
|
||
];
|
||
const pilotHeaderText = ref("Every step moves you forward");
|
||
|
||
const latestChangeMessage = computed(() => {
|
||
return (
|
||
[...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 (
|
||
[...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 activeChangeStepNumber = computed(() => activeChangeIndex.value + 1);
|
||
const rollbackableCount = computed(() => activeChangeItems.value.filter((item) => !item.rolledBack).length);
|
||
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),
|
||
};
|
||
});
|
||
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";
|
||
}
|
||
|
||
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 selectedDateKey.value;
|
||
}
|
||
if (view === "year") {
|
||
return String(calendarCursor.value.getFullYear());
|
||
}
|
||
return calendarCursorToken(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;
|
||
}
|
||
|
||
function normalizedConversationId() {
|
||
return safeTrim(selectedChatId.value || authMe.value?.conversation.id || "pilot");
|
||
}
|
||
|
||
function currentUiPath() {
|
||
if (selectedTab.value === "documents") {
|
||
const docId = selectedDocumentId.value.trim();
|
||
if (docId) {
|
||
return withReviewQuery(`/documents/${encodeURIComponent(docId)}`);
|
||
}
|
||
return withReviewQuery("/documents");
|
||
}
|
||
|
||
if (peopleLeftMode.value === "calendar") {
|
||
if (focusedCalendarEventId.value.trim()) {
|
||
return withReviewQuery(`/calendar/event/${encodeURIComponent(focusedCalendarEventId.value.trim())}`);
|
||
}
|
||
return withReviewQuery(`/calendar/${encodeURIComponent(calendarView.value)}/${encodeURIComponent(calendarRouteToken(calendarView.value))}`);
|
||
}
|
||
|
||
if (peopleListMode.value === "deals" && selectedDealId.value.trim()) {
|
||
return withReviewQuery(`/deal/${encodeURIComponent(selectedDealId.value.trim())}`);
|
||
}
|
||
|
||
if (selectedContactId.value.trim()) {
|
||
return withReviewQuery(`/contact/${encodeURIComponent(selectedContactId.value.trim())}`);
|
||
}
|
||
|
||
return withReviewQuery(`/chat/${encodeURIComponent(normalizedConversationId())}`);
|
||
}
|
||
|
||
function withReviewQuery(path: string) {
|
||
const reviewSet = activeChangeSetId.value.trim();
|
||
if (!reviewSet) return path;
|
||
const params = new URLSearchParams();
|
||
params.set("reviewSet", reviewSet);
|
||
params.set("reviewStep", String(Math.max(1, activeChangeStep.value + 1)));
|
||
return `${path}?${params.toString()}`;
|
||
}
|
||
|
||
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 setPeopleLeftMode(mode: PeopleLeftMode, push = false) {
|
||
selectedTab.value = "communications";
|
||
peopleLeftMode.value = mode;
|
||
focusedCalendarEventId.value = "";
|
||
syncPathFromUi(push);
|
||
}
|
||
|
||
function openChangeReview(changeSetId: string, step = 0, push = true) {
|
||
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;
|
||
applyReviewStepToUi(push);
|
||
}
|
||
|
||
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) {
|
||
activeChangeSetId.value = reviewSet;
|
||
activeChangeStep.value = Number.isFinite(reviewStep) && reviewStep > 0 ? reviewStep - 1 : 0;
|
||
} else {
|
||
activeChangeSetId.value = "";
|
||
activeChangeStep.value = 0;
|
||
}
|
||
|
||
const calendarEventMatch = path.match(/^\/calendar\/event\/([^/]+)\/?$/i);
|
||
if (calendarEventMatch) {
|
||
const rawEventId = decodeURIComponent(calendarEventMatch[1] ?? "").trim();
|
||
selectedTab.value = "communications";
|
||
peopleLeftMode.value = "calendar";
|
||
const event = sortedEvents.value.find((x) => x.id === rawEventId);
|
||
if (event) {
|
||
pickDate(event.start.slice(0, 10));
|
||
}
|
||
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);
|
||
selectedTab.value = "communications";
|
||
peopleLeftMode.value = "calendar";
|
||
focusedCalendarEventId.value = "";
|
||
calendarView.value = view;
|
||
if (view === "day" || view === "week") {
|
||
const parsed = cursorByDate;
|
||
if (parsed) {
|
||
selectedDateKey.value = dayKey(parsed);
|
||
calendarCursor.value = new Date(parsed.getFullYear(), parsed.getMonth(), 1);
|
||
}
|
||
} else if (view === "year") {
|
||
if (cursorByYear) {
|
||
calendarCursor.value = new Date(cursorByYear, 0, 1);
|
||
selectedDateKey.value = dayKey(new Date(cursorByYear, 0, 1));
|
||
}
|
||
} else if (cursorByMonth) {
|
||
calendarCursor.value = cursorByMonth;
|
||
selectedDateKey.value = dayKey(cursorByMonth);
|
||
}
|
||
return;
|
||
}
|
||
|
||
const documentsMatch = path.match(/^\/documents(?:\/([^/]+))?\/?$/i);
|
||
if (documentsMatch) {
|
||
const rawDocumentId = decodeURIComponent(documentsMatch[1] ?? "").trim();
|
||
selectedTab.value = "documents";
|
||
focusedCalendarEventId.value = "";
|
||
if (rawDocumentId) selectedDocumentId.value = rawDocumentId;
|
||
return;
|
||
}
|
||
|
||
const contactMatch = path.match(/^\/contact\/([^/]+)\/?$/i);
|
||
if (contactMatch) {
|
||
const rawContactId = decodeURIComponent(contactMatch[1] ?? "").trim();
|
||
selectedTab.value = "communications";
|
||
peopleLeftMode.value = "contacts";
|
||
peopleListMode.value = "contacts";
|
||
if (rawContactId) {
|
||
selectedContactId.value = rawContactId;
|
||
const linkedThread = commThreads.value.find((thread) => thread.id === rawContactId);
|
||
if (linkedThread) selectedCommThreadId.value = linkedThread.id;
|
||
}
|
||
focusedCalendarEventId.value = "";
|
||
return;
|
||
}
|
||
|
||
const dealMatch = path.match(/^\/deal\/([^/]+)\/?$/i);
|
||
if (dealMatch) {
|
||
const rawDealId = decodeURIComponent(dealMatch[1] ?? "").trim();
|
||
selectedTab.value = "communications";
|
||
peopleLeftMode.value = "contacts";
|
||
peopleListMode.value = "deals";
|
||
if (rawDealId) {
|
||
selectedDealId.value = rawDealId;
|
||
const linkedDeal = deals.value.find((deal) => deal.id === rawDealId);
|
||
const linkedContact = linkedDeal
|
||
? contacts.value.find((contact) => contact.name === linkedDeal.contact)
|
||
: null;
|
||
if (linkedContact) {
|
||
selectedContactId.value = linkedContact.id;
|
||
selectedCommThreadId.value = linkedContact.id;
|
||
}
|
||
}
|
||
focusedCalendarEventId.value = "";
|
||
return;
|
||
}
|
||
|
||
const chatMatch = path.match(/^\/chat\/([^/]+)\/?$/i);
|
||
if (chatMatch) {
|
||
const rawChatId = decodeURIComponent(chatMatch[1] ?? "").trim();
|
||
selectedTab.value = "communications";
|
||
peopleLeftMode.value = "contacts";
|
||
peopleListMode.value = "contacts";
|
||
focusedCalendarEventId.value = "";
|
||
if (rawChatId) 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) {
|
||
activeChangeSetId.value = rawId;
|
||
activeChangeStep.value = Number.isFinite(rawStep) && rawStep > 0 ? rawStep - 1 : 0;
|
||
}
|
||
selectedTab.value = "communications";
|
||
peopleLeftMode.value = "contacts";
|
||
peopleListMode.value = "contacts";
|
||
focusedCalendarEventId.value = "";
|
||
return;
|
||
}
|
||
|
||
selectedTab.value = "communications";
|
||
peopleLeftMode.value = "contacts";
|
||
peopleListMode.value = "contacts";
|
||
focusedCalendarEventId.value = "";
|
||
}
|
||
|
||
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;
|
||
setPeopleLeftMode("contacts");
|
||
} 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;
|
||
}
|
||
}
|
||
|
||
function goToChangeStep(step: number) {
|
||
const items = activeChangeItems.value;
|
||
if (!items.length) return;
|
||
activeChangeStep.value = Math.max(0, Math.min(step, items.length - 1));
|
||
applyReviewStepToUi(true);
|
||
}
|
||
|
||
function goToPreviousChangeStep() {
|
||
goToChangeStep(activeChangeIndex.value - 1);
|
||
}
|
||
|
||
function goToNextChangeStep() {
|
||
goToChangeStep(activeChangeIndex.value + 1);
|
||
}
|
||
|
||
function openChangeItemTarget(item: PilotChangeItem) {
|
||
if (!item) return;
|
||
const idx = activeChangeItems.value.findIndex((candidate) => candidate.id === item.id);
|
||
if (idx >= 0) {
|
||
goToChangeStep(idx);
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
function applyReviewStepToUi(push = false) {
|
||
const item = activeChangeItem.value;
|
||
if (!item) {
|
||
syncPathFromUi(push);
|
||
return;
|
||
}
|
||
|
||
selectedTab.value = "communications";
|
||
|
||
if (item.entity === "calendar_event" && item.entityId) {
|
||
peopleLeftMode.value = "calendar";
|
||
calendarView.value = "month";
|
||
const event = sortedEvents.value.find((x) => x.id === item.entityId);
|
||
if (event) {
|
||
pickDate(event.start.slice(0, 10));
|
||
}
|
||
focusedCalendarEventId.value = item.entityId;
|
||
syncPathFromUi(push);
|
||
return;
|
||
}
|
||
|
||
if (item.entity === "contact_note" && item.entityId) {
|
||
peopleLeftMode.value = "contacts";
|
||
peopleListMode.value = "contacts";
|
||
selectedContactId.value = item.entityId;
|
||
const thread = commThreads.value.find((entry) => entry.id === item.entityId);
|
||
if (thread) selectedCommThreadId.value = thread.id;
|
||
focusedCalendarEventId.value = "";
|
||
syncPathFromUi(push);
|
||
return;
|
||
}
|
||
|
||
if (item.entity === "deal" && item.entityId) {
|
||
peopleLeftMode.value = "contacts";
|
||
peopleListMode.value = "deals";
|
||
selectedDealId.value = item.entityId;
|
||
const deal = deals.value.find((entry) => entry.id === item.entityId);
|
||
if (deal) {
|
||
const contact = contacts.value.find((entry) => entry.name === deal.contact);
|
||
if (contact) {
|
||
selectedContactId.value = contact.id;
|
||
selectedCommThreadId.value = contact.id;
|
||
}
|
||
}
|
||
focusedCalendarEventId.value = "";
|
||
syncPathFromUi(push);
|
||
return;
|
||
}
|
||
|
||
if (item.entity === "message" && item.entityId) {
|
||
peopleLeftMode.value = "contacts";
|
||
peopleListMode.value = "contacts";
|
||
const message = commItems.value.find((entry) => entry.id === item.entityId);
|
||
if (message?.contact) {
|
||
openCommunicationThread(message.contact);
|
||
}
|
||
focusedCalendarEventId.value = "";
|
||
syncPathFromUi(push);
|
||
return;
|
||
}
|
||
|
||
if (item.entity === "workspace_document" && item.entityId) {
|
||
selectedTab.value = "documents";
|
||
selectedDocumentId.value = item.entityId;
|
||
focusedCalendarEventId.value = "";
|
||
syncPathFromUi(push);
|
||
return;
|
||
}
|
||
|
||
peopleLeftMode.value = "contacts";
|
||
focusedCalendarEventId.value = "";
|
||
syncPathFromUi(push);
|
||
}
|
||
|
||
function finishReview(push = true) {
|
||
activeChangeSetId.value = "";
|
||
activeChangeStep.value = 0;
|
||
syncPathFromUi(push);
|
||
}
|
||
|
||
watch(
|
||
() => activeChangeMessage.value?.changeSetId,
|
||
() => {
|
||
if (!activeChangeSetId.value.trim()) return;
|
||
const maxIndex = Math.max(0, (activeChangeItems.value.length || 1) - 1);
|
||
if (activeChangeStep.value > maxIndex) activeChangeStep.value = maxIndex;
|
||
applyReviewStepToUi(false);
|
||
},
|
||
);
|
||
|
||
if (process.server) {
|
||
await bootstrapSession();
|
||
}
|
||
|
||
onMounted(() => {
|
||
pilotHeaderText.value = pilotHeaderPhrases[Math.floor(Math.random() * pilotHeaderPhrases.length)] ?? "Every step moves you forward";
|
||
pilotMicSupported.value = isVoiceCaptureSupported();
|
||
lifecycleClock = setInterval(() => {
|
||
lifecycleNowMs.value = Date.now();
|
||
}, 15000);
|
||
|
||
uiPathSyncLocked.value = true;
|
||
try {
|
||
const params = new URLSearchParams(window.location.search);
|
||
const tgLinkToken = String(params.get("tg_link_token") ?? "").trim();
|
||
if (tgLinkToken) {
|
||
void 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);
|
||
window.addEventListener("pointerdown", onWindowPointerDownForCommPinMenu);
|
||
window.addEventListener("keydown", onWindowKeyDownForCommPinMenu);
|
||
|
||
if (!authResolved.value) {
|
||
void bootstrapSession().finally(() => {
|
||
if (authMe.value) {
|
||
startPilotBackgroundPolling();
|
||
startCrmRealtime();
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (authMe.value) {
|
||
startPilotBackgroundPolling();
|
||
startCrmRealtime();
|
||
}
|
||
});
|
||
|
||
onBeforeUnmount(() => {
|
||
stopCrmRealtime();
|
||
if (pilotRecording.value) {
|
||
stopPilotRecording("fill");
|
||
}
|
||
stopEventArchiveRecording();
|
||
destroyAllCommCallWaves();
|
||
void stopPilotMeter();
|
||
if (pilotWaveSurfer) {
|
||
pilotWaveSurfer.destroy();
|
||
pilotWaveSurfer = null;
|
||
pilotWaveRecordPlugin = null;
|
||
}
|
||
if (pilotRecorderStream) {
|
||
pilotRecorderStream.getTracks().forEach((track) => track.stop());
|
||
pilotRecorderStream = null;
|
||
}
|
||
stopPilotBackgroundPolling();
|
||
if (popstateHandler) {
|
||
window.removeEventListener("popstate", popstateHandler);
|
||
popstateHandler = null;
|
||
}
|
||
window.removeEventListener("pointerdown", onWindowPointerDownForCommPinMenu);
|
||
window.removeEventListener("keydown", onWindowKeyDownForCommPinMenu);
|
||
if (lifecycleClock) {
|
||
clearInterval(lifecycleClock);
|
||
lifecycleClock = null;
|
||
}
|
||
if (calendarViewportResizeObserver) {
|
||
calendarViewportResizeObserver.disconnect();
|
||
calendarViewportResizeObserver = null;
|
||
}
|
||
clearCalendarZoomPrime();
|
||
});
|
||
|
||
const calendarView = ref<CalendarView>("year");
|
||
const calendarCursor = ref(new Date(new Date().getFullYear(), new Date().getMonth(), 1));
|
||
const selectedDateKey = ref(dayKey(new Date()));
|
||
|
||
const sortedEvents = computed(() => [...calendarEvents.value].sort((a, b) => a.start.localeCompare(b.start)));
|
||
const focusedCalendarEvent = computed(() => {
|
||
const id = (focusedCalendarEventId.value ?? "").trim();
|
||
if (!id) return null;
|
||
return sortedEvents.value.find((event) => event.id === id) ?? null;
|
||
});
|
||
|
||
const eventsByDate = computed(() => {
|
||
const map = new Map<string, CalendarEvent[]>();
|
||
|
||
for (const event of sortedEvents.value) {
|
||
const key = event.start.slice(0, 10);
|
||
if (!map.has(key)) {
|
||
map.set(key, []);
|
||
}
|
||
map.get(key)?.push(event);
|
||
}
|
||
|
||
return map;
|
||
});
|
||
|
||
function getEventsByDate(key: string) {
|
||
return eventsByDate.value.get(key) ?? [];
|
||
}
|
||
|
||
const monthLabel = computed(() =>
|
||
new Intl.DateTimeFormat("en-US", { month: "long", year: "numeric" }).format(calendarCursor.value),
|
||
);
|
||
|
||
const calendarViewOptions: { value: CalendarView; label: string }[] = [
|
||
{ value: "day", label: "Day" },
|
||
{ value: "week", label: "Week" },
|
||
{ value: "month", label: "Month" },
|
||
{ value: "year", label: "Year" },
|
||
{ value: "agenda", label: "Agenda" },
|
||
];
|
||
|
||
type CalendarHierarchyView = "year" | "month" | "week" | "day";
|
||
type CalendarRect = { left: number; top: number; width: number; height: number };
|
||
|
||
const calendarContentWrapRef = ref<HTMLElement | null>(null);
|
||
const calendarContentScrollRef = ref<HTMLElement | null>(null);
|
||
const calendarSceneRef = ref<HTMLElement | null>(null);
|
||
const calendarViewportHeight = ref(0);
|
||
const calendarHoveredMonthIndex = ref<number | null>(null);
|
||
const calendarHoveredWeekStartKey = ref("");
|
||
const calendarHoveredDayKey = ref("");
|
||
let calendarViewportResizeObserver: ResizeObserver | null = null;
|
||
|
||
function setCalendarContentWrapRef(element: HTMLElement | null) {
|
||
calendarContentWrapRef.value = element;
|
||
}
|
||
|
||
function setCalendarContentScrollRef(element: HTMLElement | null) {
|
||
if (calendarViewportResizeObserver) {
|
||
calendarViewportResizeObserver.disconnect();
|
||
calendarViewportResizeObserver = null;
|
||
}
|
||
calendarContentScrollRef.value = element;
|
||
if (element && typeof ResizeObserver !== "undefined") {
|
||
calendarViewportResizeObserver = new ResizeObserver(() => {
|
||
calendarViewportHeight.value = Math.max(0, Math.round(element.clientHeight));
|
||
});
|
||
calendarViewportResizeObserver.observe(element);
|
||
calendarViewportHeight.value = Math.max(0, Math.round(element.clientHeight));
|
||
}
|
||
}
|
||
|
||
function setCalendarSceneRef(element: HTMLElement | null) {
|
||
calendarSceneRef.value = element;
|
||
}
|
||
|
||
function setCalendarHoveredMonthIndex(value: number | null) {
|
||
calendarHoveredMonthIndex.value = value;
|
||
}
|
||
|
||
function setCalendarHoveredWeekStartKey(value: string) {
|
||
calendarHoveredWeekStartKey.value = value;
|
||
}
|
||
|
||
function setCalendarHoveredDayKey(value: string) {
|
||
calendarHoveredDayKey.value = value;
|
||
}
|
||
|
||
function onCalendarSceneMouseLeave() {
|
||
calendarHoveredMonthIndex.value = null;
|
||
calendarHoveredWeekStartKey.value = "";
|
||
calendarHoveredDayKey.value = "";
|
||
clearCalendarZoomPrime();
|
||
}
|
||
const calendarZoomBusy = ref(false);
|
||
const calendarCameraState = ref({
|
||
active: false,
|
||
left: 0,
|
||
top: 0,
|
||
scale: 1,
|
||
durationMs: 0,
|
||
});
|
||
const calendarZoomPrimeToken = ref("");
|
||
const calendarZoomPrimeScale = ref(1);
|
||
const calendarZoomPrimeTicks = ref(0);
|
||
let calendarWheelLockUntil = 0;
|
||
let calendarZoomPrimeTimer: ReturnType<typeof setTimeout> | null = null;
|
||
let calendarZoomPrimeLastAt = 0;
|
||
const CALENDAR_ZOOM_DURATION_MS = 2400;
|
||
const CALENDAR_ZOOM_PRIME_STEPS = 2;
|
||
const CALENDAR_ZOOM_PRIME_MAX_SCALE = 1.05;
|
||
const CALENDAR_ZOOM_PRIME_RESET_MS = 900;
|
||
const calendarZoomOrder: CalendarHierarchyView[] = ["year", "month", "week", "day"];
|
||
|
||
const normalizedCalendarView = computed<CalendarHierarchyView>(() =>
|
||
calendarView.value === "agenda" ? "month" : calendarView.value,
|
||
);
|
||
const calendarZoomLevelIndex = computed(() => Math.max(0, calendarZoomOrder.indexOf(normalizedCalendarView.value)));
|
||
const calendarSceneTransformStyle = computed(() => {
|
||
if (!calendarCameraState.value.active) return undefined;
|
||
return {
|
||
transform: `translate(${calendarCameraState.value.left}px, ${calendarCameraState.value.top}px) scale(${calendarCameraState.value.scale})`,
|
||
transformOrigin: "0 0",
|
||
transition:
|
||
calendarCameraState.value.durationMs > 0
|
||
? `transform ${calendarCameraState.value.durationMs}ms cubic-bezier(0.16, 0.86, 0.18, 1)`
|
||
: "none",
|
||
willChange: "transform",
|
||
};
|
||
});
|
||
|
||
function clearCalendarZoomPrime() {
|
||
if (calendarZoomPrimeTimer) {
|
||
clearTimeout(calendarZoomPrimeTimer);
|
||
calendarZoomPrimeTimer = null;
|
||
}
|
||
calendarZoomPrimeToken.value = "";
|
||
calendarZoomPrimeScale.value = 1;
|
||
calendarZoomPrimeTicks.value = 0;
|
||
calendarZoomPrimeLastAt = 0;
|
||
}
|
||
|
||
function calendarPrimeMonthToken(monthIndex: number) {
|
||
return `year-month-${monthIndex}`;
|
||
}
|
||
|
||
function calendarPrimeWeekToken(startKey: string) {
|
||
return `month-week-${startKey}`;
|
||
}
|
||
|
||
function calendarPrimeDayToken(key: string) {
|
||
return `week-day-${key}`;
|
||
}
|
||
|
||
function calendarPrimeStyle(token: string) {
|
||
if (calendarZoomPrimeToken.value !== token) return undefined;
|
||
return {
|
||
transform: `scale(${calendarZoomPrimeScale.value})`,
|
||
};
|
||
}
|
||
|
||
function maybePrimeWheelZoom(event: WheelEvent | undefined, token: string) {
|
||
if (!event || event.deltaY >= 0) return false;
|
||
const now = Date.now();
|
||
if (calendarZoomPrimeToken.value !== token || now - calendarZoomPrimeLastAt > CALENDAR_ZOOM_PRIME_RESET_MS) {
|
||
calendarZoomPrimeTicks.value = 0;
|
||
}
|
||
|
||
calendarZoomPrimeToken.value = token;
|
||
calendarZoomPrimeTicks.value += 1;
|
||
calendarZoomPrimeLastAt = now;
|
||
|
||
if (calendarZoomPrimeTicks.value <= CALENDAR_ZOOM_PRIME_STEPS) {
|
||
const ratio = calendarZoomPrimeTicks.value / CALENDAR_ZOOM_PRIME_STEPS;
|
||
calendarZoomPrimeScale.value = 1 + (CALENDAR_ZOOM_PRIME_MAX_SCALE - 1) * ratio;
|
||
if (calendarZoomPrimeTimer) clearTimeout(calendarZoomPrimeTimer);
|
||
calendarZoomPrimeTimer = setTimeout(() => {
|
||
clearCalendarZoomPrime();
|
||
}, CALENDAR_ZOOM_PRIME_RESET_MS);
|
||
return true;
|
||
}
|
||
|
||
clearCalendarZoomPrime();
|
||
return false;
|
||
}
|
||
|
||
function queryCalendarElement(selector: string) {
|
||
return calendarContentWrapRef.value?.querySelector<HTMLElement>(selector) ?? null;
|
||
}
|
||
|
||
function getCalendarViewportRect(): CalendarRect | null {
|
||
const wrapRect = calendarContentWrapRef.value?.getBoundingClientRect();
|
||
if (!wrapRect) return null;
|
||
return {
|
||
left: 0,
|
||
top: 0,
|
||
width: Math.max(24, wrapRect.width),
|
||
height: Math.max(24, wrapRect.height),
|
||
};
|
||
}
|
||
|
||
function getCalendarCameraViewportRect() {
|
||
const viewport = calendarContentScrollRef.value?.getBoundingClientRect();
|
||
if (!viewport) return null;
|
||
return {
|
||
width: Math.max(24, viewport.width),
|
||
height: Math.max(24, viewport.height),
|
||
};
|
||
}
|
||
|
||
function getElementRectInCalendar(element: HTMLElement | null): CalendarRect | null {
|
||
if (!element) return null;
|
||
const wrapRect = calendarContentWrapRef.value?.getBoundingClientRect();
|
||
if (!wrapRect) return null;
|
||
const rect = element.getBoundingClientRect();
|
||
const left = Math.max(0, Math.min(rect.left - wrapRect.left, wrapRect.width));
|
||
const top = Math.max(0, Math.min(rect.top - wrapRect.top, wrapRect.height));
|
||
const right = Math.max(0, Math.min(rect.right - wrapRect.left, wrapRect.width));
|
||
const bottom = Math.max(0, Math.min(rect.bottom - wrapRect.top, wrapRect.height));
|
||
const visibleWidth = right - left;
|
||
const visibleHeight = bottom - top;
|
||
if (visibleWidth < 2 || visibleHeight < 2) return null;
|
||
const width = Math.min(Math.max(24, visibleWidth), wrapRect.width - left);
|
||
const height = Math.min(Math.max(24, visibleHeight), wrapRect.height - top);
|
||
return { left, top, width, height };
|
||
}
|
||
|
||
function getElementRectInScene(element: HTMLElement | null): CalendarRect | null {
|
||
if (!element) return null;
|
||
const sceneRect = calendarSceneRef.value?.getBoundingClientRect();
|
||
if (!sceneRect) return null;
|
||
const rect = element.getBoundingClientRect();
|
||
const left = rect.left - sceneRect.left;
|
||
const top = rect.top - sceneRect.top;
|
||
const width = Math.max(24, rect.width);
|
||
const height = Math.max(24, rect.height);
|
||
return { left, top, width, height };
|
||
}
|
||
|
||
function fallbackZoomOriginRectInScene(): CalendarRect | null {
|
||
const viewport = getCalendarCameraViewportRect();
|
||
const scroll = calendarContentScrollRef.value;
|
||
if (!viewport || !scroll) return null;
|
||
const width = Math.max(96, Math.round(viewport.width * 0.28));
|
||
const height = Math.max(64, Math.round(viewport.height * 0.24));
|
||
return {
|
||
left: scroll.scrollLeft + Math.max(0, (viewport.width - width) / 2),
|
||
top: scroll.scrollTop + Math.max(0, (viewport.height - height) / 2),
|
||
width,
|
||
height,
|
||
};
|
||
}
|
||
|
||
function weekRowStartForDate(key: string) {
|
||
const date = new Date(`${key}T00:00:00`);
|
||
date.setDate(date.getDate() - date.getDay());
|
||
return dayKey(date);
|
||
}
|
||
|
||
function nextAnimationFrame() {
|
||
return new Promise<void>((resolve) => {
|
||
requestAnimationFrame(() => resolve());
|
||
});
|
||
}
|
||
|
||
function waitForTransformTransition(element: HTMLElement) {
|
||
return new Promise<void>((resolve) => {
|
||
let settled = false;
|
||
const finish = () => {
|
||
if (settled) return;
|
||
settled = true;
|
||
element.removeEventListener("transitionend", onTransitionEnd);
|
||
clearTimeout(fallbackTimer);
|
||
resolve();
|
||
};
|
||
const onTransitionEnd = (event: TransitionEvent) => {
|
||
if (event.target !== element) return;
|
||
if (event.propertyName !== "transform") return;
|
||
finish();
|
||
};
|
||
const fallbackTimer = setTimeout(() => finish(), CALENDAR_ZOOM_DURATION_MS + 160);
|
||
element.addEventListener("transitionend", onTransitionEnd);
|
||
});
|
||
}
|
||
|
||
function fadeOutCalendarSiblings(sourceElement: HTMLElement) {
|
||
const scene = calendarSceneRef.value;
|
||
if (!scene) return () => {};
|
||
const targets = Array.from(scene.querySelectorAll<HTMLElement>(".calendar-hover-targetable"));
|
||
const siblings = targets.filter((element) => {
|
||
if (element === sourceElement) return false;
|
||
if (sourceElement.contains(element)) return false;
|
||
if (element.contains(sourceElement)) return false;
|
||
return true;
|
||
});
|
||
const snapshots = siblings.map((element) => ({
|
||
element,
|
||
opacity: element.style.opacity,
|
||
pointerEvents: element.style.pointerEvents,
|
||
transition: element.style.transition,
|
||
}));
|
||
for (const { element } of snapshots) {
|
||
element.style.transition = "opacity 180ms ease";
|
||
element.style.opacity = "0";
|
||
element.style.pointerEvents = "none";
|
||
}
|
||
return () => {
|
||
for (const snapshot of snapshots) {
|
||
snapshot.element.style.opacity = snapshot.opacity;
|
||
snapshot.element.style.pointerEvents = snapshot.pointerEvents;
|
||
snapshot.element.style.transition = snapshot.transition;
|
||
}
|
||
};
|
||
}
|
||
|
||
function isRenderableRect(rect: DOMRect | null) {
|
||
return Boolean(rect && rect.width >= 2 && rect.height >= 2);
|
||
}
|
||
|
||
async function animateCalendarFlipTransition(
|
||
sourceElement: HTMLElement | null,
|
||
apply: () => void,
|
||
resolveTarget: () => HTMLElement | null,
|
||
) {
|
||
clearCalendarZoomPrime();
|
||
calendarZoomBusy.value = true;
|
||
let restoreSiblings = () => {};
|
||
let animatedElement: HTMLElement | null = null;
|
||
let snapshot: {
|
||
transform: string;
|
||
transition: string;
|
||
transformOrigin: string;
|
||
willChange: string;
|
||
zIndex: string;
|
||
} | null = null;
|
||
try {
|
||
const sourceRect = sourceElement?.getBoundingClientRect() ?? null;
|
||
apply();
|
||
await nextTick();
|
||
const targetElement = resolveTarget();
|
||
const targetRect = targetElement?.getBoundingClientRect() ?? null;
|
||
if (!targetElement || !isRenderableRect(sourceRect) || !isRenderableRect(targetRect)) return;
|
||
|
||
restoreSiblings = fadeOutCalendarSiblings(targetElement);
|
||
animatedElement = targetElement;
|
||
snapshot = {
|
||
transform: targetElement.style.transform,
|
||
transition: targetElement.style.transition,
|
||
transformOrigin: targetElement.style.transformOrigin,
|
||
willChange: targetElement.style.willChange,
|
||
zIndex: targetElement.style.zIndex,
|
||
};
|
||
|
||
const dx = sourceRect.left - targetRect.left;
|
||
const dy = sourceRect.top - targetRect.top;
|
||
const sx = Math.max(0.01, sourceRect.width / targetRect.width);
|
||
const sy = Math.max(0.01, sourceRect.height / targetRect.height);
|
||
|
||
targetElement.style.transformOrigin = "top left";
|
||
targetElement.style.willChange = "transform";
|
||
targetElement.style.zIndex = "24";
|
||
targetElement.style.transition = "none";
|
||
targetElement.style.transform = `translate3d(${dx}px, ${dy}px, 0px) scale(${sx}, ${sy})`;
|
||
targetElement.getBoundingClientRect();
|
||
await nextAnimationFrame();
|
||
|
||
targetElement.style.transition = `transform ${CALENDAR_ZOOM_DURATION_MS}ms cubic-bezier(0.16, 0.86, 0.18, 1)`;
|
||
targetElement.style.transform = "translate3d(0px, 0px, 0px) scale(1, 1)";
|
||
await waitForTransformTransition(targetElement);
|
||
} finally {
|
||
if (animatedElement && snapshot) {
|
||
animatedElement.style.transform = snapshot.transform;
|
||
animatedElement.style.transition = snapshot.transition;
|
||
animatedElement.style.transformOrigin = snapshot.transformOrigin;
|
||
animatedElement.style.willChange = snapshot.willChange;
|
||
animatedElement.style.zIndex = snapshot.zIndex;
|
||
}
|
||
restoreSiblings();
|
||
calendarZoomBusy.value = false;
|
||
}
|
||
}
|
||
|
||
async function animateCalendarZoomIntoSource(
|
||
sourceElement: HTMLElement | null,
|
||
apply: () => void,
|
||
) {
|
||
clearCalendarZoomPrime();
|
||
calendarZoomBusy.value = true;
|
||
let restoreSiblings = () => {};
|
||
let snapshot: {
|
||
transform: string;
|
||
transition: string;
|
||
transformOrigin: string;
|
||
willChange: string;
|
||
zIndex: string;
|
||
} | null = null;
|
||
try {
|
||
const viewportRect = calendarContentScrollRef.value?.getBoundingClientRect() ?? null;
|
||
const sourceRect = sourceElement?.getBoundingClientRect() ?? null;
|
||
if (!sourceElement || !isRenderableRect(viewportRect) || !isRenderableRect(sourceRect)) {
|
||
apply();
|
||
return;
|
||
}
|
||
|
||
restoreSiblings = fadeOutCalendarSiblings(sourceElement);
|
||
snapshot = {
|
||
transform: sourceElement.style.transform,
|
||
transition: sourceElement.style.transition,
|
||
transformOrigin: sourceElement.style.transformOrigin,
|
||
willChange: sourceElement.style.willChange,
|
||
zIndex: sourceElement.style.zIndex,
|
||
};
|
||
|
||
const dx = viewportRect.left - sourceRect.left;
|
||
const dy = viewportRect.top - sourceRect.top;
|
||
const sx = Math.max(0.01, viewportRect.width / sourceRect.width);
|
||
const sy = Math.max(0.01, viewportRect.height / sourceRect.height);
|
||
|
||
sourceElement.style.transformOrigin = "top left";
|
||
sourceElement.style.willChange = "transform";
|
||
sourceElement.style.zIndex = "24";
|
||
sourceElement.style.transition = "none";
|
||
sourceElement.style.transform = "translate3d(0px, 0px, 0px) scale(1, 1)";
|
||
sourceElement.getBoundingClientRect();
|
||
await nextAnimationFrame();
|
||
|
||
sourceElement.style.transition = `transform ${CALENDAR_ZOOM_DURATION_MS}ms cubic-bezier(0.16, 0.86, 0.18, 1)`;
|
||
sourceElement.style.transform = `translate3d(${dx}px, ${dy}px, 0px) scale(${sx}, ${sy})`;
|
||
await waitForTransformTransition(sourceElement);
|
||
|
||
apply();
|
||
await nextTick();
|
||
await nextAnimationFrame();
|
||
} finally {
|
||
if (sourceElement && snapshot) {
|
||
sourceElement.style.transform = snapshot.transform;
|
||
sourceElement.style.transition = snapshot.transition;
|
||
sourceElement.style.transformOrigin = snapshot.transformOrigin;
|
||
sourceElement.style.willChange = snapshot.willChange;
|
||
sourceElement.style.zIndex = snapshot.zIndex;
|
||
}
|
||
restoreSiblings();
|
||
calendarZoomBusy.value = false;
|
||
}
|
||
}
|
||
|
||
function resolveMonthAnchor(event?: WheelEvent) {
|
||
const target = event?.target as HTMLElement | null;
|
||
const monthAttr = target?.closest<HTMLElement>("[data-calendar-month-index]")?.dataset.calendarMonthIndex;
|
||
if (monthAttr) {
|
||
const parsed = Number(monthAttr);
|
||
if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 11) return parsed;
|
||
}
|
||
if (calendarHoveredMonthIndex.value !== null) return calendarHoveredMonthIndex.value;
|
||
return calendarCursor.value.getMonth();
|
||
}
|
||
|
||
function fallbackMonthGridAnchorKey() {
|
||
if (monthCells.value.some((cell) => cell.key === selectedDateKey.value)) return selectedDateKey.value;
|
||
const middle = dayKey(new Date(calendarCursor.value.getFullYear(), calendarCursor.value.getMonth(), 15));
|
||
if (monthCells.value.some((cell) => cell.key === middle)) return middle;
|
||
return monthCells.value.find((cell) => cell.inMonth)?.key ?? monthCells.value[0]?.key ?? selectedDateKey.value;
|
||
}
|
||
|
||
function resolveWeekAnchor(event?: WheelEvent) {
|
||
const target = event?.target as HTMLElement | null;
|
||
const weekKey = target?.closest<HTMLElement>("[data-calendar-week-start-key]")?.dataset.calendarWeekStartKey;
|
||
if (weekKey) return weekKey;
|
||
if (calendarHoveredWeekStartKey.value) return calendarHoveredWeekStartKey.value;
|
||
if (calendarHoveredDayKey.value) return calendarHoveredDayKey.value;
|
||
return fallbackMonthGridAnchorKey();
|
||
}
|
||
|
||
function resolveDayAnchor(event?: WheelEvent) {
|
||
const target = event?.target as HTMLElement | null;
|
||
const dayKeyAttr = target?.closest<HTMLElement>("[data-calendar-day-key]")?.dataset.calendarDayKey;
|
||
if (dayKeyAttr) return dayKeyAttr;
|
||
if (calendarHoveredDayKey.value) return calendarHoveredDayKey.value;
|
||
return weekDays.value[0]?.key ?? selectedDateKey.value;
|
||
}
|
||
|
||
async function zoomInCalendar(event?: Event) {
|
||
const wheelEvent = event instanceof WheelEvent ? event : undefined;
|
||
if (calendarView.value === "year") {
|
||
const monthIndex = resolveMonthAnchor(wheelEvent);
|
||
const sourceElement =
|
||
queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`) ??
|
||
queryCalendarElement("[data-calendar-month-index]");
|
||
if (maybePrimeWheelZoom(wheelEvent, calendarPrimeMonthToken(monthIndex))) return;
|
||
await animateCalendarZoomIntoSource(sourceElement, () => {
|
||
openYearMonth(monthIndex);
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (calendarView.value === "month" || calendarView.value === "agenda") {
|
||
const anchorDayKey = resolveWeekAnchor(wheelEvent);
|
||
const rowStartKey = weekRowStartForDate(anchorDayKey);
|
||
const sourceElement =
|
||
queryCalendarElement(`[data-calendar-layer="month"] [data-calendar-week-start-key="${rowStartKey}"]`) ??
|
||
queryCalendarElement(`[data-calendar-layer="month"] [data-calendar-day-key="${anchorDayKey}"]`) ??
|
||
queryCalendarElement(`[data-calendar-layer="month"] [data-calendar-week-start-key]`) ??
|
||
queryCalendarElement(`[data-calendar-layer="month"] [data-calendar-day-key]`);
|
||
if (maybePrimeWheelZoom(wheelEvent, calendarPrimeWeekToken(rowStartKey))) return;
|
||
await animateCalendarZoomIntoSource(sourceElement, () => {
|
||
openWeekView(anchorDayKey);
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (calendarView.value === "week") {
|
||
const dayAnchor = resolveDayAnchor(wheelEvent);
|
||
const sourceElement =
|
||
queryCalendarElement(`[data-calendar-layer="week"] [data-calendar-day-key="${dayAnchor}"]`) ??
|
||
queryCalendarElement(`[data-calendar-layer="week"] [data-calendar-day-key]`);
|
||
if (maybePrimeWheelZoom(wheelEvent, calendarPrimeDayToken(dayAnchor))) return;
|
||
await animateCalendarZoomIntoSource(sourceElement, () => {
|
||
openDayView(dayAnchor);
|
||
});
|
||
}
|
||
}
|
||
|
||
async function zoomToMonth(monthIndex: number) {
|
||
await animateCalendarZoomIntoSource(queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`), () => {
|
||
openYearMonth(monthIndex);
|
||
});
|
||
}
|
||
|
||
async function zoomOutCalendar() {
|
||
focusedCalendarEventId.value = "";
|
||
clearCalendarZoomPrime();
|
||
|
||
if (calendarView.value === "day") {
|
||
const targetDayKey = selectedDateKey.value;
|
||
await animateCalendarFlipTransition(
|
||
queryCalendarElement(`[data-calendar-month-index="${calendarCursor.value.getMonth()}"]`),
|
||
() => {
|
||
calendarView.value = "week";
|
||
},
|
||
() =>
|
||
queryCalendarElement(`[data-calendar-layer="week"] [data-calendar-day-key="${targetDayKey}"]`) ??
|
||
queryCalendarElement(`[data-calendar-layer="week"] [data-calendar-day-key]`),
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (calendarView.value === "week") {
|
||
const targetRowKey = weekRowStartForDate(selectedDateKey.value);
|
||
await animateCalendarFlipTransition(
|
||
queryCalendarElement(`[data-calendar-month-index="${calendarCursor.value.getMonth()}"]`),
|
||
() => {
|
||
calendarView.value = "month";
|
||
},
|
||
() =>
|
||
queryCalendarElement(`[data-calendar-layer="month"] [data-calendar-week-start-key="${targetRowKey}"]`) ??
|
||
queryCalendarElement(`[data-calendar-layer="month"] [data-calendar-day-key="${selectedDateKey.value}"]`) ??
|
||
queryCalendarElement(`[data-calendar-layer="month"] [data-calendar-week-start-key]`) ??
|
||
queryCalendarElement(`[data-calendar-layer="month"] [data-calendar-day-key]`),
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (calendarView.value === "month" || calendarView.value === "agenda") {
|
||
const targetMonthIndex = calendarCursor.value.getMonth();
|
||
await animateCalendarFlipTransition(
|
||
queryCalendarElement(`[data-calendar-month-index="${targetMonthIndex}"]`),
|
||
() => {
|
||
calendarView.value = "year";
|
||
},
|
||
() => queryCalendarElement(`[data-calendar-month-index="${targetMonthIndex}"]`),
|
||
);
|
||
}
|
||
}
|
||
|
||
function onCalendarHierarchyWheel(event: WheelEvent) {
|
||
const now = Date.now();
|
||
if (calendarZoomBusy.value) return;
|
||
if (now < calendarWheelLockUntil) return;
|
||
if (Math.abs(event.deltaY) < 5) return;
|
||
calendarWheelLockUntil = now + 140;
|
||
|
||
if (event.deltaY < 0) {
|
||
void zoomInCalendar(event);
|
||
return;
|
||
}
|
||
|
||
void zoomOutCalendar();
|
||
}
|
||
|
||
async function setCalendarZoomLevel(targetView: CalendarHierarchyView) {
|
||
let currentIndex = calendarZoomOrder.indexOf(normalizedCalendarView.value);
|
||
const targetIndex = calendarZoomOrder.indexOf(targetView);
|
||
if (currentIndex < 0 || targetIndex < 0 || currentIndex === targetIndex) return;
|
||
|
||
while (currentIndex !== targetIndex) {
|
||
if (targetIndex > currentIndex) {
|
||
await zoomInCalendar();
|
||
} else {
|
||
await zoomOutCalendar();
|
||
}
|
||
currentIndex = calendarZoomOrder.indexOf(normalizedCalendarView.value);
|
||
}
|
||
}
|
||
|
||
function onCalendarZoomSliderInput(event: Event) {
|
||
const value = Number((event.target as HTMLInputElement | null)?.value ?? NaN);
|
||
if (!Number.isFinite(value)) return;
|
||
const targetIndex = Math.max(0, Math.min(3, Math.round(value)));
|
||
const targetView = calendarZoomOrder[targetIndex];
|
||
if (!targetView) return;
|
||
void setCalendarZoomLevel(targetView);
|
||
}
|
||
|
||
const monthCells = computed(() => {
|
||
const year = calendarCursor.value.getFullYear();
|
||
const month = calendarCursor.value.getMonth();
|
||
const first = new Date(year, month, 1);
|
||
const start = new Date(year, month, 1 - first.getDay());
|
||
|
||
return Array.from({ length: 42 }, (_, index) => {
|
||
const d = new Date(start);
|
||
d.setDate(start.getDate() + index);
|
||
const key = dayKey(d);
|
||
|
||
return {
|
||
key,
|
||
day: d.getDate(),
|
||
inMonth: d.getMonth() === month,
|
||
events: getEventsByDate(key),
|
||
};
|
||
});
|
||
});
|
||
|
||
const monthRows = computed(() => {
|
||
const rows: Array<{ key: string; startKey: string; cells: typeof monthCells.value }> = [];
|
||
for (let index = 0; index < monthCells.value.length; index += 7) {
|
||
const cells = monthCells.value.slice(index, index + 7);
|
||
if (!cells.length) continue;
|
||
rows.push({
|
||
key: `${cells[0]?.key ?? index}-week-row`,
|
||
startKey: cells[0]?.key ?? selectedDateKey.value,
|
||
cells,
|
||
});
|
||
}
|
||
return rows;
|
||
});
|
||
|
||
function monthCellHasFocusedEvent(events: CalendarEvent[]) {
|
||
const id = focusedCalendarEventId.value.trim();
|
||
if (!id) return false;
|
||
return events.some((event) => event.id === id);
|
||
}
|
||
|
||
function monthCellEvents(events: CalendarEvent[]) {
|
||
const id = focusedCalendarEventId.value.trim();
|
||
if (!id) return events.slice(0, 2);
|
||
const focused = events.find((event) => event.id === id);
|
||
if (!focused) return events.slice(0, 2);
|
||
const rest = events.filter((event) => event.id !== id).slice(0, 1);
|
||
return [focused, ...rest];
|
||
}
|
||
|
||
const weekDays = computed(() => {
|
||
const base = new Date(`${selectedDateKey.value}T00:00:00`);
|
||
const mondayOffset = (base.getDay() + 6) % 7;
|
||
const monday = new Date(base);
|
||
monday.setDate(base.getDate() - mondayOffset);
|
||
|
||
return Array.from({ length: 7 }, (_, index) => {
|
||
const d = new Date(monday);
|
||
d.setDate(monday.getDate() + index);
|
||
const key = dayKey(d);
|
||
|
||
return {
|
||
key,
|
||
label: new Intl.DateTimeFormat("en-US", { weekday: "short" }).format(d),
|
||
day: d.getDate(),
|
||
events: getEventsByDate(key),
|
||
};
|
||
});
|
||
});
|
||
|
||
const calendarPeriodLabel = computed(() => {
|
||
if (calendarView.value === "month") {
|
||
return monthLabel.value;
|
||
}
|
||
|
||
if (calendarView.value === "year") {
|
||
return String(calendarCursor.value.getFullYear());
|
||
}
|
||
|
||
if (calendarView.value === "week") {
|
||
const first = weekDays.value[0];
|
||
const last = weekDays.value[weekDays.value.length - 1];
|
||
if (!first || !last) return "";
|
||
return `${formatDay(`${first.key}T00:00:00`)} - ${formatDay(`${last.key}T00:00:00`)}`;
|
||
}
|
||
|
||
if (calendarView.value === "day") {
|
||
return formatDay(`${selectedDateKey.value}T00:00:00`);
|
||
}
|
||
|
||
return `Agenda · ${monthLabel.value}`;
|
||
});
|
||
|
||
const yearMonths = computed(() => {
|
||
const year = calendarCursor.value.getFullYear();
|
||
return Array.from({ length: 12 }, (_, monthIndex) => {
|
||
const monthStart = new Date(year, monthIndex, 1);
|
||
const monthEnd = new Date(year, monthIndex + 1, 1);
|
||
const items = sortedEvents.value.filter((event) => {
|
||
const d = new Date(event.start);
|
||
return d >= monthStart && d < monthEnd;
|
||
});
|
||
|
||
return {
|
||
monthIndex,
|
||
label: new Intl.DateTimeFormat("en-US", { month: "long" }).format(monthStart),
|
||
count: items.length,
|
||
first: items[0],
|
||
};
|
||
});
|
||
});
|
||
|
||
const selectedDayEvents = computed(() => getEventsByDate(selectedDateKey.value));
|
||
|
||
function shiftCalendar(step: number) {
|
||
focusedCalendarEventId.value = "";
|
||
if (calendarView.value === "year") {
|
||
const next = new Date(calendarCursor.value);
|
||
next.setFullYear(next.getFullYear() + step);
|
||
calendarCursor.value = new Date(next.getFullYear(), next.getMonth(), 1);
|
||
const selected = new Date(`${selectedDateKey.value}T00:00:00`);
|
||
selected.setFullYear(selected.getFullYear() + step);
|
||
selectedDateKey.value = dayKey(selected);
|
||
return;
|
||
}
|
||
|
||
if (calendarView.value === "month" || calendarView.value === "agenda") {
|
||
const next = new Date(calendarCursor.value);
|
||
next.setMonth(next.getMonth() + step);
|
||
calendarCursor.value = new Date(next.getFullYear(), next.getMonth(), 1);
|
||
return;
|
||
}
|
||
|
||
const current = new Date(`${selectedDateKey.value}T00:00:00`);
|
||
const days = calendarView.value === "week" ? 7 : 1;
|
||
current.setDate(current.getDate() + days * step);
|
||
|
||
selectedDateKey.value = dayKey(current);
|
||
calendarCursor.value = new Date(current.getFullYear(), current.getMonth(), 1);
|
||
}
|
||
|
||
function setToday() {
|
||
focusedCalendarEventId.value = "";
|
||
const now = new Date();
|
||
selectedDateKey.value = dayKey(now);
|
||
calendarCursor.value = new Date(now.getFullYear(), now.getMonth(), 1);
|
||
}
|
||
|
||
function pickDate(key: string) {
|
||
focusedCalendarEventId.value = "";
|
||
selectedDateKey.value = key;
|
||
const d = new Date(`${key}T00:00:00`);
|
||
calendarCursor.value = new Date(d.getFullYear(), d.getMonth(), 1);
|
||
}
|
||
|
||
function openDayView(key: string) {
|
||
pickDate(key);
|
||
calendarView.value = "day";
|
||
}
|
||
|
||
function openWeekView(key: string) {
|
||
pickDate(key);
|
||
calendarView.value = "week";
|
||
}
|
||
|
||
function openYearMonth(monthIndex: number) {
|
||
focusedCalendarEventId.value = "";
|
||
const year = calendarCursor.value.getFullYear();
|
||
calendarCursor.value = new Date(year, monthIndex, 1);
|
||
selectedDateKey.value = dayKey(new Date(year, monthIndex, 1));
|
||
calendarView.value = "month";
|
||
}
|
||
|
||
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 selectedContactEvents = computed(() => {
|
||
if (!selectedContact.value) return [];
|
||
const nowIso = new Date().toISOString();
|
||
const events = sortedEvents.value.filter((event) => event.contact === selectedContact.value?.name);
|
||
const upcoming = events.filter((event) => event.end >= nowIso);
|
||
const past = events.filter((event) => event.end < nowIso).reverse();
|
||
|
||
return [...upcoming, ...past].slice(0, 8);
|
||
});
|
||
|
||
const selectedContactRecentMessages = computed(() => {
|
||
if (!selectedContact.value) return [];
|
||
return commItems.value
|
||
.filter((item) => item.contact === selectedContact.value?.name && item.kind === "message")
|
||
.sort((a, b) => b.at.localeCompare(a.at))
|
||
.slice(0, 8);
|
||
});
|
||
|
||
const documentSearch = ref("");
|
||
const documentSortMode = ref<DocumentSortMode>("updatedAt");
|
||
const documentDeletingId = ref("");
|
||
const documentSortOptions: Array<{ value: DocumentSortMode; label: string }> = [
|
||
{ value: "updatedAt", label: "Updated" },
|
||
{ value: "title", label: "Title" },
|
||
{ value: "owner", label: "Owner" },
|
||
];
|
||
|
||
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;
|
||
});
|
||
|
||
const selectedDocumentId = ref(documents.value[0]?.id ?? "");
|
||
|
||
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(push = false) {
|
||
selectedTab.value = "documents";
|
||
focusedCalendarEventId.value = "";
|
||
if (!selectedDocumentId.value && filteredDocuments.value.length) {
|
||
const first = filteredDocuments.value[0];
|
||
if (first) selectedDocumentId.value = first.id;
|
||
}
|
||
syncPathFromUi(push);
|
||
}
|
||
|
||
async function deleteWorkspaceDocumentById(documentIdInput: string) {
|
||
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 = "";
|
||
}
|
||
}
|
||
}
|
||
|
||
const peopleListMode = ref<"contacts" | "deals">("contacts");
|
||
const peopleSearch = ref("");
|
||
const peopleSortMode = ref<PeopleSortMode>("lastContact");
|
||
const peopleVisibilityMode = ref<PeopleVisibilityMode>("all");
|
||
const brokenAvatarByContactId = ref<Record<string, boolean>>({});
|
||
const peopleSortOptions: Array<{ value: PeopleSortMode; label: string }> = [
|
||
{ value: "lastContact", label: "Last contact" },
|
||
{ value: "name", label: "Name" },
|
||
];
|
||
const peopleVisibilityOptions: Array<{ value: PeopleVisibilityMode; label: string }> = [
|
||
{ value: "all", label: "All" },
|
||
{ value: "hidden", label: "Hidden" },
|
||
];
|
||
const selectedDealId = ref(deals.value[0]?.id ?? "");
|
||
const selectedDealStepsExpanded = ref(false);
|
||
|
||
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,
|
||
};
|
||
}
|
||
|
||
const commThreads = computed(() => {
|
||
const sorted = [...commItems.value].sort((a, b) => a.at.localeCompare(b.at));
|
||
const map = new Map<string, CommItem[]>();
|
||
|
||
for (const item of sorted) {
|
||
if (!map.has(item.contact)) {
|
||
map.set(item.contact, []);
|
||
}
|
||
map.get(item.contact)?.push(item);
|
||
}
|
||
|
||
const contactById = new Map(contacts.value.map((contact) => [contact.id, contact]));
|
||
const inboxesByContactId = new Map<string, ContactInbox[]>();
|
||
for (const inbox of contactInboxes.value) {
|
||
if (!inboxesByContactId.has(inbox.contactId)) {
|
||
inboxesByContactId.set(inbox.contactId, []);
|
||
}
|
||
inboxesByContactId.get(inbox.contactId)?.push(inbox);
|
||
}
|
||
|
||
const contactIds = new Set<string>([
|
||
...contacts.value.map((contact) => contact.id),
|
||
...contactInboxes.value.map((inbox) => inbox.contactId),
|
||
]);
|
||
|
||
return [...contactIds]
|
||
.map((contactId) => {
|
||
const contact = contactById.get(contactId);
|
||
const inboxes = inboxesByContactId.get(contactId) ?? [];
|
||
const contactName = contact?.name ?? inboxes[0]?.contactName ?? "";
|
||
const items = map.get(contactName) ?? [];
|
||
const last = items[items.length - 1];
|
||
const channels = [
|
||
...new Set([
|
||
...(contact?.channels ?? []),
|
||
...inboxes.map((inbox) => inbox.channel),
|
||
...items.map((item) => item.channel),
|
||
]),
|
||
] as CommItem["channel"][];
|
||
const inboxFallbackLast = inboxes
|
||
.map((inbox) => inbox.lastMessageAt || inbox.updatedAt)
|
||
.filter(Boolean)
|
||
.sort()
|
||
.at(-1);
|
||
|
||
return {
|
||
id: contactId,
|
||
contact: contactName,
|
||
avatar: contact?.avatar ?? "",
|
||
channels,
|
||
lastAt: last?.at ?? contact?.lastContactAt ?? inboxFallbackLast ?? "",
|
||
lastText: last?.text ?? "No messages yet",
|
||
items,
|
||
};
|
||
})
|
||
.filter((thread) => thread.contact)
|
||
.sort((a, b) => b.lastAt.localeCompare(a.lastAt));
|
||
});
|
||
|
||
const peopleContactList = computed(() => {
|
||
const query = peopleSearch.value.trim().toLowerCase();
|
||
const list = commThreads.value.filter((item) => {
|
||
if (!query) return true;
|
||
const haystack = [item.contact, ...(item.channels ?? [])].join(" ").toLowerCase();
|
||
return haystack.includes(query);
|
||
});
|
||
const byVisibility = list.filter((item) => {
|
||
if (peopleVisibilityMode.value === "all") return true;
|
||
return threadInboxes(item).some((inbox) => inbox.isHidden);
|
||
});
|
||
|
||
return byVisibility.sort((a, b) => {
|
||
if (peopleSortMode.value === "name") return a.contact.localeCompare(b.contact);
|
||
return b.lastAt.localeCompare(a.lastAt);
|
||
});
|
||
});
|
||
|
||
const peopleDealList = computed(() => {
|
||
const query = peopleSearch.value.trim().toLowerCase();
|
||
const list = deals.value.filter((deal) => {
|
||
if (!query) return true;
|
||
const haystack = [deal.title, deal.stage, deal.amount, deal.nextStep, deal.summary, deal.contact]
|
||
.join(" ")
|
||
.toLowerCase();
|
||
return haystack.includes(query);
|
||
});
|
||
|
||
return list.sort((a, b) => a.title.localeCompare(b.title));
|
||
});
|
||
|
||
const selectedCommThreadId = ref("");
|
||
|
||
watchEffect(() => {
|
||
if (!commThreads.value.length) {
|
||
selectedCommThreadId.value = "";
|
||
return;
|
||
}
|
||
|
||
if (!commThreads.value.some((thread) => thread.id === selectedCommThreadId.value)) {
|
||
const first = commThreads.value[0];
|
||
if (first) selectedCommThreadId.value = first.id;
|
||
}
|
||
});
|
||
|
||
const selectedCommThread = computed(() =>
|
||
commThreads.value.find((thread) => thread.id === selectedCommThreadId.value),
|
||
);
|
||
|
||
watch(
|
||
() => [
|
||
selectedTab.value,
|
||
peopleLeftMode.value,
|
||
peopleListMode.value,
|
||
selectedChatId.value,
|
||
calendarView.value,
|
||
calendarRouteToken(calendarView.value),
|
||
focusedCalendarEventId.value,
|
||
selectedContactId.value,
|
||
selectedDealId.value,
|
||
selectedDocumentId.value,
|
||
activeChangeSetId.value,
|
||
activeChangeStep.value,
|
||
],
|
||
() => {
|
||
if (process.server || uiPathSyncLocked.value) return;
|
||
syncPathFromUi(false);
|
||
},
|
||
);
|
||
|
||
const commSendChannel = ref<CommItem["channel"] | "">("");
|
||
const commPinnedOnly = ref(false);
|
||
const commDraft = ref("");
|
||
const commSending = ref(false);
|
||
const commRecording = ref(false);
|
||
const commTranscribing = ref(false);
|
||
const commMicError = ref("");
|
||
const commComposerMode = ref<"message" | "planned" | "logged" | "document">("message");
|
||
const commQuickMenuOpen = ref(false);
|
||
const commPinContextMenu = ref<{
|
||
open: boolean;
|
||
x: number;
|
||
y: number;
|
||
entry: any | null;
|
||
}>({
|
||
open: false,
|
||
x: 0,
|
||
y: 0,
|
||
entry: null,
|
||
});
|
||
const commEventSaving = ref(false);
|
||
const commEventError = ref("");
|
||
const commEventMode = ref<"planned" | "logged">("planned");
|
||
const commEventForm = ref({
|
||
startDate: "",
|
||
startTime: "",
|
||
durationMinutes: 30,
|
||
});
|
||
const commDocumentForm = ref<{
|
||
title: string;
|
||
}>({
|
||
title: "",
|
||
});
|
||
const inboxToggleLoadingById = ref<Record<string, boolean>>({});
|
||
const eventCloseOpen = ref<Record<string, boolean>>({});
|
||
const eventCloseDraft = ref<Record<string, string>>({});
|
||
const eventCloseSaving = ref<Record<string, boolean>>({});
|
||
const eventCloseError = ref<Record<string, string>>({});
|
||
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 = "";
|
||
|
||
watch(selectedCommThreadId, () => {
|
||
stopEventArchiveRecording();
|
||
destroyAllCommCallWaves();
|
||
callTranscriptOpen.value = {};
|
||
callTranscriptLoading.value = {};
|
||
callTranscriptText.value = {};
|
||
callTranscriptError.value = {};
|
||
commPinnedOnly.value = false;
|
||
commDraft.value = "";
|
||
commRecording.value = false;
|
||
commTranscribing.value = false;
|
||
commMicError.value = "";
|
||
commComposerMode.value = "message";
|
||
commQuickMenuOpen.value = false;
|
||
commPinContextMenu.value = { open: false, x: 0, y: 0, entry: null };
|
||
commEventError.value = "";
|
||
commDocumentForm.value = { title: "" };
|
||
inboxToggleLoadingById.value = {};
|
||
eventCloseOpen.value = {};
|
||
eventCloseDraft.value = {};
|
||
eventCloseSaving.value = {};
|
||
eventCloseError.value = {};
|
||
eventArchiveRecordingById.value = {};
|
||
eventArchiveTranscribingById.value = {};
|
||
eventArchiveMicErrorById.value = {};
|
||
clientTimelineRequestToken += 1;
|
||
const preferred = selectedCommThread.value?.channels.find((channel) => channel !== "Phone") ?? "";
|
||
commSendChannel.value = preferred;
|
||
if (!selectedCommThread.value) {
|
||
clientTimelineItems.value = [];
|
||
return;
|
||
}
|
||
void refreshSelectedClientTimeline().catch(() => undefined);
|
||
});
|
||
|
||
const commSendChannelOptions = computed<CommItem["channel"][]>(() => {
|
||
if (!selectedCommThread.value) return [];
|
||
const items = selectedCommThread.value.channels.filter((channel) => channel !== "Phone");
|
||
return items;
|
||
});
|
||
|
||
const visibleThreadItems = computed(() => {
|
||
if (!selectedCommThread.value) return [];
|
||
return selectedCommThread.value.items;
|
||
});
|
||
|
||
const selectedCommPins = computed(() => {
|
||
if (!selectedCommThread.value) return [];
|
||
return commPins.value.filter((item) => item.contact === selectedCommThread.value?.contact);
|
||
});
|
||
|
||
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 threadChannelLabel(thread: { id: string; channels: CommItem["channel"][] }) {
|
||
const visibleChannels = [...new Set(threadInboxes(thread).filter((inbox) => !inbox.isHidden).map((inbox) => inbox.channel))];
|
||
if (visibleChannels.length === 1) return visibleChannels[0];
|
||
if (visibleChannels.length > 1) return `${visibleChannels[0]} +${visibleChannels.length - 1}`;
|
||
|
||
const fallback = [...new Set(thread.channels.filter((channel) => channel !== "Phone"))];
|
||
if (fallback.length === 1) return fallback[0];
|
||
if (fallback.length > 1) return `${fallback[0]} +${fallback.length - 1}`;
|
||
return "No channel";
|
||
}
|
||
|
||
const selectedCommLifecycleEvents = computed(() => {
|
||
const nowMs = lifecycleNowMs.value;
|
||
|
||
return clientTimelineItems.value
|
||
.filter((entry) => entry.contentType === "calendar_event" && entry.calendarEvent)
|
||
.map((entry) => {
|
||
const event = entry.calendarEvent as CalendarEvent;
|
||
const phase = eventLifecyclePhase(event, nowMs);
|
||
return {
|
||
event,
|
||
phase,
|
||
timelineAt: entry.datetime,
|
||
};
|
||
})
|
||
.sort((a, b) => a.timelineAt.localeCompare(b.timelineAt));
|
||
});
|
||
|
||
const threadStreamItems = computed(() => {
|
||
const rows = clientTimelineItems.value
|
||
.map((entry) => {
|
||
if (entry.contentType === "message" && entry.message) {
|
||
return {
|
||
id: entry.id,
|
||
at: entry.datetime,
|
||
kind: entry.message.kind,
|
||
item: entry.message,
|
||
};
|
||
}
|
||
|
||
if (entry.contentType === "calendar_event" && entry.calendarEvent) {
|
||
const phase = eventLifecyclePhase(entry.calendarEvent, lifecycleNowMs.value);
|
||
return {
|
||
id: entry.id,
|
||
at: entry.datetime,
|
||
kind: "eventLifecycle" as const,
|
||
event: entry.calendarEvent,
|
||
phase,
|
||
};
|
||
}
|
||
|
||
if (entry.contentType === "recommendation" && entry.recommendation) {
|
||
return {
|
||
id: entry.id,
|
||
at: entry.datetime,
|
||
kind: "recommendation" as const,
|
||
card: entry.recommendation,
|
||
};
|
||
}
|
||
|
||
if (entry.contentType === "document" && entry.document) {
|
||
return {
|
||
id: entry.id,
|
||
at: entry.datetime,
|
||
kind: "document" as const,
|
||
document: entry.document,
|
||
};
|
||
}
|
||
|
||
return null;
|
||
})
|
||
.filter((entry) => entry !== null) as Array<any>;
|
||
|
||
return rows.sort((a, b) => a.at.localeCompare(b.at));
|
||
});
|
||
|
||
watch(
|
||
() => threadStreamItems.value.map((entry: any) => `${entry.kind}:${entry.id}`).join("|"),
|
||
() => {
|
||
void syncCommCallWaves();
|
||
},
|
||
);
|
||
|
||
const selectedCommPinnedStream = computed(() => {
|
||
const pins = selectedCommPins.value.map((pin) => {
|
||
const normalizedText = normalizePinText(stripPinnedPrefix(pin.text));
|
||
const sourceItem =
|
||
[...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 = 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)}`;
|
||
});
|
||
|
||
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 || "");
|
||
}
|
||
|
||
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,
|
||
};
|
||
}
|
||
|
||
function isPinnedEntry(entry: any) {
|
||
const contact = selectedCommThread.value?.contact ?? "";
|
||
const text = entryPinText(entry);
|
||
return isPinnedText(contact, text);
|
||
}
|
||
|
||
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();
|
||
}
|
||
}
|
||
|
||
function canManuallyCloseEvent(entry: { kind: string; event?: CalendarEvent; phase?: EventLifecyclePhase }) {
|
||
if (entry.kind !== "eventLifecycle" || !entry.event) return false;
|
||
return !isEventFinalStatus(entry.event.isArchived);
|
||
}
|
||
|
||
function isEventCloseOpen(eventId: string) {
|
||
return Boolean(eventCloseOpen.value[eventId]);
|
||
}
|
||
|
||
function toggleEventClose(eventId: string) {
|
||
const next = !eventCloseOpen.value[eventId];
|
||
eventCloseOpen.value = { ...eventCloseOpen.value, [eventId]: next };
|
||
if (next && !eventCloseDraft.value[eventId]) {
|
||
eventCloseDraft.value = { ...eventCloseDraft.value, [eventId]: "" };
|
||
}
|
||
if (!next && eventCloseError.value[eventId]) {
|
||
eventCloseError.value = { ...eventCloseError.value, [eventId]: "" };
|
||
}
|
||
}
|
||
|
||
function isEventArchiveRecording(eventId: string) {
|
||
return Boolean(eventArchiveRecordingById.value[eventId]);
|
||
}
|
||
|
||
function isEventArchiveTranscribing(eventId: string) {
|
||
return Boolean(eventArchiveTranscribingById.value[eventId]);
|
||
}
|
||
|
||
async function startEventArchiveRecording(eventId: 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(eventCloseDraft.value[targetId] ?? "").trim();
|
||
const merged = previous ? `${previous} ${text}` : text;
|
||
eventCloseDraft.value = { ...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) {
|
||
if (!pilotMicSupported.value) {
|
||
eventArchiveMicErrorById.value = { ...eventArchiveMicErrorById.value, [eventId]: "Recording is not supported in this browser" };
|
||
return;
|
||
}
|
||
if (isEventArchiveRecording(eventId)) {
|
||
stopEventArchiveRecording();
|
||
return;
|
||
}
|
||
void startEventArchiveRecording(eventId);
|
||
}
|
||
|
||
async function archiveEventManually(event: CalendarEvent) {
|
||
const eventId = event.id;
|
||
const archiveNote = String(eventCloseDraft.value[eventId] ?? "").trim();
|
||
if (eventCloseSaving.value[eventId]) return;
|
||
|
||
eventCloseSaving.value = { ...eventCloseSaving.value, [eventId]: true };
|
||
eventCloseError.value = { ...eventCloseError.value, [eventId]: "" };
|
||
try {
|
||
await doArchiveCalendarEvent({
|
||
input: {
|
||
id: eventId,
|
||
archiveNote: archiveNote || undefined,
|
||
},
|
||
});
|
||
eventCloseOpen.value = { ...eventCloseOpen.value, [eventId]: false };
|
||
eventCloseDraft.value = { ...eventCloseDraft.value, [eventId]: "" };
|
||
} catch (error: any) {
|
||
eventCloseError.value = { ...eventCloseError.value, [eventId]: String(error?.message ?? error ?? "Failed to archive event") };
|
||
} finally {
|
||
eventCloseSaving.value = { ...eventCloseSaving.value, [eventId]: false };
|
||
}
|
||
}
|
||
|
||
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 = selectedCommThread.value?.contact ?? "";
|
||
const text = entryPinText(entry);
|
||
await togglePinnedText(contact, text);
|
||
}
|
||
|
||
const selectedWorkspaceContact = computed(() => {
|
||
if (selectedContact.value) return selectedContact.value;
|
||
|
||
const threadContactId = (selectedCommThread.value?.id ?? "").trim();
|
||
if (threadContactId) {
|
||
const byId = contacts.value.find((contact) => contact.id === threadContactId);
|
||
if (byId) return byId;
|
||
}
|
||
|
||
const threadContactName = (selectedCommThread.value?.contact ?? "").trim();
|
||
if (threadContactName) {
|
||
const byName = contacts.value.find((contact) => contact.name === threadContactName);
|
||
if (byName) return byName;
|
||
}
|
||
return contacts.value[0] ?? null;
|
||
});
|
||
|
||
const contactRightPanelMode = ref<"summary" | "documents">("summary");
|
||
const contactDocumentsSearch = ref("");
|
||
|
||
const selectedWorkspaceContactDocuments = computed(() => {
|
||
const contact = selectedWorkspaceContact.value;
|
||
if (!contact) return [];
|
||
return documents.value
|
||
.filter((doc) => isDocumentLinkedToContact(doc.scope, contact))
|
||
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
||
});
|
||
|
||
const filteredSelectedWorkspaceContactDocuments = computed(() => {
|
||
const query = contactDocumentsSearch.value.trim().toLowerCase();
|
||
if (!query) return selectedWorkspaceContactDocuments.value;
|
||
return selectedWorkspaceContactDocuments.value.filter((doc) => {
|
||
const haystack = [doc.title, doc.summary, doc.owner, formatDocumentScope(doc.scope), doc.body].join(" ").toLowerCase();
|
||
return haystack.includes(query);
|
||
});
|
||
});
|
||
|
||
watch(
|
||
() => selectedWorkspaceContact.value?.id ?? "",
|
||
() => {
|
||
contactRightPanelMode.value = "summary";
|
||
contactDocumentsSearch.value = "";
|
||
},
|
||
);
|
||
|
||
const selectedWorkspaceDeal = computed(() => {
|
||
const explicit = deals.value.find((deal) => deal.id === selectedDealId.value);
|
||
if (explicit) return explicit;
|
||
|
||
if (selectedWorkspaceContact.value) {
|
||
const linked = deals.value.find((deal) => deal.contact === selectedWorkspaceContact.value?.name);
|
||
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 formatYearMonthFirst(item: { first?: CalendarEvent }) {
|
||
if (!item.first) return "";
|
||
return `${formatDay(item.first.start)} · ${item.first.title}`;
|
||
}
|
||
|
||
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);
|
||
});
|
||
|
||
function calendarScopeLabel() {
|
||
if (focusedCalendarEvent.value) {
|
||
return `Календарь: ${focusedCalendarEvent.value.title}`;
|
||
}
|
||
if (calendarView.value === "month" || calendarView.value === "agenda") {
|
||
return `Календарь: ${monthLabel.value}`;
|
||
}
|
||
if (calendarView.value === "year") {
|
||
return `Календарь: ${calendarCursor.value.getFullYear()}`;
|
||
}
|
||
if (calendarView.value === "week") {
|
||
return `Календарь: ${calendarPeriodLabel.value}`;
|
||
}
|
||
return `Календарь: ${formatDay(`${selectedDateKey.value}T00:00:00`)}`;
|
||
}
|
||
|
||
function contextScopeLabel(scope: ContextScope) {
|
||
if (scope === "summary") return "Summary";
|
||
if (scope === "deal") return "Сделка";
|
||
if (scope === "message") return "Работа с пользователем";
|
||
return calendarScopeLabel();
|
||
}
|
||
|
||
const contextScopeChips = computed(() =>
|
||
contextScopes.value.map((scope) => ({
|
||
scope,
|
||
label: contextScopeLabel(scope),
|
||
})),
|
||
);
|
||
|
||
function buildContextPayload(): PilotContextPayload | null {
|
||
const scopes = [...contextScopes.value];
|
||
if (!scopes.length) return null;
|
||
|
||
const payload: PilotContextPayload = { scopes };
|
||
|
||
if (hasContextScope("summary") && selectedWorkspaceContact.value) {
|
||
payload.summary = {
|
||
contactId: selectedWorkspaceContact.value.id,
|
||
name: selectedWorkspaceContact.value.name,
|
||
};
|
||
}
|
||
|
||
if (hasContextScope("deal") && selectedWorkspaceDeal.value) {
|
||
payload.deal = {
|
||
dealId: selectedWorkspaceDeal.value.id,
|
||
title: selectedWorkspaceDeal.value.title,
|
||
contact: selectedWorkspaceDeal.value.contact,
|
||
};
|
||
}
|
||
|
||
if (hasContextScope("message")) {
|
||
payload.message = {
|
||
contactId: selectedWorkspaceContact.value?.id || undefined,
|
||
contact: selectedWorkspaceContact.value?.name || selectedCommThread.value?.contact || undefined,
|
||
intent: "add_message_or_reminder",
|
||
};
|
||
}
|
||
|
||
if (hasContextScope("calendar")) {
|
||
const eventIds = (() => {
|
||
if (focusedCalendarEvent.value) return [focusedCalendarEvent.value.id];
|
||
if (calendarView.value === "day") return selectedDayEvents.value.map((event) => event.id);
|
||
if (calendarView.value === "week") return weekDays.value.flatMap((d) => d.events.map((event) => event.id));
|
||
if (calendarView.value === "month" || calendarView.value === "agenda") {
|
||
const monthStart = new Date(calendarCursor.value.getFullYear(), calendarCursor.value.getMonth(), 1);
|
||
const monthEnd = new Date(calendarCursor.value.getFullYear(), calendarCursor.value.getMonth() + 1, 1);
|
||
return sortedEvents.value
|
||
.filter((event) => {
|
||
const d = new Date(event.start);
|
||
return d >= monthStart && d < monthEnd;
|
||
})
|
||
.map((event) => event.id);
|
||
}
|
||
return sortedEvents.value
|
||
.filter((event) => new Date(event.start).getFullYear() === calendarCursor.value.getFullYear())
|
||
.map((event) => event.id);
|
||
})();
|
||
|
||
payload.calendar = {
|
||
view: calendarView.value,
|
||
period: calendarPeriodLabel.value,
|
||
selectedDateKey: selectedDateKey.value,
|
||
focusedEventId: focusedCalendarEvent.value?.id || undefined,
|
||
eventIds,
|
||
};
|
||
}
|
||
|
||
return payload;
|
||
}
|
||
|
||
watch(
|
||
() => selectedWorkspaceDeal.value?.id ?? "",
|
||
() => {
|
||
selectedDealStepsExpanded.value = false;
|
||
},
|
||
);
|
||
|
||
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]);
|
||
}
|
||
|
||
async function toggleCommCallPlayback(item: CommItem) {
|
||
if (!isCommCallPlayable(item)) return;
|
||
const itemId = item.id;
|
||
await ensureCommCallWave(itemId);
|
||
const ws = commCallWaveSurfers.get(itemId);
|
||
if (!ws) return;
|
||
if (isCommCallPlaying(itemId)) {
|
||
ws.pause?.();
|
||
return;
|
||
}
|
||
pauseOtherCommCallWaves(itemId);
|
||
await ws.play?.();
|
||
}
|
||
|
||
function channelIcon(channel: "All" | CommItem["channel"]) {
|
||
if (channel === "All") return "all";
|
||
if (channel === "Telegram") return "telegram";
|
||
if (channel === "WhatsApp") return "whatsapp";
|
||
if (channel === "Instagram") return "instagram";
|
||
if (channel === "Email") return "email";
|
||
return "phone";
|
||
}
|
||
|
||
function 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}`;
|
||
}
|
||
|
||
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 });
|
||
} finally {
|
||
inboxToggleLoadingById.value = { ...inboxToggleLoadingById.value, [id]: false };
|
||
}
|
||
}
|
||
|
||
function messageDeliveryUiState(item: CommItem): "none" | "sending" | "sent" | "delivered" | "failed" {
|
||
if (item.kind !== "message" || item.direction !== "out") return "none";
|
||
const rawStatus = String(item.deliveryStatus ?? "").toUpperCase();
|
||
if (rawStatus === "FAILED") return "failed";
|
||
if (rawStatus === "READ" || rawStatus === "DELIVERED") return "delivered";
|
||
if (rawStatus === "SENT") return "sent";
|
||
return "sending";
|
||
}
|
||
|
||
function messageDeliveryLabel(item: CommItem) {
|
||
const state = messageDeliveryUiState(item);
|
||
if (state === "failed") return "Delivery failed";
|
||
if (state === "delivered") return "Delivered";
|
||
if (state === "sent") return "Sent";
|
||
if (state === "sending") return "Sending";
|
||
return "";
|
||
}
|
||
|
||
function makeId(prefix: string) {
|
||
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
|
||
}
|
||
|
||
function pushPilotNote(text: string) {
|
||
// Fire-and-forget: log assistant note to the same conversation.
|
||
doLogPilotNote({ text })
|
||
.then(() => Promise.all([refetchChatMessages(), refetchChatConversations()]))
|
||
.catch(() => {});
|
||
}
|
||
|
||
function openCommunicationThread(contact: string) {
|
||
setPeopleLeftMode("contacts", true);
|
||
peopleListMode.value = "contacts";
|
||
selectedDealStepsExpanded.value = false;
|
||
const linkedContact = contacts.value.find((item) => item.name === contact);
|
||
if (linkedContact) {
|
||
selectedContactId.value = linkedContact.id;
|
||
}
|
||
const linkedDeal = deals.value.find((deal) => deal.contact === contact);
|
||
if (linkedDeal) {
|
||
selectedDealId.value = linkedDeal.id;
|
||
}
|
||
const thread = commThreads.value.find((item) => item.contact === contact);
|
||
if (thread) {
|
||
selectedCommThreadId.value = thread.id;
|
||
}
|
||
}
|
||
|
||
function openDealThread(deal: Deal) {
|
||
selectedDealId.value = deal.id;
|
||
peopleListMode.value = "deals";
|
||
selectedDealStepsExpanded.value = false;
|
||
openCommunicationThread(deal.contact);
|
||
peopleListMode.value = "deals";
|
||
}
|
||
|
||
function openThreadFromCalendarItem(event: CalendarEvent) {
|
||
if (!event.contact?.trim()) {
|
||
setPeopleLeftMode("calendar", true);
|
||
pickDate(event.start.slice(0, 10));
|
||
focusedCalendarEventId.value = event.id;
|
||
syncPathFromUi(true);
|
||
return;
|
||
}
|
||
openCommunicationThread(event.contact);
|
||
}
|
||
|
||
function openEventFromContact(event: CalendarEvent) {
|
||
setPeopleLeftMode("calendar", true);
|
||
pickDate(event.start.slice(0, 10));
|
||
focusedCalendarEventId.value = event.id;
|
||
syncPathFromUi(true);
|
||
}
|
||
|
||
function openMessageFromContact(channel: CommItem["channel"]) {
|
||
if (!selectedContact.value) return;
|
||
openCommunicationThread(selectedContact.value.name);
|
||
commSendChannel.value = channel;
|
||
}
|
||
|
||
function setDefaultCommEventForm(mode: "planned" | "logged") {
|
||
const start = mode === "planned"
|
||
? roundToNextQuarter(new Date(Date.now() + 15 * 60 * 1000))
|
||
: roundToPrevQuarter(new Date(Date.now() - 30 * 60 * 1000));
|
||
commEventForm.value = {
|
||
startDate: toInputDate(start),
|
||
startTime: toInputTime(start),
|
||
durationMinutes: 30,
|
||
};
|
||
}
|
||
|
||
function setDefaultCommDocumentForm() {
|
||
commDocumentForm.value = {
|
||
title: "",
|
||
};
|
||
}
|
||
|
||
function openCommEventModal(mode: "planned" | "logged") {
|
||
if (!selectedCommThread.value) return;
|
||
commEventMode.value = mode;
|
||
setDefaultCommEventForm(mode);
|
||
commEventError.value = "";
|
||
commComposerMode.value = mode;
|
||
commQuickMenuOpen.value = false;
|
||
}
|
||
|
||
function openCommDocumentModal() {
|
||
if (!selectedCommThread.value) return;
|
||
setDefaultCommDocumentForm();
|
||
commEventError.value = "";
|
||
commComposerMode.value = "document";
|
||
commQuickMenuOpen.value = false;
|
||
}
|
||
|
||
function closeCommEventModal() {
|
||
if (commEventSaving.value) return;
|
||
commComposerMode.value = "message";
|
||
commEventError.value = "";
|
||
setDefaultCommDocumentForm();
|
||
commQuickMenuOpen.value = false;
|
||
}
|
||
|
||
function toggleCommQuickMenu() {
|
||
if (!selectedCommThread.value || commEventSaving.value) return;
|
||
commQuickMenuOpen.value = !commQuickMenuOpen.value;
|
||
}
|
||
|
||
function closeCommQuickMenu() {
|
||
commQuickMenuOpen.value = false;
|
||
}
|
||
|
||
function commComposerPlaceholder() {
|
||
if (commComposerMode.value === "planned") return "Опиши, что нужно запланировать...";
|
||
if (commComposerMode.value === "logged") return "Опиши итог/отчёт по прошедшему событию...";
|
||
if (commComposerMode.value === "document") return "Опиши документ или вложение для контакта...";
|
||
return "Type a message...";
|
||
}
|
||
|
||
function buildCommEventTitle(text: string, mode: "planned" | "logged", contact: string) {
|
||
const cleaned = text.replace(/\s+/g, " ").trim();
|
||
if (cleaned) {
|
||
const sentence = cleaned.split(/[.!?\n]/)[0]?.trim() ?? "";
|
||
if (sentence) return sentence.slice(0, 120);
|
||
}
|
||
return mode === "logged" ? `Отчёт по контакту ${contact}` : `Событие с ${contact}`;
|
||
}
|
||
|
||
function 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}`;
|
||
}
|
||
|
||
async function createCommEvent() {
|
||
if (!selectedCommThread.value || commEventSaving.value) return;
|
||
|
||
const note = commDraft.value.trim();
|
||
const title = buildCommEventTitle(note, commEventMode.value, selectedCommThread.value.contact);
|
||
const duration = Number(commEventForm.value.durationMinutes || 0);
|
||
if (!note) {
|
||
commEventError.value = "Текст события обязателен";
|
||
return;
|
||
}
|
||
|
||
if (!commEventForm.value.startDate || !commEventForm.value.startTime) {
|
||
commEventError.value = "Date and time are required";
|
||
return;
|
||
}
|
||
|
||
const start = new Date(`${commEventForm.value.startDate}T${commEventForm.value.startTime}:00`);
|
||
if (Number.isNaN(start.getTime())) {
|
||
commEventError.value = "Invalid date or time";
|
||
return;
|
||
}
|
||
|
||
const safeDuration = Number.isFinite(duration) && duration > 0 ? duration : 30;
|
||
const end = new Date(start);
|
||
end.setMinutes(end.getMinutes() + safeDuration);
|
||
|
||
commEventSaving.value = true;
|
||
commEventError.value = "";
|
||
try {
|
||
const res = await doCreateCalendarEvent({
|
||
input: {
|
||
title,
|
||
start: start.toISOString(),
|
||
end: end.toISOString(),
|
||
contact: selectedCommThread.value.contact,
|
||
note,
|
||
archived: commEventMode.value === "logged",
|
||
archiveNote: commEventMode.value === "logged" ? note : undefined,
|
||
},
|
||
});
|
||
if (res?.data?.createCalendarEvent) {
|
||
calendarEvents.value = [res.data.createCalendarEvent as CalendarEvent, ...calendarEvents.value];
|
||
}
|
||
selectedDateKey.value = dayKey(start);
|
||
calendarCursor.value = new Date(start.getFullYear(), start.getMonth(), 1);
|
||
commDraft.value = "";
|
||
commComposerMode.value = "message";
|
||
commEventError.value = "";
|
||
} catch (error: any) {
|
||
commEventError.value = String(error?.message ?? error ?? "Failed to create event");
|
||
} finally {
|
||
commEventSaving.value = false;
|
||
}
|
||
}
|
||
|
||
async function createCommDocument() {
|
||
if (!selectedCommThread.value || commEventSaving.value) return;
|
||
|
||
const summary = commDraft.value.trim();
|
||
if (!summary) {
|
||
commEventError.value = "Текст документа обязателен";
|
||
return;
|
||
}
|
||
|
||
const title = safeTrim(commDocumentForm.value.title) || buildCommDocumentTitle(summary, selectedCommThread.value.contact);
|
||
const scope = buildContactDocumentScope(selectedCommThread.value.id, selectedCommThread.value.contact);
|
||
const body = summary;
|
||
|
||
commEventSaving.value = true;
|
||
commEventError.value = "";
|
||
try {
|
||
const res = await doCreateWorkspaceDocument({
|
||
input: {
|
||
title,
|
||
owner: authDisplayName.value,
|
||
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 = "";
|
||
}
|
||
contactRightPanelMode.value = "documents";
|
||
commDraft.value = "";
|
||
commComposerMode.value = "message";
|
||
commEventError.value = "";
|
||
setDefaultCommDocumentForm();
|
||
} catch (error: any) {
|
||
commEventError.value = String(error?.message ?? error ?? "Failed to create document");
|
||
} finally {
|
||
commEventSaving.value = false;
|
||
}
|
||
}
|
||
|
||
async function sendCommMessage() {
|
||
const text = commDraft.value.trim();
|
||
if (!text || commSending.value || !selectedCommThread.value) return;
|
||
|
||
commSending.value = true;
|
||
try {
|
||
const channel = commSendChannel.value;
|
||
if (!channel) return;
|
||
|
||
await doCreateCommunication({
|
||
input: {
|
||
contact: selectedCommThread.value.contact,
|
||
channel,
|
||
kind: "message",
|
||
direction: "out",
|
||
text,
|
||
},
|
||
});
|
||
|
||
commDraft.value = "";
|
||
openCommunicationThread(selectedCommThread.value.contact);
|
||
} finally {
|
||
commSending.value = false;
|
||
}
|
||
}
|
||
|
||
function onCommDictationTranscript(text: string) {
|
||
const next = String(text ?? "").trim();
|
||
if (!next) return;
|
||
const previous = String(commDraft.value ?? "").trim();
|
||
commDraft.value = previous ? `${previous} ${next}` : next;
|
||
commMicError.value = "";
|
||
}
|
||
|
||
function handleCommComposerEnter(event: KeyboardEvent) {
|
||
if (event.shiftKey) return;
|
||
event.preventDefault();
|
||
handleCommComposerSubmit();
|
||
}
|
||
|
||
function handleCommComposerSubmit() {
|
||
if (commComposerMode.value === "message") {
|
||
void sendCommMessage();
|
||
return;
|
||
}
|
||
if (commComposerMode.value === "document") {
|
||
void createCommDocument();
|
||
return;
|
||
}
|
||
void createCommEvent();
|
||
}
|
||
|
||
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.",
|
||
},
|
||
});
|
||
if (res?.data?.createCalendarEvent) {
|
||
calendarEvents.value = [res.data.createCalendarEvent as CalendarEvent, ...calendarEvents.value];
|
||
}
|
||
|
||
selectedDateKey.value = dayKey(start);
|
||
calendarCursor.value = new Date(start.getFullYear(), start.getMonth(), 1);
|
||
setPeopleLeftMode("calendar", true);
|
||
return `Event created: Follow-up · ${formatDay(start.toISOString())} ${formatTime(start.toISOString())} · ${card.contact}`;
|
||
}
|
||
|
||
if (key === "open_comm") {
|
||
openCommunicationThread(card.contact);
|
||
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,
|
||
},
|
||
});
|
||
openCommunicationThread(card.contact);
|
||
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.",
|
||
},
|
||
});
|
||
openCommunicationThread(card.contact);
|
||
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?",
|
||
},
|
||
});
|
||
openCommunicationThread(card.contact);
|
||
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}`);
|
||
}
|
||
|
||
</script>
|
||
|
||
<template>
|
||
<div class="h-[100dvh] overflow-hidden bg-base-200/35">
|
||
<CrmAuthLoading v-if="!authResolved || !authMe" />
|
||
|
||
<template v-else>
|
||
<div class="grid h-full min-h-0 grid-cols-1 gap-0 lg:grid-cols-[320px_minmax(0,1fr)]">
|
||
<CrmPilotSidebar
|
||
:pilot-header-text="pilotHeaderText"
|
||
:chat-switching="chatSwitching"
|
||
:chat-threads-loading="chatThreadsLoading"
|
||
:chat-conversations="chatConversations"
|
||
:auth-me="authMe"
|
||
:chat-creating="chatCreating"
|
||
:rendered-pilot-messages="renderedPilotMessages"
|
||
:pilot-live-logs="pilotLiveLogs"
|
||
:pilot-live-logs-expanded="pilotLiveLogsExpanded"
|
||
:pilot-live-log-hidden-count="pilotLiveLogHiddenCount"
|
||
:pilot-visible-log-count="pilotVisibleLogCount"
|
||
:pilot-visible-live-logs="pilotVisibleLiveLogs"
|
||
:chat-thread-picker-open="chatThreadPickerOpen"
|
||
:selected-chat-id="selectedChatId"
|
||
:chat-archiving-id="chatArchivingId"
|
||
:pilot-input="pilotInput"
|
||
:pilot-recording="pilotRecording"
|
||
:context-scope-chips="contextScopeChips"
|
||
:context-picker-enabled="contextPickerEnabled"
|
||
:pilot-transcribing="pilotTranscribing"
|
||
:pilot-sending="pilotSending"
|
||
:pilot-mic-supported="pilotMicSupported"
|
||
:pilot-mic-error="pilotMicError"
|
||
:toggle-chat-thread-picker="toggleChatThreadPicker"
|
||
:create-new-chat-conversation="createNewChatConversation"
|
||
:pilot-role-badge="pilotRoleBadge"
|
||
:pilot-role-name="pilotRoleName"
|
||
:format-pilot-stamp="formatPilotStamp"
|
||
:summarize-change-actions="summarizeChangeActions"
|
||
:summarize-change-entities="summarizeChangeEntities"
|
||
:open-change-review="openChangeReview"
|
||
:toggle-pilot-live-logs-expanded="togglePilotLiveLogsExpanded"
|
||
:close-chat-thread-picker="closeChatThreadPicker"
|
||
:switch-chat-conversation="switchChatConversation"
|
||
:format-chat-thread-meta="formatChatThreadMeta"
|
||
:archive-chat-conversation="archiveChatConversation"
|
||
:handle-pilot-composer-enter="handlePilotComposerEnter"
|
||
:on-pilot-input="(value) => { pilotInput = value; }"
|
||
:set-pilot-wave-container-ref="setPilotWaveContainerRef"
|
||
:toggle-context-picker="toggleContextPicker"
|
||
:remove-context-scope="removeContextScope"
|
||
:toggle-pilot-recording="togglePilotRecording"
|
||
:handle-pilot-send-action="handlePilotSendAction"
|
||
/>
|
||
|
||
<main class="relative min-h-0 bg-base-100">
|
||
<div class="flex h-full min-h-0 flex-col">
|
||
<CrmWorkspaceTopbar
|
||
:selected-tab="selectedTab"
|
||
:people-left-mode="peopleLeftMode"
|
||
:auth-initials="authInitials"
|
||
:auth-display-name="authDisplayName"
|
||
:telegram-status-badge-class="telegramStatusBadgeClass"
|
||
:telegram-status-label="telegramStatusLabel"
|
||
:telegram-connect-busy="telegramConnectBusy"
|
||
:telegram-connect-notice="telegramConnectNotice"
|
||
@open-contacts="setPeopleLeftMode('contacts', true)"
|
||
@open-calendar="setPeopleLeftMode('calendar', true)"
|
||
@open-documents="openDocumentsTab(true)"
|
||
@start-telegram-connect="startTelegramBusinessConnect"
|
||
@logout="logout"
|
||
/>
|
||
<div
|
||
class="min-h-0 flex-1"
|
||
:class="selectedTab === 'documents' || (selectedTab === 'communications' && peopleLeftMode === 'contacts') ? 'px-0 pt-0 pb-0' : 'px-3 pt-3 pb-0 md:px-4 md:pt-4 md:pb-0'"
|
||
>
|
||
<CrmCalendarPanel
|
||
v-if="selectedTab === 'communications' && peopleLeftMode === 'calendar'"
|
||
:context-picker-enabled="contextPickerEnabled"
|
||
:has-context-scope="hasContextScope"
|
||
:toggle-context-scope="toggleContextScope"
|
||
:context-scope-label="contextScopeLabel"
|
||
:set-today="setToday"
|
||
:calendar-period-label="calendarPeriodLabel"
|
||
:calendar-zoom-level-index="calendarZoomLevelIndex"
|
||
:on-calendar-zoom-slider-input="onCalendarZoomSliderInput"
|
||
:focused-calendar-event="focusedCalendarEvent"
|
||
:format-day="formatDay"
|
||
:format-time="formatTime"
|
||
:set-calendar-content-wrap-ref="setCalendarContentWrapRef"
|
||
:shift-calendar="shiftCalendar"
|
||
:set-calendar-content-scroll-ref="setCalendarContentScrollRef"
|
||
:on-calendar-hierarchy-wheel="onCalendarHierarchyWheel"
|
||
:set-calendar-scene-ref="setCalendarSceneRef"
|
||
:calendar-viewport-height="calendarViewportHeight"
|
||
:normalized-calendar-view="normalizedCalendarView"
|
||
:calendar-scene-transform-style="calendarSceneTransformStyle"
|
||
:on-calendar-scene-mouse-leave="onCalendarSceneMouseLeave"
|
||
:calendar-view="calendarView"
|
||
:year-months="yearMonths"
|
||
:calendar-cursor-month="calendarCursor.getMonth()"
|
||
:calendar-hovered-month-index="calendarHoveredMonthIndex"
|
||
:set-calendar-hovered-month-index="setCalendarHoveredMonthIndex"
|
||
:calendar-zoom-prime-token="calendarZoomPrimeToken"
|
||
:calendar-prime-month-token="calendarPrimeMonthToken"
|
||
:calendar-prime-style="calendarPrimeStyle"
|
||
:zoom-to-month="zoomToMonth"
|
||
:open-thread-from-calendar-item="openThreadFromCalendarItem"
|
||
:month-rows="monthRows"
|
||
:calendar-hovered-week-start-key="calendarHoveredWeekStartKey"
|
||
:set-calendar-hovered-week-start-key="setCalendarHoveredWeekStartKey"
|
||
:calendar-prime-week-token="calendarPrimeWeekToken"
|
||
:selected-date-key="selectedDateKey"
|
||
:month-cell-has-focused-event="monthCellHasFocusedEvent"
|
||
:calendar-hovered-day-key="calendarHoveredDayKey"
|
||
:set-calendar-hovered-day-key="setCalendarHoveredDayKey"
|
||
:pick-date="pickDate"
|
||
:month-cell-events="monthCellEvents"
|
||
:is-review-highlighted-event="isReviewHighlightedEvent"
|
||
:week-days="weekDays"
|
||
:calendar-prime-day-token="calendarPrimeDayToken"
|
||
:selected-day-events="selectedDayEvents"
|
||
/>
|
||
|
||
|
||
<section v-else-if="selectedTab === 'communications' && peopleLeftMode === 'contacts'" class="flex h-full min-h-0 flex-col gap-0">
|
||
<div class="grid h-full min-h-0 flex-1 gap-0 md:grid-cols-[248px_minmax(0,1fr)_320px] md:grid-rows-[auto_minmax(0,1fr)]">
|
||
<CrmCommunicationsListSidebar
|
||
:people-list-mode="peopleListMode"
|
||
:people-search="peopleSearch"
|
||
:people-sort-options="peopleSortOptions"
|
||
:people-sort-mode="peopleSortMode"
|
||
:people-visibility-options="peopleVisibilityOptions"
|
||
:people-visibility-mode="peopleVisibilityMode"
|
||
:people-contact-list="peopleContactList"
|
||
:selected-comm-thread-id="selectedCommThreadId"
|
||
:is-review-highlighted-contact="isReviewHighlightedContact"
|
||
:open-communication-thread="openCommunicationThread"
|
||
:avatar-src-for-thread="avatarSrcForThread"
|
||
:mark-avatar-broken="markAvatarBroken"
|
||
:contact-initials="contactInitials"
|
||
:format-thread-time="formatThreadTime"
|
||
:thread-channel-label="threadChannelLabel"
|
||
:people-deal-list="peopleDealList"
|
||
:selected-deal-id="selectedDealId"
|
||
:is-review-highlighted-deal="isReviewHighlightedDeal"
|
||
:open-deal-thread="openDealThread"
|
||
:get-deal-current-step-label="getDealCurrentStepLabel"
|
||
:on-people-list-mode-change="(mode) => { peopleListMode = mode; }"
|
||
:on-people-search-input="(value) => { peopleSearch = value; }"
|
||
:on-people-sort-mode-change="(mode) => { peopleSortMode = mode; }"
|
||
:on-people-visibility-mode-change="(mode) => { peopleVisibilityMode = mode; }"
|
||
/>
|
||
|
||
<div class="hidden h-12 items-center justify-between gap-2 border-b border-base-300 px-3 md:flex md:col-span-2">
|
||
<div v-if="selectedWorkspaceContact">
|
||
<p class="font-medium">{{ selectedWorkspaceContact.name }}</p>
|
||
</div>
|
||
<div v-else-if="selectedCommThread">
|
||
<p class="font-medium">{{ selectedCommThread.contact }}</p>
|
||
</div>
|
||
<div v-if="selectedCommThread" class="dropdown dropdown-end" @click.stop>
|
||
<button
|
||
tabindex="0"
|
||
class="btn btn-ghost btn-sm btn-square"
|
||
title="Source visibility settings"
|
||
>
|
||
<svg viewBox="0 0 24 24" class="h-4 w-4 fill-current">
|
||
<path d="M19.14 12.94a7.43 7.43 0 0 0 .05-.94 7.43 7.43 0 0 0-.05-.94l2.03-1.58a.5.5 0 0 0 .12-.63l-1.92-3.32a.5.5 0 0 0-.6-.22l-2.39.96a7.2 7.2 0 0 0-1.62-.94l-.36-2.54A.5.5 0 0 0 13.9 2h-3.8a.5.5 0 0 0-.49.41L9.25 4.95a7.2 7.2 0 0 0-1.62.94l-2.39-.96a.5.5 0 0 0-.6.22L2.72 8.47a.5.5 0 0 0 .12.63l2.03 1.58a7.43 7.43 0 0 0-.05.94c0 .31.02.63.05.94l-2.03 1.58a.5.5 0 0 0-.12.63l1.92 3.32c.13.23.39.32.6.22l2.39-.96c.5.39 1.05.71 1.62.94l.36 2.54c.04.24.25.41.49.41h3.8c.24 0 .45-.17.49-.41l.36-2.54c.57-.23 1.12-.55 1.62-.94l2.39.96c.22.09.47 0 .6-.22l1.92-3.32a.5.5 0 0 0-.12-.63zM12 15.5A3.5 3.5 0 1 1 12 8a3.5 3.5 0 0 1 0 7.5Z" />
|
||
</svg>
|
||
</button>
|
||
<div tabindex="0" class="dropdown-content z-20 mt-1 w-60 rounded-xl border border-base-300 bg-base-100 p-2 shadow-lg">
|
||
<p class="px-1 pb-1 text-[10px] font-semibold uppercase tracking-wide text-base-content/55">Sources</p>
|
||
<div v-if="threadInboxes(selectedCommThread).length" class="space-y-1">
|
||
<button
|
||
v-for="inbox in threadInboxes(selectedCommThread)"
|
||
:key="`thread-header-inbox-setting-${inbox.id}`"
|
||
class="btn btn-ghost btn-xs h-auto min-h-0 w-full justify-between px-2 py-1 text-left normal-case"
|
||
@click.stop="setInboxHidden(inbox.id, !inbox.isHidden)"
|
||
>
|
||
<span class="min-w-0 truncate">{{ formatInboxLabel(inbox) }}</span>
|
||
<span class="shrink-0 text-[10px] text-base-content/70">
|
||
{{
|
||
isInboxToggleLoading(inbox.id)
|
||
? "..."
|
||
: inbox.isHidden
|
||
? "Hidden"
|
||
: "Visible"
|
||
}}
|
||
</span>
|
||
</button>
|
||
</div>
|
||
<p v-else class="px-1 py-1 text-[11px] text-base-content/60">No sources.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<article class="h-full min-h-0 border-r border-base-300 flex flex-col">
|
||
<div v-if="false" class="p-3">
|
||
<div class="grid grid-cols-[auto_1fr_auto] items-center gap-2">
|
||
<div class="flex items-center gap-1">
|
||
<button class="btn btn-xs" @click="setToday">Today</button>
|
||
<button class="btn btn-xs btn-ghost" @click="shiftCalendar(-1)">←</button>
|
||
<button class="btn btn-xs btn-ghost" @click="shiftCalendar(1)">→</button>
|
||
</div>
|
||
|
||
<div class="text-center text-sm font-medium">
|
||
{{ calendarPeriodLabel }}
|
||
</div>
|
||
|
||
<div class="justify-self-end">
|
||
<select v-model="calendarView" class="select select-bordered select-xs w-36">
|
||
<option
|
||
v-for="option in calendarViewOptions"
|
||
:key="`workspace-right-calendar-view-${option.value}`"
|
||
:value="option.value"
|
||
>
|
||
{{ option.label }}
|
||
</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="calendarView === 'month'" class="mt-3 space-y-1">
|
||
<div class="grid grid-cols-7 gap-1 text-center text-xs font-semibold text-base-content/60">
|
||
<span>Sun</span>
|
||
<span>Mon</span>
|
||
<span>Tue</span>
|
||
<span>Wed</span>
|
||
<span>Thu</span>
|
||
<span>Fri</span>
|
||
<span>Sat</span>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-7 gap-1">
|
||
<button
|
||
v-for="cell in monthCells"
|
||
:key="`workspace-right-month-${cell.key}`"
|
||
class="min-h-24 rounded-lg border p-1 text-left"
|
||
:class="[
|
||
cell.inMonth ? 'border-base-300 bg-base-100' : 'border-base-200 bg-base-200/40 text-base-content/40',
|
||
selectedDateKey === cell.key ? 'border-primary bg-primary/5' : '',
|
||
]"
|
||
@click="pickDate(cell.key)"
|
||
>
|
||
<p class="mb-1 text-xs font-semibold">{{ cell.day }}</p>
|
||
<button
|
||
v-for="event in cell.events.slice(0, 2)"
|
||
:key="`workspace-right-month-event-${event.id}`"
|
||
class="block w-full truncate text-left text-[10px] text-base-content/70 hover:underline"
|
||
@click.stop="openThreadFromCalendarItem(event)"
|
||
>
|
||
{{ formatTime(event.start) }} {{ event.title }}
|
||
</button>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else-if="calendarView === 'week'" class="mt-3 space-y-2">
|
||
<article
|
||
v-for="day in weekDays"
|
||
:key="`workspace-right-week-${day.key}`"
|
||
class="rounded-xl border border-base-300 p-3"
|
||
:class="selectedDateKey === day.key ? 'border-primary bg-primary/5' : ''"
|
||
@click="pickDate(day.key)"
|
||
>
|
||
<p class="mb-2 text-sm font-semibold">{{ day.label }} {{ day.day }}</p>
|
||
<div class="space-y-1">
|
||
<button
|
||
v-for="event in day.events"
|
||
:key="`workspace-right-week-event-${event.id}`"
|
||
class="block w-full rounded bg-base-200 px-2 py-1 text-left text-xs hover:bg-base-300/80"
|
||
@click.stop="openThreadFromCalendarItem(event)"
|
||
>
|
||
{{ formatTime(event.start) }} - {{ event.title }} ({{ event.contact }})
|
||
</button>
|
||
<p v-if="day.events.length === 0" class="text-xs text-base-content/50">No events</p>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
|
||
<div v-else-if="calendarView === 'day'" class="mt-3 space-y-2">
|
||
<button
|
||
v-for="event in selectedDayEvents"
|
||
:key="`workspace-right-day-event-${event.id}`"
|
||
class="block w-full rounded-xl border border-base-300 p-3 text-left hover:bg-base-200/60"
|
||
@click="openThreadFromCalendarItem(event)"
|
||
>
|
||
<p class="font-medium">{{ event.title }}</p>
|
||
<p class="text-xs text-base-content/60">{{ event.contact }}</p>
|
||
<p class="text-xs text-base-content/60">{{ formatTime(event.start) }} - {{ formatTime(event.end) }}</p>
|
||
<p class="mt-1 text-sm text-base-content/80">{{ event.note }}</p>
|
||
</button>
|
||
<p v-if="selectedDayEvents.length === 0" class="text-sm text-base-content/60">No events on this day.</p>
|
||
</div>
|
||
|
||
<div v-else-if="calendarView === 'year'" class="mt-3 grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||
<button
|
||
v-for="item in yearMonths"
|
||
:key="`workspace-right-year-${item.monthIndex}`"
|
||
class="rounded-xl border border-base-300 p-3 text-left transition hover:border-primary/50 hover:bg-primary/5"
|
||
@click="openYearMonth(item.monthIndex)"
|
||
>
|
||
<p class="font-medium">{{ item.label }}</p>
|
||
<p class="text-xs text-base-content/60">{{ item.count }} events</p>
|
||
<p v-if="item.first" class="mt-1 text-xs text-base-content/70">
|
||
{{ formatYearMonthFirst(item) }}
|
||
</p>
|
||
</button>
|
||
</div>
|
||
|
||
<div v-else class="mt-3 space-y-2">
|
||
<button
|
||
v-for="event in sortedEvents"
|
||
:key="`workspace-right-agenda-${event.id}`"
|
||
class="block w-full rounded-xl border border-base-300 p-3 text-left hover:bg-base-200/60"
|
||
@click="openThreadFromCalendarItem(event)"
|
||
>
|
||
<p class="font-medium">{{ event.title }}</p>
|
||
<p class="text-xs text-base-content/60">{{ event.contact }}</p>
|
||
<p class="text-xs text-base-content/60">{{ formatDay(event.start) }} · {{ formatTime(event.start) }} - {{ formatTime(event.end) }}</p>
|
||
<p class="mt-1 text-sm text-base-content/80">{{ event.note }}</p>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else-if="selectedCommThread" class="relative flex h-full min-h-0 flex-col">
|
||
<div class="comm-thread-surface min-h-0 flex-1 space-y-2 overflow-y-auto px-3 pb-2">
|
||
<button
|
||
class="sticky top-0 z-10 -mx-3 mb-2 flex w-[calc(100%+1.5rem)] items-center gap-2 border-b border-base-300 bg-base-100/80 px-3 py-2 text-left backdrop-blur-sm transition hover:bg-base-100"
|
||
@click="commPinnedOnly = !commPinnedOnly"
|
||
>
|
||
<svg viewBox="0 0 24 24" class="h-4 w-4 shrink-0 fill-current text-base-content/75">
|
||
<path d="M14 3a1 1 0 0 0-1 1v4.59l-1.7 1.7A2 2 0 0 0 10.7 12H8v2h2.7a2 2 0 0 0 .6 1.41L13 17.1V21l2-1.2v-2.7l1.7-1.7A2 2 0 0 0 17.3 14H20v-2h-2.7a2 2 0 0 0-.6-1.41L15 8.9V4a1 1 0 0 0-1-1Z" />
|
||
</svg>
|
||
<span class="min-w-0 flex-1 truncate text-xs text-base-content/80">{{ latestPinnedLabel }}</span>
|
||
<span class="shrink-0 text-xs text-base-content/75">{{ selectedCommPinnedStream.length }}</span>
|
||
</button>
|
||
|
||
<div
|
||
v-for="entry in (commPinnedOnly ? selectedCommPinnedStream : threadStreamItems)"
|
||
:key="entry.id"
|
||
@contextmenu.prevent="openCommPinContextMenu($event, entry)"
|
||
>
|
||
<div
|
||
v-if="entry.kind === 'pin'"
|
||
class="flex"
|
||
:class="entry.sourceItem ? (entry.sourceItem.direction === 'out' ? 'justify-end' : 'justify-start') : 'justify-center'"
|
||
>
|
||
<div
|
||
class="max-w-[88%] rounded-xl border border-base-300 p-3"
|
||
:class="entry.sourceItem?.direction === 'out' ? 'bg-base-200' : 'bg-base-100'"
|
||
>
|
||
<p class="text-sm">{{ stripPinnedPrefix(entry.text) }}</p>
|
||
<p class="mt-1 text-xs text-base-content/60">
|
||
<span class="mr-1 inline-flex h-4 w-4 items-center justify-center align-middle">
|
||
<svg viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
|
||
<path d="M14 3a1 1 0 0 0-1 1v4.59l-1.7 1.7A2 2 0 0 0 10.7 12H8v2h2.7a2 2 0 0 0 .6 1.41L13 17.1V21l2-1.2v-2.7l1.7-1.7A2 2 0 0 0 17.3 14H20v-2h-2.7a2 2 0 0 0-.6-1.41L15 8.9V4a1 1 0 0 0-1-1Z" />
|
||
</svg>
|
||
</span>
|
||
<span>{{ entry.sourceItem ? formatStamp(entry.sourceItem.at) : "Pinned" }}</span>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
v-else-if="entry.kind === 'call'"
|
||
class="flex"
|
||
:class="entry.item.direction === 'out' ? 'justify-end' : 'justify-start'"
|
||
>
|
||
<div
|
||
class="call-wave-card w-full max-w-[460px] rounded-2xl border border-base-300 px-4 py-3"
|
||
:class="[
|
||
entry.item.direction === 'out' ? 'bg-base-200' : 'bg-base-100',
|
||
isReviewHighlightedMessage(entry.item.id) ? 'border-success/60 bg-success/10 ring-2 ring-success/40' : '',
|
||
]"
|
||
>
|
||
<p class="mb-2 text-xs text-base-content/65">
|
||
<span class="font-semibold">{{ entry.item.direction === "out" ? "You" : selectedCommThread?.contact || "Contact" }}</span>
|
||
<span class="mx-1">·</span>
|
||
{{ formatDay(entry.item.at) }} · {{ formatTime(entry.item.at) }}
|
||
<span v-if="entry.item.duration"> · {{ entry.item.duration }}</span>
|
||
</p>
|
||
<div class="comm-call-wave-wrap mb-2">
|
||
<div class="comm-call-wave" :ref="(el) => setCommCallWaveHost(entry.item.id, el as Element | null)" />
|
||
<button
|
||
class="call-wave-center-play"
|
||
:disabled="!isCommCallPlayable(entry.item)"
|
||
:title="isCommCallPlayable(entry.item) ? 'Play voice message' : 'Audio unavailable'"
|
||
@click="toggleCommCallPlayback(entry.item)"
|
||
>
|
||
<svg v-if="!isCommCallPlaying(entry.item.id)" viewBox="0 0 20 20" class="h-4 w-4">
|
||
<path
|
||
fill="currentColor"
|
||
d="M6.5 4.75a.75.75 0 0 1 1.12-.65l7.5 4.25a.75.75 0 0 1 0 1.3l-7.5 4.25a.75.75 0 0 1-1.12-.65v-8.5Z"
|
||
/>
|
||
</svg>
|
||
<svg v-else viewBox="0 0 20 20" class="h-4 w-4">
|
||
<path
|
||
fill="currentColor"
|
||
d="M6.75 4.5a.75.75 0 0 1 .75.75v9.5a.75.75 0 0 1-1.5 0v-9.5a.75.75 0 0 1 .75-.75Zm6.5 0a.75.75 0 0 1 .75.75v9.5a.75.75 0 0 1-1.5 0v-9.5a.75.75 0 0 1 .75-.75Z"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="mt-2 flex" :class="entry.item.direction === 'out' ? 'justify-end' : 'justify-start'">
|
||
<button class="call-transcript-toggle" @click="toggleCallTranscript(entry.item)">
|
||
<span>
|
||
{{
|
||
callTranscriptLoading[entry.item.id]
|
||
? "Generating transcript..."
|
||
: isCallTranscriptOpen(entry.item.id)
|
||
? "Hide transcript"
|
||
: "Show transcript"
|
||
}}
|
||
</span>
|
||
<svg
|
||
viewBox="0 0 20 20"
|
||
class="h-3.5 w-3.5 transition-transform"
|
||
:class="isCallTranscriptOpen(entry.item.id) ? 'rotate-180' : ''"
|
||
>
|
||
<path
|
||
fill="currentColor"
|
||
fill-rule="evenodd"
|
||
clip-rule="evenodd"
|
||
d="M5.22 7.22a.75.75 0 0 1 1.06 0L10 10.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 8.28a.75.75 0 0 1 0-1.06Z"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<transition name="accordion">
|
||
<div v-if="isCallTranscriptOpen(entry.item.id)" class="mt-2 rounded-xl border border-base-300 bg-base-100 p-2 text-left">
|
||
<div v-if="callTranscriptLoading[entry.item.id]" class="call-transcript-loader" aria-live="polite" aria-busy="true">
|
||
<span />
|
||
<span />
|
||
<span />
|
||
<span />
|
||
</div>
|
||
<div v-else-if="callTranscriptError[entry.item.id]" class="space-y-2">
|
||
<p class="text-xs leading-relaxed text-error">
|
||
{{ callTranscriptError[entry.item.id] }}
|
||
</p>
|
||
<button class="btn btn-xs btn-outline" @click="transcribeCallItem(entry.item)">Retry</button>
|
||
</div>
|
||
<p v-else class="text-xs leading-relaxed text-base-content/80">
|
||
{{ callTranscriptText[entry.item.id] || "No transcript yet" }}
|
||
</p>
|
||
</div>
|
||
</transition>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else-if="entry.kind === 'eventLifecycle'" class="flex justify-center">
|
||
<article
|
||
class="w-full max-w-[460px] rounded-xl border p-3 text-center"
|
||
:class="[eventPhaseToneClass(entry.phase), isReviewHighlightedEvent(entry.event.id) ? 'ring-2 ring-success/45' : '']"
|
||
>
|
||
<p class="text-xs text-base-content/70">
|
||
{{ eventRelativeLabel(entry.event, lifecycleNowMs) }} · {{ formatDay(entry.event.start) }} {{ formatTime(entry.event.start) }}
|
||
</p>
|
||
<p class="mt-1 text-sm text-base-content/90">{{ entry.event.note || entry.event.title }}</p>
|
||
<p v-if="entry.event.archiveNote" class="mt-2 text-xs text-base-content/70">Archive note: {{ entry.event.archiveNote }}</p>
|
||
|
||
<div v-if="canManuallyCloseEvent(entry)" class="mt-2">
|
||
<button class="btn btn-xs btn-outline" @click="toggleEventClose(entry.event.id)">
|
||
{{ isEventCloseOpen(entry.event.id) ? "Cancel" : "Archive event" }}
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="canManuallyCloseEvent(entry) && isEventCloseOpen(entry.event.id)" class="mt-2 space-y-2 text-left">
|
||
<textarea
|
||
v-model="eventCloseDraft[entry.event.id]"
|
||
class="textarea textarea-bordered w-full text-xs"
|
||
rows="3"
|
||
placeholder="Archive note (optional)"
|
||
/>
|
||
<div class="flex justify-between gap-2">
|
||
<button
|
||
class="btn btn-xs btn-outline"
|
||
:disabled="isEventArchiveTranscribing(entry.event.id)"
|
||
@click="toggleEventArchiveRecording(entry.event.id)"
|
||
>
|
||
{{
|
||
isEventArchiveTranscribing(entry.event.id)
|
||
? "Transcribing..."
|
||
: isEventArchiveRecording(entry.event.id)
|
||
? "Stop mic"
|
||
: "Voice note"
|
||
}}
|
||
</button>
|
||
</div>
|
||
<p v-if="eventArchiveMicErrorById[entry.event.id]" class="text-xs text-error">{{ eventArchiveMicErrorById[entry.event.id] }}</p>
|
||
<p v-if="eventCloseError[entry.event.id]" class="text-xs text-error">{{ eventCloseError[entry.event.id] }}</p>
|
||
<div class="flex justify-end">
|
||
<button
|
||
class="btn btn-xs"
|
||
:disabled="eventCloseSaving[entry.event.id]"
|
||
@click="archiveEventManually(entry.event)"
|
||
>
|
||
{{ eventCloseSaving[entry.event.id] ? "Saving..." : "Confirm archive" }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
|
||
<div v-else-if="entry.kind === 'document'" class="flex justify-center">
|
||
<article class="w-full max-w-[460px] rounded-xl border border-base-300 bg-base-100 p-3 text-left">
|
||
<p class="text-xs text-base-content/65">Document · {{ formatStamp(entry.at) }}</p>
|
||
<p class="mt-1 text-sm font-semibold text-base-content">{{ entry.document.title }}</p>
|
||
<p class="mt-1 text-xs text-base-content/70">
|
||
{{ formatDocumentScope(entry.document.scope) }} · {{ entry.document.owner }}
|
||
</p>
|
||
<p class="mt-2 text-sm text-base-content/85">{{ entry.document.summary }}</p>
|
||
</article>
|
||
</div>
|
||
|
||
<div v-else-if="entry.kind === 'recommendation'" class="flex justify-center">
|
||
<article class="w-full max-w-[460px] rounded-xl border border-base-300 bg-base-100 p-3">
|
||
<p class="text-sm">{{ entry.card.text }}</p>
|
||
|
||
<div class="mt-2 rounded-lg border border-base-300 bg-base-200/30 p-2">
|
||
<p class="text-xs font-semibold uppercase tracking-wide text-base-content/70">{{ entry.card.proposal.title }}</p>
|
||
<p
|
||
v-for="line in entry.card.proposal.details"
|
||
:key="`${entry.card.id}-${line}`"
|
||
class="mt-1 text-xs text-base-content/80"
|
||
>
|
||
{{ line }}
|
||
</p>
|
||
</div>
|
||
|
||
<div v-if="entry.card.decision === 'pending'" class="mt-2 flex gap-2">
|
||
<button class="btn btn-xs flex-1" @click="decideFeedCard(entry.card, 'accepted')">Yes</button>
|
||
<button class="btn btn-xs btn-outline flex-1" @click="decideFeedCard(entry.card, 'rejected')">No</button>
|
||
</div>
|
||
<p v-else class="mt-2 text-xs text-base-content/70">{{ entry.card.decisionNote }}</p>
|
||
</article>
|
||
</div>
|
||
|
||
<div
|
||
v-else
|
||
class="flex"
|
||
:class="entry.item.direction === 'out' ? 'justify-end' : 'justify-start'"
|
||
>
|
||
<div
|
||
class="max-w-[88%] rounded-xl border border-base-300 p-3"
|
||
:class="[
|
||
entry.item.direction === 'out' ? 'bg-base-200' : 'bg-base-100',
|
||
isReviewHighlightedMessage(entry.item.id) ? 'border-success/60 bg-success/10 ring-2 ring-success/40' : '',
|
||
]"
|
||
>
|
||
<p class="text-sm">{{ entry.item.text }}</p>
|
||
<p class="mt-1 text-xs text-base-content/60">
|
||
<span class="mr-1 inline-flex h-4 w-4 items-center justify-center align-middle">
|
||
<svg v-if="channelIcon(entry.item.channel) === 'telegram'" viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
|
||
<path d="M9.04 15.51 8.7 20.27c.49 0 .7-.21.96-.46l2.3-2.2 4.77 3.49c.88.49 1.5.23 1.74-.81l3.15-14.77.01-.01c.29-1.35-.49-1.88-1.35-1.56L1.74 11.08c-1.28.5-1.26 1.22-.22 1.54l4.74 1.48L17.3 7.03c.52-.34 1-.15.61.19" />
|
||
</svg>
|
||
<svg v-else-if="channelIcon(entry.item.channel) === 'whatsapp'" viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
|
||
<path d="M20 3.99A9.94 9.94 0 0 0 12.01 1C6.49 1 2 5.49 2 11c0 1.76.46 3.49 1.33 5.03L2 23l7.17-1.88A9.95 9.95 0 0 0 12 21h.01c5.51 0 9.99-4.49 9.99-10.01 0-2.67-1.04-5.18-2.99-7m-7.99 15.32h-.01a8.3 8.3 0 0 1-4.23-1.16l-.3-.18-4.26 1.12 1.14-4.15-.2-.32a8.28 8.28 0 0 1-1.27-4.4c0-4.58 3.73-8.31 8.32-8.31a8.27 8.27 0 0 1 5.88 2.44 8.25 8.25 0 0 1 2.43 5.87c0 4.59-3.73 8.32-8.31 8.32m4.56-6.23c-.25-.12-1.49-.74-1.73-.82-.23-.09-.4-.12-.57.12s-.66.82-.81.99-.3.18-.55.06a6.7 6.7 0 0 1-1.97-1.21 7.43 7.43 0 0 1-1.38-1.71c-.14-.24-.01-.37.1-.49.11-.11.24-.3.36-.45.12-.14.16-.24.25-.39.08-.18.05-.3-.02-.42-.07-.12-.56-1.35-.77-1.85-.2-.48-.41-.41-.57-.42h-.48c-.16 0-.42.06-.64.3-.22.24-.84.82-.84 2s.86 2.31.98 2.48c.12.16 1.69 2.57 4.09 3.6.57.24 1.01.38 1.36.48.58.18 1.11.15 1.52.09.46-.06 1.49-.61 1.7-1.19.21-.58.21-1.09.15-1.19-.06-.11-.23-.17-.48-.3" />
|
||
</svg>
|
||
<svg v-else-if="channelIcon(entry.item.channel) === 'instagram'" viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
|
||
<path d="M7 2h10a5 5 0 0 1 5 5v10a5 5 0 0 1-5 5H7a5 5 0 0 1-5-5V7a5 5 0 0 1 5-5m10 2H7a3 3 0 0 0-3 3v10a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V7a3 3 0 0 0-3-3m-5 3.5A4.5 4.5 0 1 1 7.5 12 4.5 4.5 0 0 1 12 7.5m0 2A2.5 2.5 0 1 0 14.5 12 2.5 2.5 0 0 0 12 9.5m4.8-3.2a1.2 1.2 0 1 1-1.2 1.2 1.2 1.2 0 0 1 1.2-1.2" />
|
||
</svg>
|
||
<svg v-else-if="channelIcon(entry.item.channel) === 'email'" viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
|
||
<path d="M20 4H4a2 2 0 0 0-2 2v.4l10 5.6 10-5.6V6a2 2 0 0 0-2-2m0 4.2-7.4 4.14a1.25 1.25 0 0 1-1.2 0L4 8.2V18a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2z" />
|
||
</svg>
|
||
<svg v-else viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
|
||
<path d="M6.62 10.79a15.47 15.47 0 0 0 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1C10.07 21 3 13.93 3 5c0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.24.2 2.45.57 3.57.11.35.03.74-.25 1.02z" />
|
||
</svg>
|
||
</span>
|
||
<span>{{ formatStamp(entry.item.at) }}</span>
|
||
<span
|
||
v-if="messageDeliveryUiState(entry.item) !== 'none'"
|
||
class="ml-1 inline-flex items-center align-middle text-base-content/70"
|
||
:title="messageDeliveryLabel(entry.item)"
|
||
>
|
||
<span
|
||
v-if="messageDeliveryUiState(entry.item) === 'sending'"
|
||
class="inline-block h-2.5 w-2.5 animate-spin rounded-full border border-current border-t-transparent"
|
||
/>
|
||
<span
|
||
v-else-if="messageDeliveryUiState(entry.item) === 'sent'"
|
||
class="text-[10px] leading-none"
|
||
>
|
||
✓
|
||
</span>
|
||
<span
|
||
v-else-if="messageDeliveryUiState(entry.item) === 'delivered'"
|
||
class="text-[10px] leading-none tracking-[-0.12em]"
|
||
>
|
||
✓✓
|
||
</span>
|
||
<span
|
||
v-else-if="messageDeliveryUiState(entry.item) === 'failed'"
|
||
class="text-[10px] font-semibold leading-none text-error"
|
||
>
|
||
!
|
||
</span>
|
||
</span>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
v-if="commPinContextMenu.open"
|
||
class="comm-pin-context-menu"
|
||
:style="{ left: `${commPinContextMenu.x}px`, top: `${commPinContextMenu.y}px` }"
|
||
@click.stop
|
||
>
|
||
<button
|
||
class="comm-pin-context-menu-item"
|
||
:disabled="commPinToggling"
|
||
@click="applyCommPinContextAction"
|
||
>
|
||
{{ commPinContextActionLabel }}
|
||
</button>
|
||
</div>
|
||
|
||
<div class="sticky bottom-0 z-10 mt-0 border-t border-base-300 bg-base-100/95 px-3 pt-3 backdrop-blur">
|
||
<div class="absolute left-1/2 top-0 z-20 -translate-x-1/2 -translate-y-1/2">
|
||
<div
|
||
class="dropdown dropdown-top dropdown-center"
|
||
:class="{ 'dropdown-open': commQuickMenuOpen }"
|
||
@focusout="closeCommQuickMenu"
|
||
>
|
||
<button
|
||
tabindex="0"
|
||
type="button"
|
||
class="btn btn-sm btn-circle border border-base-300 bg-base-100 text-base-content/85 hover:bg-base-200"
|
||
title="Add item"
|
||
@click.stop="toggleCommQuickMenu"
|
||
>
|
||
+
|
||
</button>
|
||
<ul tabindex="0" class="dropdown-content menu menu-sm mb-2 w-56 rounded-xl border border-base-300 bg-base-100 p-2 shadow-xl">
|
||
<li>
|
||
<button @click="openCommEventModal('planned')">
|
||
Plan event
|
||
</button>
|
||
</li>
|
||
<li>
|
||
<button @click="openCommEventModal('logged')">
|
||
Log past event
|
||
</button>
|
||
</li>
|
||
<li>
|
||
<button @click="openCommDocumentModal">
|
||
Attach document
|
||
</button>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
<div
|
||
class="comm-input-wrap"
|
||
:class="[
|
||
contextPickerEnabled ? 'context-scope-block context-scope-block-active' : '',
|
||
hasContextScope('message') ? 'context-scope-block-selected' : '',
|
||
]"
|
||
@click="toggleContextScope('message')"
|
||
>
|
||
<span v-if="contextPickerEnabled" class="context-scope-label">Работа с пользователем</span>
|
||
<div class="comm-input-shell">
|
||
<textarea
|
||
v-model="commDraft"
|
||
class="comm-input-textarea"
|
||
:placeholder="commComposerPlaceholder()"
|
||
:disabled="commSending || commEventSaving"
|
||
@keydown.enter="handleCommComposerEnter"
|
||
/>
|
||
|
||
<div v-if="commComposerMode === 'planned' || commComposerMode === 'logged'" class="comm-event-controls">
|
||
<input
|
||
v-model="commEventForm.startDate"
|
||
type="date"
|
||
class="input input-bordered input-xs h-7 min-h-7"
|
||
:disabled="commEventSaving"
|
||
>
|
||
<input
|
||
v-model="commEventForm.startTime"
|
||
type="time"
|
||
class="input input-bordered input-xs h-7 min-h-7"
|
||
:disabled="commEventSaving"
|
||
>
|
||
<select
|
||
v-model.number="commEventForm.durationMinutes"
|
||
class="select select-bordered select-xs h-7 min-h-7"
|
||
:disabled="commEventSaving"
|
||
>
|
||
<option :value="15">15m</option>
|
||
<option :value="30">30m</option>
|
||
<option :value="45">45m</option>
|
||
<option :value="60">60m</option>
|
||
<option :value="90">90m</option>
|
||
</select>
|
||
</div>
|
||
<div v-else-if="commComposerMode === 'document'" class="comm-event-controls">
|
||
<input
|
||
v-model="commDocumentForm.title"
|
||
type="text"
|
||
class="input input-bordered input-xs h-7 min-h-7 flex-1"
|
||
:disabled="commEventSaving"
|
||
placeholder="Document title (optional)"
|
||
>
|
||
</div>
|
||
|
||
<p v-if="commEventError && commComposerMode !== 'message'" class="comm-event-error text-xs text-error">
|
||
{{ commEventError }}
|
||
</p>
|
||
<p v-if="commMicError" class="comm-mic-error text-xs text-error">
|
||
{{ commMicError }}
|
||
</p>
|
||
|
||
<div v-if="commComposerMode === 'message'" class="comm-input-channel dropdown dropdown-top not-prose">
|
||
<button
|
||
tabindex="0"
|
||
class="btn btn-ghost btn-xs h-7 min-h-7 px-1 text-xs font-medium"
|
||
:disabled="commSending"
|
||
:title="`Channel: ${commSendChannel}`"
|
||
>
|
||
<span class="mr-1">{{ commSendChannel || "Channel" }}</span>
|
||
<svg viewBox="0 0 20 20" class="h-3.5 w-3.5 fill-current">
|
||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" />
|
||
</svg>
|
||
</button>
|
||
<ul tabindex="-1" class="dropdown-content menu menu-sm bg-base-200 rounded-box my-2 w-40 border border-base-300 shadow-2xl">
|
||
<li v-for="channel in commSendChannelOptions" :key="`comm-send-menu-${channel}`">
|
||
<button @click="commSendChannel = channel">
|
||
<span>{{ channel }}</span>
|
||
<span v-if="commSendChannel === channel">✓</span>
|
||
</button>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div class="comm-input-actions">
|
||
<button
|
||
v-if="commComposerMode !== 'message'"
|
||
class="btn btn-xs btn-circle border border-base-300 bg-base-100 text-base-content/80 hover:bg-base-200"
|
||
:disabled="commEventSaving"
|
||
title="Back to message"
|
||
@click="closeCommEventModal"
|
||
>
|
||
<svg viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
|
||
<path d="M20 11H7.83l4.58-4.59L11 5l-7 7 7 7 1.41-1.41L7.83 13H20z" />
|
||
</svg>
|
||
</button>
|
||
<CrmVoiceDictationButton
|
||
class="btn btn-xs btn-circle border border-base-300 bg-base-100 text-base-content/80 hover:bg-base-200"
|
||
:class="commRecording || commTranscribing ? 'comm-mic-active' : ''"
|
||
:disabled="commSending || commEventSaving"
|
||
:session-key="selectedCommThreadId"
|
||
idle-title="Voice input"
|
||
recording-title="Stop and insert transcript"
|
||
transcribing-title="Transcribing..."
|
||
@update:recording="commRecording = $event"
|
||
@update:transcribing="commTranscribing = $event"
|
||
@transcript="onCommDictationTranscript"
|
||
@error="commMicError = $event"
|
||
/>
|
||
|
||
<button
|
||
class="btn btn-sm btn-circle border-0 bg-[#5865f2] text-white hover:bg-[#4752c4]"
|
||
:disabled="commSending || commEventSaving || commRecording || commTranscribing || !commDraft.trim() || (commComposerMode === 'message' && !commSendChannel)"
|
||
:title="
|
||
commComposerMode === 'message'
|
||
? `Send via ${commSendChannel}`
|
||
: commComposerMode === 'logged'
|
||
? 'Save log event'
|
||
: commComposerMode === 'document'
|
||
? 'Save document'
|
||
: 'Create event'
|
||
"
|
||
@click="handleCommComposerSubmit"
|
||
>
|
||
<svg viewBox="0 0 24 24" class="h-4 w-4 fill-current" :class="commSending ? 'opacity-50' : ''">
|
||
<path d="M4.5 19.5 21 12 4.5 4.5l.02 5.84L15 12l-10.48 1.66z" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else class="flex h-full items-center justify-center text-sm text-base-content/60">
|
||
No communication history.
|
||
</div>
|
||
</article>
|
||
|
||
<CrmCommunicationsContextSidebar
|
||
:selected-workspace-contact-documents="selectedWorkspaceContactDocuments"
|
||
:contact-right-panel-mode="contactRightPanelMode"
|
||
:on-contact-right-panel-mode-change="(mode) => { contactRightPanelMode = mode; }"
|
||
:selected-document-id="selectedDocumentId"
|
||
:on-selected-document-id-change="(documentId) => { selectedDocumentId = documentId; }"
|
||
:contact-documents-search="contactDocumentsSearch"
|
||
:on-contact-documents-search-input="(value) => { contactDocumentsSearch = value; }"
|
||
:filtered-selected-workspace-contact-documents="filteredSelectedWorkspaceContactDocuments"
|
||
:format-stamp="formatStamp"
|
||
:open-documents-tab="openDocumentsTab"
|
||
:selected-workspace-deal="selectedWorkspaceDeal"
|
||
:is-review-highlighted-deal="isReviewHighlightedDeal"
|
||
:context-picker-enabled="contextPickerEnabled"
|
||
:has-context-scope="hasContextScope"
|
||
:toggle-context-scope="toggleContextScope"
|
||
:format-deal-headline="formatDealHeadline"
|
||
:selected-workspace-deal-subtitle="selectedWorkspaceDealSubtitle"
|
||
:selected-workspace-deal-steps="selectedWorkspaceDealSteps"
|
||
:selected-deal-steps-expanded="selectedDealStepsExpanded"
|
||
:on-selected-deal-steps-expanded-change="(value) => { selectedDealStepsExpanded = value; }"
|
||
:is-deal-step-done="isDealStepDone"
|
||
:format-deal-step-meta="formatDealStepMeta"
|
||
:active-review-contact-diff="activeReviewContactDiff"
|
||
:selected-workspace-contact="selectedWorkspaceContact"
|
||
/>
|
||
</div>
|
||
</section>
|
||
|
||
<CrmDocumentsPanel
|
||
v-else-if="selectedTab === 'documents'"
|
||
:document-search="documentSearch"
|
||
:document-sort-mode="documentSortMode"
|
||
:document-sort-options="documentSortOptions"
|
||
:filtered-documents="filteredDocuments"
|
||
:selected-document-id="selectedDocumentId"
|
||
:selected-document="selectedDocument"
|
||
:format-document-scope="formatDocumentScope"
|
||
:format-stamp="formatStamp"
|
||
@update:document-search="documentSearch = $event"
|
||
@update:document-sort-mode="documentSortMode = $event"
|
||
@select-document="selectedDocumentId = $event"
|
||
@update-selected-document-body="updateSelectedDocumentBody"
|
||
@delete-document="deleteWorkspaceDocumentById"
|
||
/>
|
||
|
||
<CrmChangeReviewOverlay
|
||
:visible="reviewActive && selectedTab === 'communications'"
|
||
:active-change-step-number="activeChangeStepNumber"
|
||
:active-change-items="activeChangeItems"
|
||
:active-change-item="activeChangeItem"
|
||
:active-change-index="activeChangeIndex"
|
||
:rollbackable-count="rollbackableCount"
|
||
:change-action-busy="changeActionBusy"
|
||
:describe-change-entity="describeChangeEntity"
|
||
:describe-change-action="describeChangeAction"
|
||
@close="finishReview(true)"
|
||
@open-item-target="openChangeItemTarget"
|
||
@rollback-item="rollbackChangeItemById"
|
||
@rollback-all="rollbackSelectedChangeItems"
|
||
@prev-step="goToPreviousChangeStep"
|
||
@next-step="goToNextChangeStep"
|
||
@done="finishReview(true)"
|
||
/>
|
||
|
||
</div>
|
||
|
||
</div>
|
||
</main>
|
||
</div>
|
||
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.comm-input-wrap {
|
||
display: grid;
|
||
gap: 6px;
|
||
}
|
||
|
||
.comm-input-shell {
|
||
position: relative;
|
||
}
|
||
|
||
.comm-input-textarea {
|
||
width: 100%;
|
||
min-height: 96px;
|
||
resize: none;
|
||
border-radius: 0;
|
||
border: 0;
|
||
background: transparent;
|
||
color: var(--color-base-content);
|
||
padding: 10px 88px 36px 12px;
|
||
font-size: 13px;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.comm-event-controls {
|
||
position: absolute;
|
||
left: 10px;
|
||
bottom: 8px;
|
||
display: grid;
|
||
grid-template-columns: 118px 88px 64px;
|
||
gap: 6px;
|
||
align-items: center;
|
||
}
|
||
|
||
.comm-event-controls :is(input, select) {
|
||
font-size: 11px;
|
||
padding-inline: 8px;
|
||
}
|
||
|
||
.comm-event-error {
|
||
position: absolute;
|
||
left: 12px;
|
||
top: 8px;
|
||
}
|
||
|
||
.comm-mic-error {
|
||
position: absolute;
|
||
left: 12px;
|
||
top: 8px;
|
||
max-width: 65%;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.comm-input-textarea::placeholder {
|
||
color: color-mix(in oklab, var(--color-base-content) 45%, transparent);
|
||
}
|
||
|
||
.comm-input-textarea:focus {
|
||
outline: none;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.comm-input-channel {
|
||
position: absolute;
|
||
left: 10px;
|
||
bottom: 8px;
|
||
}
|
||
|
||
.comm-input-actions {
|
||
position: absolute;
|
||
right: 10px;
|
||
bottom: 8px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.comm-mic-active {
|
||
border-color: rgba(255, 95, 95, 0.7) !important;
|
||
background: rgba(255, 95, 95, 0.12) !important;
|
||
color: rgba(185, 30, 30, 0.9) !important;
|
||
}
|
||
|
||
.comm-thread-surface {
|
||
background-color: #eaf3ff;
|
||
background-image:
|
||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='132' height='132' viewBox='0 0 132 132'%3E%3Cg fill='none' stroke='%2395acd3' stroke-width='1.2' stroke-linecap='round' stroke-linejoin='round' opacity='0.22'%3E%3Cpath d='M16 20h14a6 6 0 0 1 0 12h-7l-5 4v-4h-2a6 6 0 0 1 0-12z'/%3E%3Ccircle cx='92' cy='28' r='6'/%3E%3Cpath d='M88 62h18a5 5 0 0 1 0 10H96l-4 3v-3h-4a5 5 0 0 1 0-10z'/%3E%3Cpath d='M24 86h8m-4-4v8'/%3E%3Cpath d='M74 96l2.3 4.8 5.3.8-3.8 3.7.9 5.2-4.7-2.4-4.7 2.4.9-5.2-3.8-3.7 5.3-.8z'/%3E%3C/g%3E%3C/svg%3E");
|
||
background-size: 132px 132px;
|
||
background-repeat: repeat;
|
||
}
|
||
|
||
.comm-thread-surface::after {
|
||
content: "";
|
||
display: block;
|
||
height: 14px;
|
||
}
|
||
|
||
.comm-pin-context-menu {
|
||
position: fixed;
|
||
z-index: 60;
|
||
min-width: 128px;
|
||
border: 1px solid color-mix(in oklab, var(--color-base-content) 14%, transparent);
|
||
border-radius: 10px;
|
||
background: var(--color-base-100);
|
||
box-shadow: 0 16px 30px rgba(11, 23, 46, 0.22);
|
||
padding: 4px;
|
||
}
|
||
|
||
.comm-pin-context-menu-item {
|
||
width: 100%;
|
||
border: 0;
|
||
border-radius: 8px;
|
||
background: transparent;
|
||
color: color-mix(in oklab, var(--color-base-content) 88%, transparent);
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
line-height: 1.2;
|
||
text-align: left;
|
||
padding: 7px 9px;
|
||
transition: background-color 120ms ease;
|
||
}
|
||
|
||
.comm-pin-context-menu-item:hover:not(:disabled) {
|
||
background: color-mix(in oklab, var(--color-base-200) 82%, transparent);
|
||
}
|
||
|
||
.comm-pin-context-menu-item:disabled {
|
||
opacity: 0.55;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.comm-event-modal {
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 25;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 16px;
|
||
background: rgba(14, 22, 38, 0.42);
|
||
backdrop-filter: blur(2px);
|
||
}
|
||
|
||
.comm-event-modal-card {
|
||
width: min(520px, 100%);
|
||
border: 1px solid color-mix(in oklab, var(--color-base-content) 14%, transparent);
|
||
border-radius: 14px;
|
||
background: var(--color-base-100);
|
||
box-shadow: 0 24px 48px rgba(11, 23, 46, 0.25);
|
||
padding: 14px;
|
||
}
|
||
|
||
|
||
.feed-chart-wrap {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 12px;
|
||
background:
|
||
radial-gradient(circle at 20% 20%, rgba(30, 107, 255, 0.12), transparent 45%),
|
||
radial-gradient(circle at 80% 80%, rgba(30, 107, 255, 0.08), transparent 45%),
|
||
#f6f9ff;
|
||
border-bottom: 1px solid rgba(30, 107, 255, 0.15);
|
||
}
|
||
|
||
.feed-chart-bars {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
gap: 10px;
|
||
width: 100%;
|
||
max-width: 280px;
|
||
height: 100%;
|
||
}
|
||
|
||
.feed-chart-bars span {
|
||
flex: 1 1 0;
|
||
border-radius: 999px 999px 6px 6px;
|
||
background: linear-gradient(180deg, rgba(30, 107, 255, 0.9), rgba(30, 107, 255, 0.35));
|
||
}
|
||
|
||
.feed-chart-pie {
|
||
width: min(140px, 70%);
|
||
aspect-ratio: 1;
|
||
border-radius: 999px;
|
||
background: conic-gradient(
|
||
rgba(30, 107, 255, 0.92) 0 42%,
|
||
rgba(30, 107, 255, 0.55) 42% 73%,
|
||
rgba(30, 107, 255, 0.25) 73% 100%
|
||
);
|
||
box-shadow: 0 8px 24px rgba(30, 107, 255, 0.2);
|
||
}
|
||
|
||
.call-wave-card {
|
||
background: var(--color-base-100);
|
||
}
|
||
|
||
.comm-call-wave-wrap {
|
||
position: relative;
|
||
}
|
||
|
||
.comm-call-wave {
|
||
height: 30px;
|
||
width: 100%;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.comm-call-wave :deep(wave) {
|
||
display: block;
|
||
height: 100% !important;
|
||
}
|
||
|
||
.comm-call-wave :deep(canvas) {
|
||
height: 100% !important;
|
||
}
|
||
|
||
.call-wave-center-play {
|
||
position: absolute;
|
||
left: 50%;
|
||
top: 50%;
|
||
transform: translate(-50%, -50%);
|
||
z-index: 2;
|
||
width: 34px;
|
||
height: 34px;
|
||
border: 1px solid color-mix(in oklab, var(--color-base-content) 22%, transparent);
|
||
border-radius: 999px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: color-mix(in oklab, var(--color-base-content) 88%, transparent);
|
||
background: color-mix(in oklab, var(--color-base-100) 72%, transparent);
|
||
backdrop-filter: blur(2px);
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
transition: opacity 140ms ease, background-color 140ms ease;
|
||
}
|
||
|
||
.comm-call-wave-wrap:hover .call-wave-center-play,
|
||
.comm-call-wave-wrap:focus-within .call-wave-center-play {
|
||
opacity: 1;
|
||
pointer-events: auto;
|
||
}
|
||
|
||
.call-wave-center-play:hover:not(:disabled) {
|
||
background: color-mix(in oklab, var(--color-base-100) 58%, var(--color-base-200));
|
||
}
|
||
|
||
.call-wave-center-play:disabled {
|
||
opacity: 0.45;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.call-transcript-toggle {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
border: 1px solid color-mix(in oklab, var(--color-base-content) 18%, transparent);
|
||
border-radius: 999px;
|
||
padding: 4px 10px;
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
color: color-mix(in oklab, var(--color-base-content) 72%, transparent);
|
||
background: color-mix(in oklab, var(--color-base-100) 90%, transparent);
|
||
}
|
||
|
||
.call-transcript-toggle:hover {
|
||
background: color-mix(in oklab, var(--color-base-100) 72%, var(--color-base-200));
|
||
}
|
||
|
||
.call-transcript-loader {
|
||
display: flex;
|
||
align-items: flex-end;
|
||
justify-content: center;
|
||
gap: 4px;
|
||
height: 28px;
|
||
}
|
||
|
||
.call-transcript-loader span {
|
||
display: block;
|
||
width: 4px;
|
||
border-radius: 999px;
|
||
background: color-mix(in oklab, var(--color-base-content) 40%, transparent);
|
||
animation: transcript-ladder 1s ease-in-out infinite;
|
||
}
|
||
|
||
.call-transcript-loader span:nth-child(1) {
|
||
height: 8px;
|
||
animation-delay: 0ms;
|
||
}
|
||
|
||
.call-transcript-loader span:nth-child(2) {
|
||
height: 14px;
|
||
animation-delay: 120ms;
|
||
}
|
||
|
||
.call-transcript-loader span:nth-child(3) {
|
||
height: 20px;
|
||
animation-delay: 240ms;
|
||
}
|
||
|
||
.call-transcript-loader span:nth-child(4) {
|
||
height: 14px;
|
||
animation-delay: 360ms;
|
||
}
|
||
|
||
@keyframes transcript-ladder {
|
||
0%, 100% {
|
||
transform: scaleY(0.55);
|
||
opacity: 0.45;
|
||
}
|
||
50% {
|
||
transform: scaleY(1);
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
.accordion-enter-active,
|
||
.accordion-leave-active {
|
||
transition: all 160ms ease;
|
||
}
|
||
|
||
.accordion-enter-from,
|
||
.accordion-leave-to {
|
||
opacity: 0;
|
||
transform: translateY(-4px);
|
||
}
|
||
|
||
.context-scope-block {
|
||
position: relative;
|
||
cursor: crosshair;
|
||
transition: box-shadow 220ms ease, outline-color 220ms ease, transform 220ms ease;
|
||
}
|
||
|
||
.context-scope-block-active {
|
||
outline: 2px solid color-mix(in oklab, var(--color-primary) 58%, transparent);
|
||
outline-offset: 2px;
|
||
box-shadow: 0 0 0 1px color-mix(in oklab, var(--color-primary) 30%, transparent) inset;
|
||
}
|
||
|
||
.context-scope-block-selected {
|
||
outline: 2px solid color-mix(in oklab, var(--color-primary) 72%, transparent);
|
||
outline-offset: 2px;
|
||
box-shadow: 0 0 0 2px color-mix(in oklab, var(--color-primary) 22%, transparent) inset;
|
||
}
|
||
|
||
.context-scope-label {
|
||
position: absolute;
|
||
top: 6px;
|
||
left: 8px;
|
||
z-index: 20;
|
||
border-radius: 6px;
|
||
border: 1px solid color-mix(in oklab, var(--color-primary) 40%, transparent);
|
||
background: color-mix(in oklab, var(--color-base-100) 86%, var(--color-primary));
|
||
padding: 2px 7px;
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.02em;
|
||
color: color-mix(in oklab, var(--color-primary-content) 65%, var(--color-base-content));
|
||
}
|
||
</style>
|