Files
clientsflow/frontend/app/pages/index.vue

7445 lines
276 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted } from "vue";
import meQuery from "~~/graphql/operations/me.graphql?raw";
import chatMessagesQuery from "~~/graphql/operations/chat-messages.graphql?raw";
import dashboardQuery from "~~/graphql/operations/dashboard.graphql?raw";
import getClientTimelineQuery from "~~/graphql/operations/get-client-timeline.graphql?raw";
import loginMutation from "~~/graphql/operations/login.graphql?raw";
import logoutMutation from "~~/graphql/operations/logout.graphql?raw";
import logPilotNoteMutation from "~~/graphql/operations/log-pilot-note.graphql?raw";
import createCalendarEventMutation from "~~/graphql/operations/create-calendar-event.graphql?raw";
import archiveCalendarEventMutation from "~~/graphql/operations/archive-calendar-event.graphql?raw";
import createCommunicationMutation from "~~/graphql/operations/create-communication.graphql?raw";
import createWorkspaceDocumentMutation from "~~/graphql/operations/create-workspace-document.graphql?raw";
import updateCommunicationTranscriptMutation from "~~/graphql/operations/update-communication-transcript.graphql?raw";
import updateFeedDecisionMutation from "~~/graphql/operations/update-feed-decision.graphql?raw";
import chatConversationsQuery from "~~/graphql/operations/chat-conversations.graphql?raw";
import createChatConversationMutation from "~~/graphql/operations/create-chat-conversation.graphql?raw";
import selectChatConversationMutation from "~~/graphql/operations/select-chat-conversation.graphql?raw";
import archiveChatConversationMutation from "~~/graphql/operations/archive-chat-conversation.graphql?raw";
import toggleContactPinMutation from "~~/graphql/operations/toggle-contact-pin.graphql?raw";
import setContactInboxHiddenMutation from "~~/graphql/operations/set-contact-inbox-hidden.graphql?raw";
import confirmLatestChangeSetMutation from "~~/graphql/operations/confirm-latest-change-set.graphql?raw";
import rollbackLatestChangeSetMutation from "~~/graphql/operations/rollback-latest-change-set.graphql?raw";
import rollbackChangeSetItemsMutation from "~~/graphql/operations/rollback-change-set-items.graphql?raw";
import {
buildContactDocumentScope,
formatDocumentScope,
isDocumentLinkedToContact,
} from "../composables/useWorkspaceDocuments";
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" | "company" | "country";
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;
company: string;
country: string;
location: 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;
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;
company: 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 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);
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 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([loadPilotMessages(), loadChatConversations(), refreshCrmData()]);
},
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("");
const loginPhone = ref("");
const loginPassword = ref("");
const loginError = ref<string | null>(null);
const loginBusy = ref(false);
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;
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" && m.text.trim() === 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 gqlFetch<TData>(query: string, variables?: Record<string, unknown>) {
const headers = process.server ? useRequestHeaders(["cookie"]) : undefined;
const result = await $fetch<{ data?: TData; errors?: Array<{ message: string }> }>("/api/graphql", {
method: "POST",
headers,
body: { query, variables },
});
if (result.errors?.length) {
throw new Error(result.errors[0]?.message || "GraphQL request failed");
}
if (!result.data) {
throw new Error("GraphQL returned empty payload");
}
return result.data;
}
async function loadPilotMessages() {
const data = await gqlFetch<{ chatMessages: PilotMessage[] }>(chatMessagesQuery);
pilotMessages.value = data.chatMessages ?? [];
syncPilotChatFromHistory(pilotMessages.value);
}
async function loadChatConversations() {
chatThreadsLoading.value = true;
try {
const data = await gqlFetch<{ chatConversations: ChatConversation[] }>(chatConversationsQuery);
chatConversations.value = data.chatConversations ?? [];
} finally {
chatThreadsLoading.value = false;
}
}
async function loadMe() {
const data = await gqlFetch<{
me: {
user: { id: string; phone: string; name: string };
team: { id: string; name: string };
conversation: { id: string; title: string };
};
}>(
meQuery,
);
authMe.value = data.me;
}
const authResolved = ref(false);
async function bootstrapSession() {
try {
await loadMe();
if (!authMe.value) {
stopCrmRealtime();
pilotMessages.value = [];
chatConversations.value = [];
clientTimelineItems.value = [];
telegramConnectStatus.value = "not_connected";
telegramConnections.value = [];
telegramConnectUrl.value = "";
return;
}
await Promise.all([loadPilotMessages(), loadChatConversations(), refreshCrmData(), loadTelegramConnectStatus()]);
if (process.client) {
startCrmRealtime();
}
} catch {
stopCrmRealtime();
authMe.value = null;
pilotMessages.value = [];
chatConversations.value = [];
clientTimelineItems.value = [];
telegramConnectStatus.value = "not_connected";
telegramConnections.value = [];
telegramConnectUrl.value = "";
} finally {
authResolved.value = true;
}
}
async function createNewChatConversation() {
if (chatCreating.value) return;
chatThreadPickerOpen.value = false;
chatCreating.value = true;
try {
await gqlFetch<{ createChatConversation: ChatConversation }>(createChatConversationMutation);
await Promise.all([loadMe(), loadPilotMessages(), loadChatConversations()]);
} 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 gqlFetch<{ selectChatConversation: { ok: boolean } }>(selectChatConversationMutation, { id });
await Promise.all([loadMe(), loadPilotMessages(), loadChatConversations()]);
} finally {
chatSwitching.value = false;
}
}
async function archiveChatConversation(id: string) {
if (!id || chatArchivingId.value) return;
chatArchivingId.value = id;
try {
await gqlFetch<{ archiveChatConversation: { ok: boolean } }>(archiveChatConversationMutation, { id });
await Promise.all([loadMe(), loadPilotMessages(), loadChatConversations()]);
} finally {
chatArchivingId.value = "";
}
}
async function login() {
loginError.value = null;
loginBusy.value = true;
try {
await gqlFetch<{ login: { ok: boolean } }>(loginMutation, {
phone: loginPhone.value,
password: loginPassword.value,
});
await loadMe();
startPilotBackgroundPolling();
startCrmRealtime();
await Promise.all([loadPilotMessages(), loadChatConversations(), refreshCrmData(), loadTelegramConnectStatus()]);
} catch (e: any) {
loginError.value = e?.data?.message || e?.message || "Login failed";
} finally {
loginBusy.value = false;
}
}
async function logout() {
await gqlFetch<{ logout: { ok: boolean } }>(logoutMutation);
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 = "";
}
async function refreshCrmData() {
const data = await gqlFetch<{
dashboard: {
contacts: Contact[];
communications: CommItem[];
contactInboxes: ContactInbox[];
calendar: CalendarEvent[];
deals: Deal[];
feed: FeedCard[];
pins: CommPin[];
documents: WorkspaceDocument[];
};
}>(dashboardQuery);
contacts.value = data.dashboard.contacts ?? [];
commItems.value = data.dashboard.communications ?? [];
contactInboxes.value = data.dashboard.contactInboxes ?? [];
calendarEvents.value = data.dashboard.calendar ?? [];
deals.value = data.dashboard.deals ?? [];
feedCards.value = data.dashboard.feed ?? [];
commPins.value = data.dashboard.pins ?? [];
documents.value = data.dashboard.documents ?? [];
// Derive channels per contact from communication items.
const byName = new Map<string, Set<string>>();
for (const item of commItems.value) {
if (!byName.has(item.contact)) byName.set(item.contact, new Set());
byName.get(item.contact)?.add(item.channel);
}
contacts.value = contacts.value.map((c) => ({
...c,
channels: Array.from(byName.get(c.name) ?? []),
}));
await refreshSelectedClientTimeline();
}
async function loadClientTimeline(contactId: string, limit = 500) {
const normalizedContactId = String(contactId ?? "").trim();
if (!normalizedContactId) {
clientTimelineItems.value = [];
return;
}
const requestToken = ++clientTimelineRequestToken;
const data = await gqlFetch<{ getClientTimeline: ClientTimelineItem[] }>(getClientTimelineQuery, {
contactId: normalizedContactId,
limit,
});
if (requestToken !== clientTimelineRequestToken) return;
clientTimelineItems.value = data.getClientTimeline ?? [];
}
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([refreshCrmData(), 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 = rawText.trim();
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([loadPilotMessages(), loadChatConversations(), refreshCrmData()]);
}
}
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);
}
function destroyAllCommCallWaves() {
for (const itemId of commCallWaveSurfers.keys()) {
destroyCommCallWave(itemId);
}
commCallWaveHosts.clear();
}
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 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 audioUrl = getCallAudioUrl(callItem);
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 ws = WaveSurfer.create({
container: host,
height: 30,
waveColor: "rgba(180, 206, 255, 0.88)",
progressColor: "rgba(118, 157, 248, 0.95)",
cursorWidth: 0,
interact: false,
normalize: true,
barWidth: 0,
});
try {
if (!audioUrl) throw new Error("missing_audio_url");
await ws.load(audioUrl);
} catch {
await ws.load("", [peaks], durationSeconds);
}
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 = text.trim();
if (!next) return "";
const merged = pilotInput.value.trim() ? `${pilotInput.value.trim()} ${next}` : next;
pilotInput.value = merged;
return merged;
}
function getAudioContextCtor(): typeof AudioContext {
const ctor = (globalThis as any).AudioContext ?? (globalThis as any).webkitAudioContext;
if (!ctor) {
throw new Error("AudioContext is not supported in this browser");
}
return ctor as typeof AudioContext;
}
function toMonoFloat32(buffer: AudioBuffer) {
if (buffer.numberOfChannels === 1) {
return buffer.getChannelData(0);
}
const out = new Float32Array(buffer.length);
for (let channel = 0; channel < buffer.numberOfChannels; channel += 1) {
const input = buffer.getChannelData(channel);
for (let i = 0; i < buffer.length; i += 1) {
const prev = out[i] ?? 0;
out[i] = prev + (input[i] ?? 0);
}
}
for (let i = 0; i < out.length; i += 1) {
out[i] = (out[i] ?? 0) / buffer.numberOfChannels;
}
return out;
}
function resampleFloat32Linear(input: Float32Array, fromRate: number, toRate: number) {
if (fromRate === toRate) return input;
const ratio = fromRate / toRate;
const outLength = Math.max(1, Math.round(input.length / ratio));
const out = new Float32Array(outLength);
for (let i = 0; i < outLength; i += 1) {
const position = i * ratio;
const left = Math.floor(position);
const right = Math.min(input.length - 1, left + 1);
const frac = position - left;
out[i] = (input[left] ?? 0) * (1 - frac) + (input[right] ?? 0) * frac;
}
return out;
}
function floatToPcm16Bytes(input: Float32Array) {
const out = new Uint8Array(input.length * 2);
const view = new DataView(out.buffer);
for (let i = 0; i < input.length; i += 1) {
const sample = Math.max(-1, Math.min(1, input[i] ?? 0));
const value = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
view.setInt16(i * 2, Math.round(value), true);
}
return out;
}
function bytesToBase64(bytes: Uint8Array) {
let binary = "";
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
}
return btoa(binary);
}
async function decodeAudioBlobToPcm16(blob: Blob) {
const AudioContextCtor = getAudioContextCtor();
const context = new AudioContextCtor();
try {
const arrayBuffer = await blob.arrayBuffer();
const decoded = await context.decodeAudioData(arrayBuffer);
const mono = toMonoFloat32(decoded);
const targetSampleRate = 16000;
const resampled = resampleFloat32Linear(mono, decoded.sampleRate, targetSampleRate);
const pcm16 = floatToPcm16Bytes(resampled);
return {
audioBase64: bytesToBase64(pcm16),
sampleRate: targetSampleRate,
};
} finally {
await context.close();
}
}
async function transcribeAudioBlob(blob: Blob) {
const payload = await decodeAudioBlobToPcm16(blob);
const result = await $fetch<{ text?: string }>("/api/pilot-transcribe", {
method: "POST",
body: payload,
});
return String(result?.text ?? "").trim();
}
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 changeSelectionByItemId = ref<Record<string, boolean>>({});
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 activeChangeApproved = computed(() => {
const item = activeChangeItem.value;
if (!item || item.rolledBack) return true;
return changeSelectionByItemId.value[item.id] !== false;
});
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),
};
});
const selectedRollbackItemIds = computed(() =>
activeChangeItems.value
.filter((item) => !item.rolledBack && changeSelectionByItemId.value[item.id] === false)
.map((item) => item.id),
);
const selectedRollbackCount = computed(() => selectedRollbackItemIds.value.length);
function setReviewApprovalForAll(approved: boolean) {
const next: Record<string, boolean> = {};
for (const item of activeChangeItems.value) {
next[item.id] = item.rolledBack ? true : approved;
}
changeSelectionByItemId.value = next;
}
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 isReviewItemApproved(item: PilotChangeItem | null | undefined) {
if (!item || item.rolledBack) return true;
return changeSelectionByItemId.value[item.id] !== false;
}
function setReviewItemApproval(itemId: string, approved: boolean) {
const target = activeChangeItems.value.find((item) => item.id === itemId);
if (!target || target.rolledBack) return;
changeSelectionByItemId.value = {
...changeSelectionByItemId.value,
[itemId]: approved,
};
}
function onReviewItemApprovalInput(itemId: string, event: Event) {
const input = event.target as HTMLInputElement | null;
setReviewItemApproval(itemId, Boolean(input?.checked));
}
function onActiveReviewApprovalInput(event: Event) {
const item = activeChangeItem.value;
if (!item) return;
const input = event.target as HTMLInputElement | null;
setReviewItemApproval(item.id, Boolean(input?.checked));
}
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 (selectedChatId.value || authMe.value?.conversation.id || "pilot").trim();
}
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 ensureChangeSelectionSeeded(message: PilotMessage | null | undefined) {
if (!message?.changeItems?.length) {
changeSelectionByItemId.value = {};
return;
}
const next: Record<string, boolean> = {};
for (const item of message.changeItems) {
const prev = changeSelectionByItemId.value[item.id];
next[item.id] = typeof prev === "boolean" ? prev : true;
}
changeSelectionByItemId.value = next;
}
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;
ensureChangeSelectionSeeded(activeChangeMessage.value);
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;
changeSelectionByItemId.value = {};
}
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 gqlFetch<{ confirmLatestChangeSet: { ok: boolean } }>(confirmLatestChangeSetMutation);
await loadPilotMessages();
} finally {
changeActionBusy.value = false;
}
}
async function rollbackLatestChangeSet() {
if (changeActionBusy.value || !latestChangeMessage.value?.changeSetId) return;
changeActionBusy.value = true;
try {
await gqlFetch<{ rollbackLatestChangeSet: { ok: boolean } }>(rollbackLatestChangeSetMutation);
await Promise.all([loadPilotMessages(), refreshCrmData(), loadChatConversations()]);
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 = selectedRollbackItemIds.value;
if (changeActionBusy.value || !targetChangeSetId || itemIds.length === 0) return;
changeActionBusy.value = true;
try {
await gqlFetch<{ rollbackChangeSetItems: { ok: boolean } }>(rollbackChangeSetItemsMutation, {
changeSetId: targetChangeSetId,
itemIds,
});
await Promise.all([loadPilotMessages(), refreshCrmData(), loadChatConversations()]);
ensureChangeSelectionSeeded(activeChangeMessage.value);
} 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;
changeSelectionByItemId.value = {};
syncPathFromUi(push);
}
watch(
() => activeChangeMessage.value?.changeSetId,
() => {
if (!activeChangeSetId.value.trim()) return;
ensureChangeSelectionSeeded(activeChangeMessage.value);
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 =
typeof navigator !== "undefined" &&
typeof MediaRecorder !== "undefined" &&
Boolean(navigator.mediaDevices?.getUserMedia);
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;
}
clearCalendarZoomOverlay();
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 };
type CalendarZoomGhost = {
title: string;
subtitle?: string;
};
const calendarContentWrapRef = ref<HTMLElement | null>(null);
const calendarContentScrollRef = ref<HTMLElement | null>(null);
const calendarSceneRef = ref<HTMLElement | null>(null);
const calendarZoomOverlayRef = ref<HTMLElement | null>(null);
const calendarHoveredMonthIndex = ref<number | null>(null);
const calendarHoveredWeekStartKey = ref("");
const calendarHoveredDayKey = ref("");
const calendarZoomOverlay = ref<{ active: boolean } & CalendarRect>({
active: false,
left: 0,
top: 0,
width: 0,
height: 0,
});
const calendarZoomGhost = ref<CalendarZoomGhost | null>(null);
const calendarZoomBusy = ref(false);
const calendarSceneMasked = 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 calendarZoomOverlayStyle = computed(() => ({
left: `${calendarZoomOverlay.value.left}px`,
top: `${calendarZoomOverlay.value.top}px`,
width: `${calendarZoomOverlay.value.width}px`,
height: `${calendarZoomOverlay.value.height}px`,
}));
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 clearCalendarZoomOverlay() {
calendarZoomOverlay.value = {
...calendarZoomOverlay.value,
active: false,
};
calendarZoomGhost.value = null;
}
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 zoomGhostForMonth(monthIndex: number): CalendarZoomGhost {
const item = yearMonths.value.find((entry) => entry.monthIndex === monthIndex);
if (!item) {
return {
title: new Intl.DateTimeFormat("en-US", { month: "long" }).format(new Date(calendarCursor.value.getFullYear(), monthIndex, 1)),
subtitle: "",
};
}
return {
title: item.label,
subtitle: `${item.count} events`,
};
}
function zoomGhostForWeek(startKey: string): CalendarZoomGhost {
const start = new Date(`${startKey}T00:00:00`);
const end = new Date(start);
end.setDate(start.getDate() + 6);
const row = monthRows.value.find((item) => item.startKey === startKey);
const count = row ? row.cells.reduce((sum, cell) => sum + cell.events.length, 0) : 0;
return {
title: `${formatDay(`${dayKey(start)}T00:00:00`)} - ${formatDay(`${dayKey(end)}T00:00:00`)}`,
subtitle: `${count} events`,
};
}
function zoomGhostForDay(dayKeyValue: string): CalendarZoomGhost {
const day = weekDays.value.find((entry) => entry.key === dayKeyValue);
if (!day) {
return {
title: formatDay(`${dayKeyValue}T00:00:00`),
subtitle: `${getEventsByDate(dayKeyValue).length} events`,
};
}
return {
title: `${day.label} ${day.day}`,
subtitle: `${day.events.length} events`,
};
}
function zoomGhostForCurrentView(): CalendarZoomGhost {
if (calendarView.value === "day") {
return {
title: formatDay(`${selectedDateKey.value}T00:00:00`),
subtitle: `${selectedDayEvents.value.length} events`,
};
}
if (calendarView.value === "week") {
return {
title: calendarPeriodLabel.value,
subtitle: `${weekDays.value.reduce((sum, day) => sum + day.events.length, 0)} events`,
};
}
if (calendarView.value === "month" || calendarView.value === "agenda") {
return {
title: monthLabel.value,
subtitle: `${monthCells.value.reduce((sum, cell) => sum + cell.events.length, 0)} events`,
};
}
return {
title: String(calendarCursor.value.getFullYear()),
subtitle: `${sortedEvents.value.filter((event) => new Date(event.start).getFullYear() === calendarCursor.value.getFullYear()).length} events`,
};
}
function primeCalendarRect(rect: CalendarRect) {
clearCalendarZoomOverlay();
calendarZoomOverlay.value = {
active: true,
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
};
}
function morphCalendarRect(toRect: CalendarRect) {
calendarZoomOverlay.value = {
active: true,
left: toRect.left,
top: toRect.top,
width: toRect.width,
height: toRect.height,
};
}
function nextAnimationFrame() {
return new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
}
function waitCalendarCameraTransition() {
const scene = calendarSceneRef.value;
if (!scene) {
return new Promise<void>((resolve) => {
setTimeout(() => resolve(), CALENDAR_ZOOM_DURATION_MS);
});
}
return new Promise<void>((resolve) => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
scene.removeEventListener("transitionend", onTransitionEnd);
clearTimeout(fallbackTimer);
resolve();
};
const onTransitionEnd = (event: TransitionEvent) => {
if (event.target !== scene) return;
if (event.propertyName !== "transform") return;
finish();
};
const fallbackTimer = setTimeout(() => finish(), CALENDAR_ZOOM_DURATION_MS + 160);
scene.addEventListener("transitionend", onTransitionEnd);
});
}
function cameraTransformForRect(rect: CalendarRect) {
const viewport = getCalendarCameraViewportRect();
if (!viewport) return null;
const availableWidth = Math.max(24, viewport.width - 24);
const availableHeight = Math.max(24, viewport.height - 24);
const fitScale = Math.min(availableWidth / rect.width, availableHeight / rect.height);
const scale = Math.max(1, Math.min(8, fitScale));
const targetLeft = (viewport.width - rect.width * scale) / 2;
const targetTop = (viewport.height - rect.height * scale) / 2;
return {
left: Math.round(targetLeft - rect.left * scale),
top: Math.round(targetTop - rect.top * scale),
scale,
};
}
async function resetCalendarCamera() {
calendarCameraState.value = {
active: true,
left: 0,
top: 0,
scale: 1,
durationMs: 0,
};
await nextTick();
await nextAnimationFrame();
calendarCameraState.value = {
active: false,
left: 0,
top: 0,
scale: 1,
durationMs: 0,
};
}
async function flushCalendarZoomStartFrame() {
await nextTick();
await nextAnimationFrame();
calendarZoomOverlayRef.value?.getBoundingClientRect();
await nextAnimationFrame();
}
function waitCalendarZoomTransition() {
const overlay = calendarZoomOverlayRef.value;
if (!overlay) {
return new Promise<void>((resolve) => {
setTimeout(() => resolve(), CALENDAR_ZOOM_DURATION_MS);
});
}
return new Promise<void>((resolve) => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
overlay.removeEventListener("transitionend", onTransitionEnd);
clearTimeout(fallbackTimer);
resolve();
};
const onTransitionEnd = (event: TransitionEvent) => {
if (event.target !== overlay) return;
if (!["left", "top", "width", "height"].includes(event.propertyName)) return;
finish();
};
const fallbackTimer = setTimeout(() => finish(), CALENDAR_ZOOM_DURATION_MS + 140);
overlay.addEventListener("transitionend", onTransitionEnd);
});
}
async function animateCalendarZoomIn(sourceElement: HTMLElement | null, ghost: CalendarZoomGhost, apply: () => void) {
clearCalendarZoomPrime();
calendarZoomBusy.value = true;
clearCalendarZoomOverlay();
calendarSceneMasked.value = false;
try {
calendarZoomGhost.value = ghost;
const fromRect = getElementRectInScene(sourceElement) ?? fallbackZoomOriginRectInScene();
const cameraTarget = fromRect ? cameraTransformForRect(fromRect) : null;
if (!cameraTarget) {
apply();
return;
}
calendarCameraState.value = {
active: true,
left: 0,
top: 0,
scale: 1,
durationMs: 0,
};
await nextTick();
await nextAnimationFrame();
calendarSceneRef.value?.getBoundingClientRect();
calendarCameraState.value = {
active: true,
left: cameraTarget.left,
top: cameraTarget.top,
scale: cameraTarget.scale,
durationMs: CALENDAR_ZOOM_DURATION_MS,
};
await waitCalendarCameraTransition();
// Freeze the filled block frame, then swap level while scene is masked.
// This keeps the "zoom into block -> reveal next grid inside" sequence.
calendarSceneMasked.value = true;
await nextAnimationFrame();
apply();
await nextTick();
} finally {
await resetCalendarCamera();
calendarZoomGhost.value = null;
calendarSceneMasked.value = false;
calendarZoomBusy.value = false;
}
}
async function animateCalendarZoomOut(apply: () => void, resolveTarget: () => HTMLElement | null) {
clearCalendarZoomPrime();
calendarZoomBusy.value = true;
clearCalendarZoomOverlay();
try {
calendarZoomGhost.value = zoomGhostForCurrentView();
calendarSceneMasked.value = true;
apply();
await nextTick();
const targetRect = getElementRectInScene(resolveTarget()) ?? fallbackZoomOriginRectInScene();
const cameraStart = targetRect ? cameraTransformForRect(targetRect) : null;
if (!cameraStart) {
calendarSceneMasked.value = false;
return;
}
calendarCameraState.value = {
active: true,
left: cameraStart.left,
top: cameraStart.top,
scale: cameraStart.scale,
durationMs: 0,
};
await nextTick();
await nextAnimationFrame();
calendarSceneRef.value?.getBoundingClientRect();
calendarSceneMasked.value = false;
await nextAnimationFrame();
calendarCameraState.value = {
active: true,
left: 0,
top: 0,
scale: 1,
durationMs: CALENDAR_ZOOM_DURATION_MS,
};
await waitCalendarCameraTransition();
} finally {
await resetCalendarCamera();
calendarZoomGhost.value = null;
calendarSceneMasked.value = false;
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 animateCalendarZoomIn(sourceElement, zoomGhostForMonth(monthIndex), () => {
openYearMonth(monthIndex);
});
return;
}
if (calendarView.value === "month" || calendarView.value === "agenda") {
const anchorDayKey = resolveWeekAnchor(wheelEvent);
const rowStartKey = weekRowStartForDate(anchorDayKey);
const sourceElement =
queryCalendarElement(`[data-calendar-week-start-key="${rowStartKey}"]`) ??
queryCalendarElement(`[data-calendar-day-key="${anchorDayKey}"]`) ??
queryCalendarElement("[data-calendar-week-start-key]") ??
queryCalendarElement("[data-calendar-day-key]");
if (maybePrimeWheelZoom(wheelEvent, calendarPrimeWeekToken(rowStartKey))) return;
await animateCalendarZoomIn(
sourceElement,
zoomGhostForWeek(rowStartKey),
() => {
openWeekView(anchorDayKey);
},
);
return;
}
if (calendarView.value === "week") {
const dayAnchor = resolveDayAnchor(wheelEvent);
const sourceElement = queryCalendarElement(`[data-calendar-day-key="${dayAnchor}"]`) ?? queryCalendarElement("[data-calendar-day-key]");
if (maybePrimeWheelZoom(wheelEvent, calendarPrimeDayToken(dayAnchor))) return;
await animateCalendarZoomIn(sourceElement, zoomGhostForDay(dayAnchor), () => {
openDayView(dayAnchor);
});
}
}
async function zoomToMonth(monthIndex: number) {
await animateCalendarZoomIn(
queryCalendarElement(`[data-calendar-month-index="${monthIndex}"]`),
zoomGhostForMonth(monthIndex),
() => {
openYearMonth(monthIndex);
},
);
}
async function zoomOutCalendar() {
focusedCalendarEventId.value = "";
clearCalendarZoomPrime();
if (calendarView.value === "day") {
const targetDayKey = selectedDateKey.value;
await animateCalendarZoomOut(
() => {
calendarView.value = "week";
},
() => queryCalendarElement(`[data-calendar-day-key="${targetDayKey}"]`),
);
return;
}
if (calendarView.value === "week") {
const targetRowKey = weekRowStartForDate(selectedDateKey.value);
await animateCalendarZoomOut(
() => {
calendarView.value = "month";
},
() =>
queryCalendarElement(`[data-calendar-week-start-key="${targetRowKey}"]`) ??
queryCalendarElement(`[data-calendar-day-key="${selectedDateKey.value}"]`),
);
return;
}
if (calendarView.value === "month" || calendarView.value === "agenda") {
const targetMonthIndex = calendarCursor.value.getMonth();
await animateCalendarZoomOut(
() => {
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 selectedCountry = ref("All");
const selectedLocation = ref("All");
const selectedCompany = ref("All");
const selectedChannel = ref("All");
const sortMode = ref<SortMode>("name");
const countries = computed(() => ["All", ...new Set(contacts.value.map((c) => c.country))].sort());
const locationScopeContacts = computed(() =>
selectedCountry.value === "All"
? contacts.value
: contacts.value.filter((contact) => contact.country === selectedCountry.value),
);
const locations = computed(() => ["All", ...new Set(locationScopeContacts.value.map((c) => c.location))].sort());
const companyScopeContacts = computed(() =>
selectedLocation.value === "All"
? locationScopeContacts.value
: locationScopeContacts.value.filter((contact) => contact.location === selectedLocation.value),
);
const companies = computed(() => ["All", ...new Set(companyScopeContacts.value.map((c) => c.company))].sort());
const channels = computed(() => ["All", ...new Set(contacts.value.flatMap((c) => c.channels))].sort());
watch(selectedCountry, () => {
selectedLocation.value = "All";
selectedCompany.value = "All";
});
watch(selectedLocation, () => {
selectedCompany.value = "All";
});
function resetContactFilters() {
contactSearch.value = "";
selectedCountry.value = "All";
selectedLocation.value = "All";
selectedCompany.value = "All";
selectedChannel.value = "All";
sortMode.value = "name";
}
const filteredContacts = computed(() => {
const query = contactSearch.value.trim().toLowerCase();
const data = contacts.value.filter((contact) => {
if (selectedCountry.value !== "All" && contact.country !== selectedCountry.value) return false;
if (selectedLocation.value !== "All" && contact.location !== selectedLocation.value) return false;
if (selectedCompany.value !== "All" && contact.company !== selectedCompany.value) return false;
if (selectedChannel.value !== "All" && !contact.channels.includes(selectedChannel.value)) return false;
if (query) {
const haystack = [contact.name, contact.company, contact.country, contact.location, 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 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 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);
}
const peopleListMode = ref<"contacts" | "deals">("contacts");
const peopleSearch = ref("");
const peopleSortMode = ref<PeopleSortMode>("lastContact");
const brokenAvatarByContactId = ref<Record<string, boolean>>({});
const peopleSortOptions: Array<{ value: PeopleSortMode; label: string }> = [
{ value: "lastContact", label: "Last contact" },
{ value: "name", label: "Name" },
{ value: "company", label: "Company" },
{ value: "country", label: "Country" },
];
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);
}
return contacts.value
.map((contact) => {
const items = map.get(contact.name) ?? [];
const last = items[items.length - 1];
const channels = [...new Set([...contact.channels, ...items.map((item) => item.channel)])] as CommItem["channel"][];
return {
id: contact.id,
contact: contact.name,
avatar: contact.avatar,
company: contact.company,
country: contact.country,
location: contact.location,
channels,
lastAt: last?.at ?? contact.lastContactAt,
lastText: last?.text ?? "No messages yet",
items,
};
})
.filter((thread) => thread.items.length > 0)
.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.company, item.country, item.location].join(" ").toLowerCase();
return haystack.includes(query);
});
return list.sort((a, b) => {
if (peopleSortMode.value === "name") return a.contact.localeCompare(b.contact);
if (peopleSortMode.value === "company") return a.company.localeCompare(b.company);
if (peopleSortMode.value === "country") return a.country.localeCompare(b.country);
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.company, 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 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 = "";
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 gqlFetch<{ archiveCalendarEvent: CalendarEvent }>(archiveCalendarEventMutation, {
input: {
id: eventId,
archiveNote: archiveNote || undefined,
},
});
await refreshCrmData();
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 gqlFetch<{ toggleContactPin: { ok: boolean; pinned: boolean } }>(toggleContactPinMutation, {
contact: contactName,
text,
});
await refreshCrmData();
} 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 = deal.title.trim();
const amountRaw = deal.amount.trim();
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 getDealCurrentStep(deal)?.title?.trim() || deal.nextStep.trim() || deal.stage.trim() || "Без шага";
}
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 payload = await decodeAudioBlobToPcm16(audioBlob);
const result = await $fetch<{ text?: string }>("/api/pilot-transcribe", {
method: "POST",
body: payload,
});
const text = String(result?.text ?? "").trim();
callTranscriptText.value[itemId] = text || "(empty transcript)";
await gqlFetch<{ updateCommunicationTranscript: { ok: boolean; id: string } }>(updateCommunicationTranscriptMutation, {
id: itemId,
transcript: text ? [text] : [],
});
await refreshCrmData();
} 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]);
}
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 gqlFetch<{ setContactInboxHidden: { ok: boolean } }>(setContactInboxHiddenMutation, {
inboxId: id,
hidden,
});
await refreshCrmData();
} 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.
gqlFetch<{ logPilotNote: { ok: boolean } }>(logPilotNoteMutation, { text })
.then(() => Promise.all([loadPilotMessages(), loadChatConversations()]))
.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 gqlFetch<{ createCalendarEvent: CalendarEvent }>(createCalendarEventMutation, {
input: {
title,
start: start.toISOString(),
end: end.toISOString(),
contact: selectedCommThread.value.contact,
note,
archived: commEventMode.value === "logged",
archiveNote: commEventMode.value === "logged" ? note : undefined,
},
});
calendarEvents.value = [res.createCalendarEvent, ...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 = commDocumentForm.value.title.trim() || 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 gqlFetch<{ createWorkspaceDocument: WorkspaceDocument }>(createWorkspaceDocumentMutation, {
input: {
title,
owner: authDisplayName.value,
scope,
summary,
body,
},
});
documents.value = [res.createWorkspaceDocument, ...documents.value.filter((doc) => doc.id !== res.createWorkspaceDocument.id)];
selectedDocumentId.value = res.createWorkspaceDocument.id;
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 gqlFetch<{ createCommunication: { ok: boolean; id: string } }>(createCommunicationMutation, {
input: {
contact: selectedCommThread.value.contact,
channel,
kind: "message",
direction: "out",
text,
},
});
commDraft.value = "";
await refreshCrmData();
openCommunicationThread(selectedCommThread.value.contact);
} finally {
commSending.value = false;
}
}
function toggleCommRecording() {
commRecording.value = !commRecording.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 gqlFetch<{ createCalendarEvent: CalendarEvent }>(createCalendarEventMutation, {
input: {
title: `Follow-up: ${card.contact.split(" ")[0] ?? "Contact"}`,
start: start.toISOString(),
end: end.toISOString(),
contact: card.contact,
note: "Created from feed action.",
},
});
calendarEvents.value = [res.createCalendarEvent, ...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 gqlFetch<{ createCommunication: { ok: boolean; id: string } }>(createCommunicationMutation, {
input: {
contact: card.contact,
channel: "Phone",
kind: "call",
direction: "out",
text: "Call started from feed",
durationSec: 0,
},
});
await refreshCrmData();
openCommunicationThread(card.contact);
return `Call event created and ${card.contact} chat opened.`;
}
if (key === "draft_message") {
await gqlFetch<{ createCommunication: { ok: boolean; id: string } }>(createCommunicationMutation, {
input: {
contact: card.contact,
channel: "Email",
kind: "message",
direction: "out",
text: "Draft: onboarding plan + two slots for tomorrow.",
},
});
await refreshCrmData();
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 gqlFetch<{ createCommunication: { ok: boolean; id: string } }>(createCommunicationMutation, {
input: {
contact: card.contact,
channel: "Telegram",
kind: "message",
direction: "out",
text: "Draft: can you confirm your decision date for this cycle?",
},
});
await refreshCrmData();
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 gqlFetch<{ updateFeedDecision: { ok: boolean; id: string } }>(updateFeedDecisionMutation, {
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 gqlFetch<{ updateFeedDecision: { ok: boolean; id: string } }>(updateFeedDecisionMutation, {
id: card.id,
decision: "accepted",
decisionNote: result,
});
pushPilotNote(`[${card.contact}] ${result}`);
}
</script>
<template>
<div class="h-[100dvh] overflow-hidden bg-base-200/35">
<div v-if="!authResolved" class="flex h-full items-center justify-center">
<span class="loading loading-spinner loading-md text-base-content/70" />
</div>
<div v-else-if="!authMe" class="flex h-full items-center justify-center px-3">
<div class="card w-full max-w-sm border border-base-300 bg-base-100 shadow-sm">
<div class="card-body p-5">
<h1 class="text-lg font-semibold">Login</h1>
<p class="mt-1 text-xs text-base-content/65">Sign in with phone and password.</p>
<div class="mt-4 space-y-2">
<input
v-model="loginPhone"
type="tel"
class="input input-bordered w-full"
placeholder="+1 555 000 0001"
@keyup.enter="login"
>
<input
v-model="loginPassword"
type="password"
class="input input-bordered w-full"
placeholder="Password"
@keyup.enter="login"
>
<p v-if="loginError" class="text-xs text-error">{{ loginError }}</p>
<button class="btn w-full" :disabled="loginBusy" @click="login">
{{ loginBusy ? "Logging in..." : "Login" }}
</button>
</div>
</div>
</div>
</div>
<template v-else>
<div class="grid h-full min-h-0 grid-cols-1 gap-0 lg:grid-cols-[320px_minmax(0,1fr)]">
<aside class="pilot-shell min-h-0 border-r border-base-300">
<div class="flex h-full min-h-0 flex-col p-0">
<div class="pilot-header">
<div>
<h2 class="text-sm font-semibold text-white/75">{{ pilotHeaderText }}</h2>
</div>
</div>
<div class="pilot-threads">
<div class="flex w-full items-center justify-between gap-2">
<button
class="btn btn-ghost btn-xs h-7 min-h-7 max-w-[228px] justify-start px-1 text-xs font-medium text-white/90 hover:bg-white/10"
:disabled="chatSwitching || chatThreadsLoading || chatConversations.length === 0"
:title="authMe?.conversation?.title || 'Thread'"
@click="toggleChatThreadPicker"
>
<span class="truncate">{{ authMe?.conversation?.title || "Thread" }}</span>
<svg viewBox="0 0 20 20" class="ml-1 h-3.5 w-3.5 fill-current opacity-80">
<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>
<button
class="btn btn-ghost btn-xs h-7 min-h-7 px-1 text-white/85 hover:bg-white/10"
:disabled="chatCreating"
title="New chat"
@click="createNewChatConversation"
>
{{ chatCreating ? "…" : "+" }}
</button>
</div>
</div>
<div class="pilot-stream-wrap min-h-0 flex-1">
<div class="pilot-timeline min-h-0 h-full overflow-y-auto">
<div
v-for="message in renderedPilotMessages"
:key="message.id"
class="pilot-row"
>
<div class="pilot-avatar" :class="message.role === 'user' ? 'pilot-avatar-user' : ''">
{{ pilotRoleBadge(message.role) }}
</div>
<div class="pilot-body">
<div class="pilot-meta">
<span class="pilot-author">{{ pilotRoleName(message.role) }}</span>
<span class="pilot-time">{{ formatPilotStamp(message.createdAt) }}</span>
</div>
<div v-if="message.messageKind === 'change_set_summary'" class="rounded-xl border border-amber-300/35 bg-amber-500/10 p-3">
<p class="text-xs font-semibold text-amber-100">
{{ message.changeSummary || "Technical change summary" }}
</p>
<div class="mt-2 overflow-x-auto">
<table class="w-full min-w-[340px] text-left text-[11px] text-white/85">
<thead>
<tr class="text-white/60">
<th class="py-1 pr-2 font-medium">Metric</th>
<th class="py-1 pr-2 font-medium">Value</th>
</tr>
</thead>
<tbody>
<tr>
<td class="py-1 pr-2">Total changes</td>
<td class="py-1 pr-2">{{ message.changeItems?.length || 0 }}</td>
</tr>
<tr>
<td class="py-1 pr-2">Created</td>
<td class="py-1 pr-2">{{ summarizeChangeActions(message.changeItems).created }}</td>
</tr>
<tr>
<td class="py-1 pr-2">Updated</td>
<td class="py-1 pr-2">{{ summarizeChangeActions(message.changeItems).updated }}</td>
</tr>
<tr>
<td class="py-1 pr-2">Archived</td>
<td class="py-1 pr-2">{{ summarizeChangeActions(message.changeItems).deleted }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="summarizeChangeEntities(message.changeItems).length" class="mt-2 flex flex-wrap gap-1.5">
<span
v-for="row in summarizeChangeEntities(message.changeItems)"
:key="`entity-summary-${message.id}-${row.entity}`"
class="rounded border border-white/20 px-2 py-0.5 text-[10px] text-white/75"
>
{{ row.entity }}: {{ row.count }}
</span>
</div>
<div class="mt-3 flex flex-wrap items-center gap-2">
<button
v-if="message.changeSetId"
class="btn btn-xs btn-outline"
@click="openChangeReview(message.changeSetId, 0, true)"
>
Review Changes
</button>
<span class="text-[10px] uppercase tracking-wide text-amber-100/80">
status: {{ message.changeStatus || "pending" }}
</span>
</div>
</div>
<div v-else class="pilot-message-text">
{{ message.text }}
</div>
</div>
</div>
<div v-if="pilotLiveLogs.length" class="pilot-stream-status">
<div class="pilot-stream-head">
<p v-if="!pilotLiveLogsExpanded && pilotLiveLogHiddenCount > 0" class="pilot-stream-caption">
Showing last {{ pilotVisibleLogCount }} steps
</p>
<button
v-if="pilotLiveLogHiddenCount > 0 || pilotLiveLogsExpanded"
type="button"
class="pilot-stream-toggle"
@click="togglePilotLiveLogsExpanded"
>
{{ pilotLiveLogsExpanded ? "Show less" : `Show all (+${pilotLiveLogHiddenCount})` }}
</button>
</div>
<p
v-for="log in pilotVisibleLiveLogs"
:key="`pilot-log-${log.id}`"
class="pilot-stream-line"
:class="log.id === pilotLiveLogs[pilotLiveLogs.length - 1]?.id ? 'pilot-stream-line-current' : ''"
>
{{ log.text }}
</p>
</div>
</div>
<div v-if="chatThreadPickerOpen" class="pilot-thread-overlay">
<div class="mb-2 flex items-center justify-between">
<p class="text-[11px] font-semibold uppercase tracking-wide text-white/60">Threads</p>
<button class="btn btn-ghost btn-xs btn-square text-white/70 hover:bg-white/10" title="Close" @click="closeChatThreadPicker">
<svg viewBox="0 0 20 20" class="h-3.5 w-3.5 fill-current">
<path d="M11.06 10 15.53 5.53a.75.75 0 1 0-1.06-1.06L10 8.94 5.53 4.47a.75.75 0 0 0-1.06 1.06L8.94 10l-4.47 4.47a.75.75 0 1 0 1.06 1.06L10 11.06l4.47 4.47a.75.75 0 0 0 1.06-1.06z" />
</svg>
</button>
</div>
<div class="max-h-full space-y-1 overflow-y-auto pr-1">
<div
v-for="thread in chatConversations"
:key="`thread-row-${thread.id}`"
class="flex items-center gap-1 rounded-md"
>
<button
class="min-w-0 flex-1 rounded-md px-2 py-1.5 text-left transition hover:bg-white/10"
:class="selectedChatId === thread.id ? 'bg-white/12' : ''"
:disabled="chatSwitching || chatArchivingId === thread.id"
@click="switchChatConversation(thread.id)"
>
<p class="truncate text-xs font-medium text-white">{{ thread.title }}</p>
<p class="truncate text-[11px] text-white/55">
{{ thread.lastMessageText || "No messages yet" }} · {{ formatChatThreadMeta(thread) }}
</p>
</button>
<button
class="btn btn-ghost btn-xs btn-square text-white/55 hover:bg-white/10 hover:text-red-300"
:disabled="chatSwitching || chatArchivingId === thread.id || chatConversations.length <= 1"
title="Archive thread"
@click="archiveChatConversation(thread.id)"
>
<span v-if="chatArchivingId === thread.id" class="loading loading-spinner loading-xs" />
<svg v-else viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
<path d="M20.54 5.23 19 3H5L3.46 5.23A2 2 0 0 0 3 6.36V8a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6.36a2 2 0 0 0-.46-1.13M5.16 5h13.68l.5.73A.5.5 0 0 1 19.5 6H4.5a.5.5 0 0 1-.34-.27zM6 12h12v6a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2z" />
</svg>
</button>
</div>
</div>
</div>
</div>
<div class="pilot-input-wrap">
<div class="pilot-input-shell">
<textarea
v-model="pilotInput"
class="pilot-input-textarea"
:placeholder="pilotRecording ? 'Recording... speak, then press mic to fill or send to submit' : 'Type a message for Pilot...'"
@keydown.enter="handlePilotComposerEnter"
/>
<div v-if="pilotRecording" class="pilot-meter">
<div ref="pilotWaveContainer" class="pilot-wave-canvas" />
</div>
<div v-if="!pilotRecording" class="pilot-input-context">
<button
v-if="contextScopeChips.length === 0"
class="context-pipette-trigger"
:class="contextPickerEnabled ? 'context-pipette-active' : ''"
:disabled="pilotTranscribing || pilotSending"
aria-label="Контекстная пипетка"
:title="contextPickerEnabled ? 'Выключить пипетку' : 'Включить пипетку контекста'"
@click="toggleContextPicker"
>
<svg viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
<path d="M19.29 4.71a1 1 0 0 0-1.42 0l-2.58 2.58-1.17-1.17a1 1 0 0 0-1.41 0l-1.42 1.42a1 1 0 0 0 0 1.41l.59.59-5.3 5.3a2 2 0 0 0-.53.92l-.86 3.43a1 1 0 0 0 1.21 1.21l3.43-.86a2 2 0 0 0 .92-.53l5.3-5.3.59.59a1 1 0 0 0 1.41 0l1.42-1.42a1 1 0 0 0 0-1.41l-1.17-1.17 2.58-2.58a1 1 0 0 0 0-1.42z" />
</svg>
</button>
<div v-else class="pilot-context-chips">
<button
v-for="chip in contextScopeChips"
:key="`context-chip-${chip.scope}`"
type="button"
class="context-pipette-chip"
@click="removeContextScope(chip.scope)"
>
{{ chip.label }}
<span class="opacity-70">×</span>
</button>
</div>
</div>
<div class="pilot-input-actions">
<button
class="btn btn-xs btn-circle border border-white/20 bg-transparent text-white/90 hover:bg-white/10"
:class="pilotRecording ? 'pilot-mic-active' : ''"
:disabled="!pilotMicSupported || pilotTranscribing || pilotSending"
:title="pilotRecording ? 'Stop and insert transcript' : 'Voice input'"
@click="togglePilotRecording"
>
<svg v-if="!pilotTranscribing" viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
<path d="M12 15a3 3 0 0 0 3-3V7a3 3 0 1 0-6 0v5a3 3 0 0 0 3 3m5-3a1 1 0 1 1 2 0 7 7 0 0 1-6 6.92V21h3a1 1 0 1 1 0 2H8a1 1 0 1 1 0-2h3v-2.08A7 7 0 0 1 5 12a1 1 0 1 1 2 0 5 5 0 0 0 10 0" />
</svg>
<span v-else class="loading loading-spinner loading-xs" />
</button>
<button
class="btn btn-sm btn-circle border-0 bg-[#5865f2] text-white hover:bg-[#4752c4]"
:disabled="pilotTranscribing || pilotSending || (!pilotRecording && !pilotInput.trim())"
:title="pilotRecording ? 'Transcribe and send' : 'Send message'"
@click="handlePilotSendAction"
>
<svg viewBox="0 0 24 24" class="h-4 w-4 fill-current" :class="pilotSending ? '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>
<p v-if="pilotMicError" class="pilot-mic-error">{{ pilotMicError }}</p>
</div>
</div>
</aside>
<main class="relative min-h-0 bg-base-100">
<div class="flex h-full min-h-0 flex-col">
<div class="workspace-topbar border-b border-base-300 px-3 py-2 md:px-4">
<div class="flex items-center justify-between gap-3">
<div class="join">
<button
class="btn btn-sm join-item"
:class="
selectedTab === 'communications' && peopleLeftMode === 'contacts'
? 'btn-ghost border border-base-300 bg-base-200/70 text-base-content'
: 'btn-ghost border border-transparent text-base-content/65 hover:border-base-300/70 hover:text-base-content'
"
@click="setPeopleLeftMode('contacts', true)"
>
Contacts
</button>
<button
class="btn btn-sm join-item"
:class="
selectedTab === 'communications' && peopleLeftMode === 'calendar'
? 'btn-ghost border border-base-300 bg-base-200/70 text-base-content'
: 'btn-ghost border border-transparent text-base-content/65 hover:border-base-300/70 hover:text-base-content'
"
@click="setPeopleLeftMode('calendar', true)"
>
Calendar
</button>
<button
class="btn btn-sm join-item"
:class="
selectedTab === 'documents'
? 'btn-ghost border border-base-300 bg-base-200/70 text-base-content'
: 'btn-ghost border border-transparent text-base-content/65 hover:border-base-300/70 hover:text-base-content'
"
@click="openDocumentsTab(true)"
>
Documents
</button>
</div>
<div class="dropdown dropdown-end">
<button tabindex="0" class="btn btn-sm btn-ghost gap-2">
<div class="avatar placeholder">
<div class="flex h-7 w-7 items-center justify-center rounded-full bg-primary text-primary-content">
<span class="text-[11px] font-semibold leading-none">{{ authInitials }}</span>
</div>
</div>
<span class="max-w-[160px] truncate text-xs font-medium">{{ authDisplayName }}</span>
</button>
<div tabindex="0" class="dropdown-content z-30 mt-2 w-80 rounded-box border border-base-300 bg-base-100 p-3 shadow-lg">
<div class="mb-2 border-b border-base-300 pb-2">
<p class="truncate text-sm font-semibold">{{ authDisplayName }}</p>
<p class="text-[11px] uppercase tracking-wide text-base-content/60">Settings</p>
</div>
<div class="space-y-2 rounded-lg border border-base-300 bg-base-50/40 p-2">
<div class="flex items-center justify-between gap-2">
<span class="text-xs font-medium">Telegram Business</span>
<span class="badge badge-xs" :class="telegramStatusBadgeClass">{{ telegramStatusLabel }}</span>
</div>
<button
class="btn btn-xs btn-primary w-full"
:disabled="telegramConnectBusy"
@click="startTelegramBusinessConnect"
>
{{ telegramConnectBusy ? "Connecting..." : "Connect Telegram" }}
</button>
<p v-if="telegramConnectNotice" class="text-[11px] leading-snug text-base-content/70">
{{ telegramConnectNotice }}
</p>
</div>
<div class="mt-3 border-t border-base-300 pt-2">
<button class="btn btn-sm w-full btn-ghost justify-start" @click="logout">Logout</button>
</div>
</div>
</div>
</div>
</div>
<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'"
>
<section
v-if="selectedTab === 'communications' && peopleLeftMode === 'calendar'"
class="relative flex h-full min-h-0 flex-col gap-3"
:class="[
contextPickerEnabled ? 'context-scope-block context-scope-block-active' : '',
hasContextScope('calendar') ? 'context-scope-block-selected' : '',
]"
@click="toggleContextScope('calendar')"
>
<span
v-if="contextPickerEnabled"
class="context-scope-label"
>{{ contextScopeLabel('calendar') }}</span>
<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>
</div>
<div class="text-center text-sm font-medium">
{{ calendarPeriodLabel }}
</div>
<div class="justify-self-end calendar-zoom-inline" @click.stop>
<input
class="calendar-zoom-slider"
type="range"
min="0"
max="3"
step="1"
:value="calendarZoomLevelIndex"
aria-label="Calendar zoom level"
@input="onCalendarZoomSliderInput"
>
<div class="calendar-zoom-marks" aria-hidden="true">
<span
v-for="index in 4"
:key="`calendar-zoom-mark-${index}`"
class="calendar-zoom-mark"
:class="calendarZoomLevelIndex === index - 1 ? 'calendar-zoom-mark-active' : ''"
/>
</div>
</div>
</div>
<article
v-if="focusedCalendarEvent"
class="rounded-xl border border-success/50 bg-success/10 px-3 py-2"
>
<p class="text-xs font-semibold uppercase tracking-wide text-success/80">Review focus event</p>
<p class="text-sm font-medium text-base-content">{{ focusedCalendarEvent.title }}</p>
<p class="text-xs text-base-content/70">
{{ formatDay(focusedCalendarEvent.start) }} · {{ formatTime(focusedCalendarEvent.start) }} - {{ formatTime(focusedCalendarEvent.end) }}
</p>
<p class="mt-1 text-xs text-base-content/80">{{ focusedCalendarEvent.note || "No note" }}</p>
</article>
<div ref="calendarContentWrapRef" class="calendar-content-wrap min-h-0 flex-1">
<button
class="calendar-side-nav calendar-side-nav-left"
type="button"
title="Previous period"
@click="shiftCalendar(-1)"
>
<span></span>
</button>
<button
class="calendar-side-nav calendar-side-nav-right"
type="button"
title="Next period"
@click="shiftCalendar(1)"
>
<span></span>
</button>
<div
ref="calendarContentScrollRef"
class="calendar-content-scroll min-h-0 h-full overflow-y-auto pr-1"
@wheel.prevent="onCalendarHierarchyWheel"
>
<div
ref="calendarSceneRef"
:class="[
'calendar-scene',
normalizedCalendarView === 'day' ? 'cursor-zoom-out' : 'cursor-zoom-in',
calendarSceneMasked ? 'calendar-scene-hidden' : '',
]"
:style="calendarSceneTransformStyle"
@mouseleave="calendarHoveredMonthIndex = null; calendarHoveredWeekStartKey = ''; calendarHoveredDayKey = ''; clearCalendarZoomPrime()"
>
<div class="grid gap-2" :class="calendarView === 'year' ? 'sm:grid-cols-2 xl:grid-cols-3' : 'grid-cols-1'">
<article
v-for="item in yearMonths"
:key="`year-month-${item.monthIndex}`"
v-show="calendarView === 'year' || item.monthIndex === calendarCursor.getMonth()"
class="group relative rounded-xl border border-base-300 p-3 text-left transition calendar-hover-targetable"
:class="[
calendarView === 'year' ? 'hover:border-primary/50 hover:bg-primary/5 cursor-zoom-in' : 'cursor-default min-h-[26rem] bg-base-100',
calendarHoveredMonthIndex === item.monthIndex ? 'calendar-hover-target' : '',
calendarZoomPrimeToken === calendarPrimeMonthToken(item.monthIndex) ? 'calendar-zoom-prime-active' : '',
]"
:style="calendarPrimeStyle(calendarPrimeMonthToken(item.monthIndex))"
:data-calendar-month-index="item.monthIndex"
@mouseenter="calendarHoveredMonthIndex = item.monthIndex"
@click="calendarView === 'year' ? zoomToMonth(item.monthIndex) : undefined"
>
<p class="font-medium">{{ item.label }}</p>
<p class="text-xs text-base-content/60">{{ item.count }} events</p>
<button
v-if="calendarView === 'year' && item.first"
class="mt-1 block w-full text-left text-xs text-base-content/70 hover:underline"
@click.stop="openThreadFromCalendarItem(item.first)"
>
{{ formatDay(item.first.start) }} · {{ item.first.title }}
</button>
<div
v-if="item.monthIndex === calendarCursor.getMonth() && (calendarView === 'month' || calendarView === 'agenda')"
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="space-y-1">
<div
v-for="row in monthRows"
:key="row.key"
class="group relative calendar-hover-targetable"
:class="[
calendarHoveredWeekStartKey === row.startKey ? 'calendar-hover-target' : '',
calendarZoomPrimeToken === calendarPrimeWeekToken(row.startKey) ? 'calendar-zoom-prime-active' : '',
]"
:style="calendarPrimeStyle(calendarPrimeWeekToken(row.startKey))"
:data-calendar-week-start-key="row.startKey"
@mouseenter="calendarHoveredWeekStartKey = row.startKey"
>
<div class="grid grid-cols-7 gap-1">
<button
v-for="cell in row.cells"
:key="cell.key"
class="group relative 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' : '',
monthCellHasFocusedEvent(cell.events) ? 'border-success/60 bg-success/10' : '',
]"
:data-calendar-day-key="cell.key"
@mouseenter="calendarHoveredDayKey = cell.key"
@click="pickDate(cell.key)"
>
<p class="mb-1 text-xs font-semibold">{{ cell.day }}</p>
<button
v-for="event in monthCellEvents(cell.events)"
:key="event.id"
class="block w-full truncate rounded px-1 text-left text-[10px] text-base-content/70 transition hover:underline"
:class="isReviewHighlightedEvent(event.id) ? 'bg-success/20 text-success-content ring-1 ring-success/40' : ''"
@click.stop="openThreadFromCalendarItem(event)"
>
{{ formatTime(event.start) }} {{ event.title }}
</button>
</button>
</div>
</div>
</div>
</div>
<div
v-else-if="item.monthIndex === calendarCursor.getMonth() && calendarView === 'week'"
class="mt-3 calendar-week-scroll overflow-x-auto pb-1"
>
<div class="calendar-week-grid">
<article
v-for="day in weekDays"
:key="day.key"
class="group relative flex min-h-[18rem] flex-col rounded-xl border border-base-300 bg-base-100 p-2.5 cursor-zoom-in calendar-hover-targetable"
:class="[
selectedDateKey === day.key ? 'border-primary bg-primary/5' : '',
calendarHoveredDayKey === day.key ? 'calendar-hover-target' : '',
calendarZoomPrimeToken === calendarPrimeDayToken(day.key) ? 'calendar-zoom-prime-active' : '',
]"
:style="calendarPrimeStyle(calendarPrimeDayToken(day.key))"
:data-calendar-day-key="day.key"
@mouseenter="calendarHoveredDayKey = day.key"
@click="pickDate(day.key)"
>
<div class="mb-2 flex items-start justify-between gap-2">
<p class="text-sm font-semibold leading-tight">{{ day.label }} {{ day.day }}</p>
</div>
<div class="space-y-1.5">
<button
v-for="event in day.events"
:key="event.id"
class="block w-full rounded-md px-2 py-1.5 text-left text-xs"
:class="isReviewHighlightedEvent(event.id) ? 'bg-success/20 ring-1 ring-success/45' : 'bg-base-200 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="pt-1 text-xs text-base-content/50">No events</p>
</div>
</article>
</div>
</div>
<div
v-else-if="item.monthIndex === calendarCursor.getMonth() && calendarView === 'day'"
class="mt-3 space-y-2"
>
<button
v-for="event in selectedDayEvents"
:key="event.id"
class="block w-full rounded-xl border border-base-300 p-3 text-left transition hover:bg-base-200/60"
:class="isReviewHighlightedEvent(event.id) ? 'border-success/60 bg-success/10' : ''"
@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>
</article>
</div>
</div>
</div>
<div
v-if="calendarZoomOverlay.active"
ref="calendarZoomOverlayRef"
class="calendar-zoom-overlay"
:style="calendarZoomOverlayStyle"
>
<div v-if="calendarZoomGhost" class="calendar-zoom-overlay-content">
<p class="calendar-zoom-overlay-title">{{ calendarZoomGhost.title }}</p>
<p v-if="calendarZoomGhost.subtitle" class="calendar-zoom-overlay-subtitle">{{ calendarZoomGhost.subtitle }}</p>
</div>
</div>
</div>
</section>
<section v-else-if="selectedTab === 'communications' && false" class="space-y-3">
<div class="mb-1 flex justify-end">
<div class="join">
<button class="btn btn-sm join-item btn-primary" @click="setPeopleLeftMode('contacts', true)">Contacts</button>
<button class="btn btn-sm join-item btn-ghost" @click="setPeopleLeftMode('calendar', true)">Calendar</button>
</div>
</div>
<div class="rounded-xl border border-base-300 p-3">
<div class="flex flex-wrap items-center gap-2">
<input
v-model="contactSearch"
type="text"
class="input input-bordered input-md w-full flex-1"
placeholder="Search contacts..."
>
<select v-model="sortMode" class="select select-bordered select-sm w-40">
<option value="name">Sort: Name</option>
<option value="lastContact">Sort: Last contact</option>
</select>
<div class="dropdown dropdown-end">
<button tabindex="0" class="btn btn-sm btn-ghost btn-square" title="Filters">
<svg viewBox="0 0 24 24" class="h-4 w-4 fill-current">
<path d="M3 5h18v2H3zm4 6h10v2H7zm3 6h4v2h-4z" />
</svg>
</button>
<div tabindex="0" class="dropdown-content z-20 mt-2 w-72 rounded-xl border border-base-300 bg-base-100 p-3 shadow-lg">
<div class="grid gap-2">
<label class="form-control">
<span class="label-text text-xs uppercase tracking-wide text-base-content/60">Country</span>
<select v-model="selectedCountry" class="select select-bordered select-sm">
<option v-for="country in countries" :key="`country-${country}`" :value="country">{{ country }}</option>
</select>
</label>
<label class="form-control">
<span class="label-text text-xs uppercase tracking-wide text-base-content/60">Location</span>
<select v-model="selectedLocation" class="select select-bordered select-sm">
<option v-for="location in locations" :key="`location-${location}`" :value="location">{{ location }}</option>
</select>
</label>
<label class="form-control">
<span class="label-text text-xs uppercase tracking-wide text-base-content/60">Company</span>
<select v-model="selectedCompany" class="select select-bordered select-sm">
<option v-for="company in companies" :key="`company-${company}`" :value="company">{{ company }}</option>
</select>
</label>
<label class="form-control">
<span class="label-text text-xs uppercase tracking-wide text-base-content/60">Channel</span>
<select v-model="selectedChannel" class="select select-bordered select-sm">
<option v-for="channel in channels" :key="`channel-${channel}`" :value="channel">{{ channel }}</option>
</select>
</label>
</div>
<div class="mt-3 flex justify-end">
<button class="btn btn-ghost btn-sm" @click="resetContactFilters">Reset filters</button>
</div>
</div>
</div>
</div>
</div>
<div class="grid gap-3 md:grid-cols-12">
<aside class="min-h-0 rounded-xl border border-base-300 md:col-span-4">
<div class="min-h-0 space-y-3 overflow-y-auto p-2">
<article v-for="group in groupedContacts" :key="group[0]" class="space-y-2">
<div class="sticky top-0 z-10 rounded-lg bg-base-200 px-3 py-1 text-sm font-semibold">{{ group[0] }}</div>
<button
v-for="contact in group[1]"
:key="contact.id"
class="w-full rounded-xl border border-base-300 p-3 text-left transition hover:bg-base-200/60"
:class="selectedContactId === contact.id ? 'border-primary bg-primary/5' : ''"
@click="selectedContactId = contact.id"
>
<p class="font-medium">{{ contact.name }}</p>
<p class="text-xs text-base-content/60">{{ contact.company }} · {{ contact.location }}, {{ contact.country }}</p>
<p class="mt-1 text-[11px] text-base-content/55">Last contact · {{ formatStamp(contact.lastContactAt) }}</p>
</button>
</article>
</div>
</aside>
<article class="min-h-0 rounded-xl border border-base-300 md:col-span-8">
<div v-if="selectedContact" class="p-3 md:p-4">
<div class="border-b border-base-300 pb-2">
<p class="font-medium">{{ selectedContact!.name }}</p>
<p class="text-xs text-base-content/60">
{{ selectedContact!.company }} · {{ selectedContact!.location }}, {{ selectedContact!.country }}
</p>
<p class="mt-1 text-xs text-base-content/55">Last contact · {{ formatStamp(selectedContact!.lastContactAt) }}</p>
</div>
<div class="mt-3">
<ContactCollaborativeEditor
:key="`contact-editor-${selectedContact!.id}`"
v-model="selectedContact!.description"
:room="`crm-contact-${selectedContact!.id}`"
placeholder="Describe contact context and next steps..."
/>
</div>
<div class="mt-4 grid gap-3 xl:grid-cols-2">
<section class="rounded-xl border border-base-300 p-3">
<div class="mb-2 flex items-center justify-between gap-2">
<p class="text-sm font-semibold">Upcoming events</p>
</div>
<div class="space-y-2">
<button
v-for="event in selectedContactEvents"
:key="`contact-event-${event.id}`"
class="w-full rounded-lg border border-base-300 px-3 py-2 text-left transition hover:bg-base-200/50"
:class="isReviewHighlightedEvent(event.id) ? 'border-success/60 bg-success/10' : ''"
@click="openEventFromContact(event)"
>
<p class="text-sm font-medium">{{ event.title }}</p>
<p class="text-xs text-base-content/65">
{{ formatDay(event.start) }} · {{ formatTime(event.start) }} - {{ formatTime(event.end) }}
</p>
<p class="mt-1 text-xs text-base-content/80">{{ event.note }}</p>
</button>
<p v-if="selectedContactEvents.length === 0" class="text-xs text-base-content/55">
No linked events yet.
</p>
</div>
</section>
<section class="rounded-xl border border-base-300 p-3">
<div class="mb-2 flex items-center justify-between gap-2">
<p class="text-sm font-semibold">Recent messages</p>
<button class="btn btn-ghost btn-xs" @click="openCommunicationThread(selectedContact!.name)">Open chat</button>
</div>
<div class="space-y-2">
<button
v-for="item in selectedContactRecentMessages"
:key="`contact-message-${item.id}`"
class="w-full rounded-lg border border-base-300 px-3 py-2 text-left transition hover:bg-base-200/50"
@click="openMessageFromContact(item.channel)"
>
<p class="text-sm text-base-content/90">{{ 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(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(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(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(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(item.at) }}</span>
</p>
</button>
<p v-if="selectedContactRecentMessages.length === 0" class="text-xs text-base-content/55">
No messages yet.
</p>
</div>
</section>
</div>
</div>
<div v-else class="flex h-full items-center justify-center text-sm text-base-content/60">
No contact selected.
</div>
</article>
</div>
</section>
<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)]">
<aside class="h-full min-h-0 border-r border-base-300 flex flex-col md:row-span-2">
<div class="sticky top-0 z-20 h-12 border-b border-base-300 bg-base-100 px-2">
<div class="flex h-full items-center gap-1">
<div class="join rounded-lg border border-base-300 overflow-hidden">
<button
class="btn btn-ghost btn-sm join-item rounded-none"
:class="peopleListMode === 'contacts' ? 'bg-base-200/80 text-base-content' : 'text-base-content/65 hover:text-base-content'"
title="Contacts"
@click="peopleListMode = 'contacts'"
>
<svg viewBox="0 0 24 24" class="h-4 w-4 fill-current">
<path d="M12 12a5 5 0 1 0-5-5 5 5 0 0 0 5 5m0 2c-4.42 0-8 2.24-8 5v1h16v-1c0-2.76-3.58-5-8-5" />
</svg>
</button>
<button
class="btn btn-ghost btn-sm join-item rounded-none border-l border-base-300/70"
:class="peopleListMode === 'deals' ? 'bg-base-200/80 text-base-content' : 'text-base-content/65 hover:text-base-content'"
title="Deals"
@click="peopleListMode = 'deals'"
>
<svg viewBox="0 0 24 24" class="h-4 w-4 fill-current">
<path d="M10 3h4a2 2 0 0 1 2 2v2h3a2 2 0 0 1 2 2v3H3V9a2 2 0 0 1 2-2h3V5a2 2 0 0 1 2-2m0 4h4V5h-4zm11 7v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-5h7v2h4v-2z" />
</svg>
</button>
</div>
<input
v-model="peopleSearch"
type="text"
class="input input-bordered input-sm w-full"
:placeholder="peopleListMode === 'contacts' ? 'Search contacts' : 'Search deals'"
>
<div class="dropdown dropdown-end">
<button
tabindex="0"
class="btn btn-ghost btn-sm btn-square"
:title="peopleListMode === 'contacts' ? 'Sort contacts' : 'Sort deals'"
>
<svg viewBox="0 0 24 24" class="h-4 w-4 fill-current">
<path d="M3 5h18v2H3zm3 6h12v2H6zm4 6h4v2h-4z" />
</svg>
</button>
<div tabindex="0" class="dropdown-content z-20 mt-2 w-52 rounded-xl border border-base-300 bg-base-100 p-2 shadow-lg">
<template v-if="peopleListMode === 'contacts'">
<p class="px-2 pb-1 text-[11px] font-semibold uppercase tracking-wide text-base-content/55">Sort contacts</p>
<button
v-for="option in peopleSortOptions"
:key="`people-sort-${option.value}`"
class="btn btn-ghost btn-sm w-full justify-between"
@click="peopleSortMode = option.value"
>
<span>{{ option.label }}</span>
<span v-if="peopleSortMode === option.value"></span>
</button>
</template>
<p v-else class="px-2 py-1 text-xs text-base-content/60">Deals are sorted by title.</p>
</div>
</div>
</div>
</div>
<div class="min-h-0 flex-1 overflow-y-auto p-0">
<div
v-if="peopleListMode === 'contacts'"
v-for="thread in peopleContactList"
:key="thread.id"
class="w-full border-b border-base-300 px-3 py-2 text-left transition hover:bg-base-200/40"
:class="[
selectedCommThreadId === thread.id ? 'bg-primary/10' : '',
isReviewHighlightedContact(thread.id) ? 'bg-primary/10 ring-1 ring-primary/45' : '',
]"
@click="openCommunicationThread(thread.contact)"
role="button"
tabindex="0"
@keydown.enter.prevent="openCommunicationThread(thread.contact)"
@keydown.space.prevent="openCommunicationThread(thread.contact)"
>
<div class="flex items-start gap-2">
<div class="avatar shrink-0">
<div class="h-8 w-8 rounded-full ring-1 ring-base-300/70">
<img
v-if="avatarSrcForThread(thread)"
:src="avatarSrcForThread(thread)"
:alt="thread.contact"
@error="markAvatarBroken(thread.id)"
>
<span v-else class="flex h-full w-full items-center justify-center text-[10px] font-semibold text-base-content/65">
{{ contactInitials(thread.contact) }}
</span>
</div>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-2">
<p class="truncate text-xs font-semibold">{{ thread.contact }}</p>
<span class="shrink-0 text-[10px] text-base-content/55">{{ formatThreadTime(thread.lastAt) }}</span>
</div>
<div class="mt-0.5 flex items-center justify-between gap-2">
<p class="min-w-0 flex-1 truncate text-[11px] text-base-content/75">{{ threadChannelLabel(thread) }}</p>
<div class="dropdown dropdown-end" @click.stop>
<button
tabindex="0"
class="btn btn-ghost btn-xs btn-square h-5 min-h-5"
title="Source visibility settings"
>
<svg viewBox="0 0 24 24" class="h-3.5 w-3.5 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(thread).length" class="space-y-1">
<button
v-for="inbox in threadInboxes(thread)"
:key="`thread-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>
</div>
</div>
</div>
<button
v-if="peopleListMode === 'deals'"
v-for="deal in peopleDealList"
:key="deal.id"
class="w-full border-b border-base-300 px-3 py-2 text-left transition hover:bg-base-200/40"
:class="[
selectedDealId === deal.id ? 'bg-primary/10' : '',
isReviewHighlightedDeal(deal.id) ? 'bg-primary/10 ring-1 ring-primary/45' : '',
]"
@click="openDealThread(deal)"
>
<div class="flex items-start justify-between gap-2">
<p class="truncate text-xs font-semibold">{{ deal.title }}</p>
<span class="shrink-0 text-[10px] text-base-content/55">{{ deal.amount }}</span>
</div>
<p class="mt-0.5 truncate text-[11px] text-base-content/75">{{ deal.company }} · {{ deal.stage }}</p>
<p class="mt-0.5 truncate text-[11px] text-base-content/60">{{ getDealCurrentStepLabel(deal) }}</p>
</button>
<p v-if="peopleListMode === 'contacts' && peopleContactList.length === 0" class="px-1 py-2 text-xs text-base-content/55">
No contacts found.
</p>
<p v-if="peopleListMode === 'deals' && peopleDealList.length === 0" class="px-1 py-2 text-xs text-base-content/55">
No deals found.
</p>
</div>
</aside>
<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>
<p class="text-xs text-base-content/60">
{{ selectedWorkspaceContact.company }} · {{ selectedWorkspaceContact.location }}, {{ selectedWorkspaceContact.country }}
</p>
</div>
<div v-else-if="selectedCommThread">
<p class="font-medium">{{ selectedCommThread.contact }}</p>
</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 justify-center">
<div
class="call-wave-card w-full max-w-[460px] rounded-2xl border border-base-300 px-4 py-3 text-center"
:class="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">
{{ formatDay(entry.item.at) }} · {{ formatTime(entry.item.at) }}
<span v-if="entry.item.duration"> · {{ entry.item.duration }}</span>
</p>
<div class="comm-call-wave mb-2" :ref="(el) => setCommCallWaveHost(entry.item.id, el as Element | null)" />
<div class="mt-2 flex justify-center">
<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>
<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>
<button
class="btn btn-xs btn-circle border border-base-300 bg-base-100 text-base-content/80 hover:bg-base-200"
:class="commRecording ? 'comm-mic-active' : ''"
:disabled="commSending || commEventSaving"
title="Voice input"
@click="toggleCommRecording"
>
<svg viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
<path d="M12 15a3 3 0 0 0 3-3V7a3 3 0 1 0-6 0v5a3 3 0 0 0 3 3m5-3a1 1 0 1 1 2 0 7 7 0 0 1-6 6.92V21h3a1 1 0 1 1 0 2H8a1 1 0 1 1 0-2h3v-2.08A7 7 0 0 1 5 12a1 1 0 1 1 2 0 5 5 0 0 0 10 0" />
</svg>
</button>
<button
class="btn btn-sm btn-circle border-0 bg-[#5865f2] text-white hover:bg-[#4752c4]"
:disabled="commSending || commEventSaving || !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>
<aside class="h-full min-h-0">
<div class="flex h-full min-h-0 flex-col p-3">
<div
v-if="selectedWorkspaceContactDocuments.length"
class="mb-2 flex flex-wrap items-center gap-1.5 rounded-xl border border-base-300 bg-base-200/35 px-2 py-1.5"
>
<button
class="badge badge-sm badge-outline"
@click="contactRightPanelMode = 'documents'"
>
{{ selectedWorkspaceContactDocuments.length }} documents
</button>
<button
v-for="doc in selectedWorkspaceContactDocuments.slice(0, 15)"
:key="`contact-doc-chip-${doc.id}`"
class="rounded-full border border-base-300 bg-base-100 px-2 py-0.5 text-[10px] text-base-content/80 hover:bg-base-200/70"
@click="contactRightPanelMode = 'documents'; selectedDocumentId = doc.id"
>
{{ doc.title }}
</button>
</div>
<div v-if="contactRightPanelMode === 'documents'" class="min-h-0 flex-1 overflow-y-auto pr-1">
<div class="sticky top-0 z-10 border-b border-base-300 bg-base-100 pb-2">
<div class="flex items-center justify-between gap-2">
<p class="text-xs font-semibold uppercase tracking-wide text-base-content/60">
Contact documents
</p>
<button class="btn btn-ghost btn-xs" @click="contactRightPanelMode = 'summary'">Summary</button>
</div>
<input
v-model="contactDocumentsSearch"
type="text"
class="input input-bordered input-xs mt-2 w-full"
placeholder="Search documents..."
>
</div>
<div class="mt-2 space-y-1.5">
<article
v-for="doc in filteredSelectedWorkspaceContactDocuments"
:key="`contact-doc-right-${doc.id}`"
class="w-full rounded-xl border border-base-300 px-2.5 py-2 text-left transition hover:bg-base-200/50"
:class="selectedDocumentId === doc.id ? 'border-primary bg-primary/10' : ''"
@click="selectedDocumentId = doc.id"
>
<div class="flex items-start justify-between gap-2">
<p class="min-w-0 flex-1 truncate text-xs font-semibold">{{ doc.title }}</p>
</div>
<p class="mt-0.5 line-clamp-2 text-[11px] text-base-content/70">{{ doc.summary }}</p>
<div class="mt-1 flex items-center justify-between gap-2">
<p class="text-[10px] text-base-content/55">Updated {{ formatStamp(doc.updatedAt) }}</p>
<button class="btn btn-ghost btn-xs px-1" @click.stop="selectedDocumentId = doc.id; openDocumentsTab(true)">Open</button>
</div>
</article>
<p v-if="filteredSelectedWorkspaceContactDocuments.length === 0" class="px-1 py-2 text-xs text-base-content/55">
No linked documents.
</p>
</div>
</div>
<div v-else class="min-h-0 flex-1 space-y-3 overflow-y-auto pr-1">
<div
v-if="selectedWorkspaceDeal"
class="rounded-xl border border-base-300 bg-base-200/30 p-2.5"
:class="[
isReviewHighlightedDeal(selectedWorkspaceDeal.id) ? 'border-primary/60 bg-primary/10' : '',
contextPickerEnabled ? 'context-scope-block context-scope-block-active' : '',
hasContextScope('deal') ? 'context-scope-block-selected' : '',
]"
@click="toggleContextScope('deal')"
>
<span v-if="contextPickerEnabled" class="context-scope-label">Сделка</span>
<p class="text-sm font-medium">
{{ formatDealHeadline(selectedWorkspaceDeal) }}
</p>
<p class="mt-1 text-[11px] text-base-content/75">
{{ selectedWorkspaceDealSubtitle }}
</p>
<button
v-if="selectedWorkspaceDealSteps.length"
class="mt-2 text-[11px] font-medium text-primary hover:underline"
@click="selectedDealStepsExpanded = !selectedDealStepsExpanded"
>
{{ selectedDealStepsExpanded ? "Скрыть шаги" : `Показать шаги (${selectedWorkspaceDealSteps.length})` }}
</button>
<div v-if="selectedDealStepsExpanded && selectedWorkspaceDealSteps.length" class="mt-2 space-y-1.5">
<div
v-for="step in selectedWorkspaceDealSteps"
:key="step.id"
class="flex items-start gap-2 rounded-lg border border-base-300/70 bg-base-100/80 px-2 py-1.5"
>
<input
type="checkbox"
class="checkbox checkbox-xs mt-0.5"
:checked="isDealStepDone(step)"
disabled
>
<div class="min-w-0 flex-1">
<p class="truncate text-[11px] font-medium" :class="isDealStepDone(step) ? 'line-through text-base-content/60' : 'text-base-content/90'">
{{ step.title }}
</p>
<p class="mt-0.5 text-[10px] text-base-content/55">{{ formatDealStepMeta(step) }}</p>
</div>
</div>
</div>
</div>
<div
class="relative"
:class="[
contextPickerEnabled ? 'context-scope-block context-scope-block-active' : '',
hasContextScope('summary') ? 'context-scope-block-selected' : '',
]"
@click="toggleContextScope('summary')"
>
<span v-if="contextPickerEnabled" class="context-scope-label">Summary</span>
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-base-content/60">Summary</p>
<div
v-if="activeReviewContactDiff && selectedWorkspaceContact && activeReviewContactDiff.contactId === selectedWorkspaceContact.id"
class="mb-2 rounded-xl border border-primary/35 bg-primary/5 p-2"
>
<p class="text-[11px] font-semibold uppercase tracking-wide text-primary/80">Review diff</p>
<p class="mt-1 text-[11px] text-base-content/65">Before</p>
<pre class="mt-1 whitespace-pre-wrap rounded-lg border border-base-300/70 bg-base-100 px-2 py-1.5 text-[11px] leading-relaxed text-base-content/65 line-through">{{ activeReviewContactDiff.before || "Empty" }}</pre>
<p class="mt-2 text-[11px] text-base-content/65">After</p>
<pre class="mt-1 whitespace-pre-wrap rounded-lg border border-success/40 bg-success/10 px-2 py-1.5 text-[11px] leading-relaxed text-base-content">{{ activeReviewContactDiff.after || "Empty" }}</pre>
</div>
<ContactCollaborativeEditor
v-if="selectedWorkspaceContact"
:key="`contact-summary-${selectedWorkspaceContact.id}`"
v-model="selectedWorkspaceContact.description"
:room="`crm-contact-${selectedWorkspaceContact.id}`"
placeholder="Contact summary..."
:plain="true"
/>
<p v-else class="text-xs text-base-content/60">No contact selected.</p>
</div>
</div>
</div>
</aside>
</div>
</section>
<section v-else-if="selectedTab === 'documents'" 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)]">
<aside class="h-full min-h-0 border-r border-base-300 flex flex-col">
<div class="sticky top-0 z-20 border-b border-base-300 bg-base-100 p-2">
<div class="flex items-center gap-1">
<input
v-model="documentSearch"
type="text"
class="input input-bordered input-sm w-full"
placeholder="Search documents"
>
<div class="dropdown dropdown-end">
<button
tabindex="0"
class="btn btn-ghost btn-sm btn-square"
title="Sort documents"
>
<svg viewBox="0 0 24 24" class="h-4 w-4 fill-current">
<path d="M3 5h18v2H3zm3 6h12v2H6zm4 6h4v2h-4z" />
</svg>
</button>
<div tabindex="0" class="dropdown-content z-20 mt-2 w-44 rounded-xl border border-base-300 bg-base-100 p-2 shadow-lg">
<p class="px-2 pb-1 text-[11px] font-semibold uppercase tracking-wide text-base-content/55">Sort docs</p>
<button
v-for="option in documentSortOptions"
:key="`document-sort-${option.value}`"
class="btn btn-ghost btn-sm w-full justify-between"
@click="documentSortMode = option.value"
>
<span>{{ option.label }}</span>
<span v-if="documentSortMode === option.value"></span>
</button>
</div>
</div>
</div>
</div>
<div class="min-h-0 flex-1 overflow-y-auto p-0">
<button
v-for="doc in filteredDocuments"
:key="doc.id"
class="w-full border-b border-base-300 px-3 py-2 text-left transition hover:bg-base-200/40"
:class="selectedDocumentId === doc.id ? 'bg-primary/10' : ''"
@click="selectedDocumentId = doc.id"
>
<div class="flex items-start justify-between gap-2">
<p class="min-w-0 flex-1 truncate text-xs font-semibold">{{ doc.title }}</p>
</div>
<p class="mt-0.5 truncate text-[11px] text-base-content/75">{{ formatDocumentScope(doc.scope) }}</p>
<p class="mt-0.5 line-clamp-2 text-[11px] text-base-content/70">{{ doc.summary }}</p>
<p class="mt-1 text-[10px] text-base-content/55">Updated {{ formatStamp(doc.updatedAt) }}</p>
</button>
<p v-if="filteredDocuments.length === 0" class="px-2 py-2 text-xs text-base-content/55">
No documents found.
</p>
</div>
</aside>
<article class="h-full min-h-0 flex flex-col">
<div v-if="selectedDocument" class="flex h-full min-h-0 flex-col p-3 md:p-4">
<div class="border-b border-base-300 pb-2">
<p class="font-medium">{{ selectedDocument.title }}</p>
<p class="text-xs text-base-content/60">
{{ formatDocumentScope(selectedDocument.scope) }} · {{ selectedDocument.owner }}
</p>
<p class="mt-1 text-sm text-base-content/80">{{ selectedDocument.summary }}</p>
</div>
<div class="mt-3 min-h-0 flex-1 overflow-y-auto">
<ContactCollaborativeEditor
:key="`doc-editor-${selectedDocument.id}`"
v-model="selectedDocument.body"
:room="`crm-doc-${selectedDocument.id}`"
placeholder="Describe policy, steps, rules, and exceptions..."
/>
</div>
</div>
<div v-else class="flex h-full items-center justify-center text-sm text-base-content/60">
No document selected.
</div>
</article>
</div>
</section>
<div
v-if="reviewActive && selectedTab === 'communications'"
class="pointer-events-none fixed inset-x-2 bottom-2 z-40 md:inset-auto md:right-4 md:bottom-4 md:w-[390px]"
>
<section class="pointer-events-auto rounded-2xl border border-base-300 bg-base-100/95 p-3 shadow-2xl backdrop-blur">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
Review {{ activeChangeStepNumber }}/{{ activeChangeItems.length }}
</p>
<p class="truncate text-sm font-semibold text-base-content">
{{ activeChangeItem?.title || "Change step" }}
</p>
</div>
<button class="btn btn-ghost btn-xs" @click="finishReview(true)">Close</button>
</div>
<div v-if="activeChangeItem" class="mt-2 rounded-xl border border-base-300 bg-base-200/35 p-2">
<p class="text-xs text-base-content/80">
{{ describeChangeEntity(activeChangeItem.entity) }} {{ describeChangeAction(activeChangeItem.action) }}
</p>
<label class="mt-1 inline-flex items-center gap-2 text-xs">
<input
type="checkbox"
class="checkbox checkbox-xs"
:checked="activeChangeApproved"
:disabled="activeChangeItem.rolledBack"
@change="onActiveReviewApprovalInput"
>
<span>{{ activeChangeItem.rolledBack ? "Already rolled back" : "Approve this step" }}</span>
</label>
</div>
<div class="mt-2 max-h-40 space-y-1 overflow-y-auto pr-1">
<div
v-for="(item, index) in activeChangeItems"
:key="`review-step-${item.id}`"
class="flex items-center gap-2 rounded-lg border px-2 py-1"
:class="index === activeChangeIndex ? 'border-primary/45 bg-primary/10' : 'border-base-300 bg-base-100'"
>
<button
class="min-w-0 flex-1 text-left"
@click="openChangeItemTarget(item)"
>
<p class="truncate text-xs font-medium text-base-content">
{{ index + 1 }}. {{ item.title }}
</p>
<p class="truncate text-[11px] text-base-content/65">
{{ describeChangeEntity(item.entity) }}
</p>
</button>
<input
type="checkbox"
class="checkbox checkbox-xs"
:checked="isReviewItemApproved(item)"
:disabled="item.rolledBack"
@change="onReviewItemApprovalInput(item.id, $event)"
>
</div>
</div>
<div class="mt-3 flex items-center justify-between gap-2">
<div class="join">
<button
class="btn btn-xs join-item"
:disabled="activeChangeIndex <= 0"
@click="goToPreviousChangeStep"
>
Prev
</button>
<button
class="btn btn-xs join-item"
:disabled="activeChangeIndex >= activeChangeItems.length - 1"
@click="goToNextChangeStep"
>
Next
</button>
</div>
<p class="text-[11px] text-base-content/70">
Rollback marked: {{ selectedRollbackCount }}
</p>
</div>
<div class="mt-2 flex flex-wrap gap-2">
<button class="btn btn-xs btn-outline" @click="setReviewApprovalForAll(true)">Approve all</button>
<button class="btn btn-xs btn-outline" @click="setReviewApprovalForAll(false)">Mark all rollback</button>
<button
class="btn btn-xs btn-warning"
:disabled="changeActionBusy || selectedRollbackCount === 0"
@click="rollbackSelectedChangeItems"
>
{{ changeActionBusy ? "Applying..." : "Rollback selected" }}
</button>
<button class="btn btn-xs btn-primary ml-auto" @click="finishReview(true)">Done</button>
</div>
</section>
</div>
</div>
</div>
</main>
</div>
</template>
</div>
</template>
<style scoped>
.calendar-content-wrap {
position: relative;
padding-left: 40px;
padding-right: 40px;
}
.calendar-content-scroll {
height: 100%;
overscroll-behavior: contain;
}
.calendar-scene {
min-height: 100%;
min-width: 100%;
transform-origin: center center;
}
.calendar-scene-hidden {
visibility: hidden;
}
.calendar-scene.cursor-zoom-in,
.calendar-scene.cursor-zoom-in * {
cursor: zoom-in;
}
.calendar-scene.cursor-zoom-out,
.calendar-scene.cursor-zoom-out * {
cursor: zoom-out;
}
.calendar-week-grid {
display: grid;
grid-template-columns: repeat(7, minmax(165px, 1fr));
gap: 8px;
min-width: 1180px;
}
.calendar-side-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 4;
width: 28px;
height: 28px;
border: 1px solid color-mix(in oklab, var(--color-base-content) 16%, transparent);
border-radius: 999px;
background: color-mix(in oklab, var(--color-base-100) 88%, transparent);
color: color-mix(in oklab, var(--color-base-content) 86%, transparent);
display: inline-flex;
align-items: center;
justify-content: center;
transition: background-color 120ms ease, border-color 120ms ease, transform 120ms ease;
}
.calendar-side-nav:hover {
border-color: color-mix(in oklab, var(--color-primary) 50%, transparent);
background: color-mix(in oklab, var(--color-primary) 14%, var(--color-base-100));
transform: translateY(-50%) scale(1.03);
}
.calendar-side-nav-left {
left: 4px;
}
.calendar-side-nav-right {
right: 4px;
}
.calendar-hover-targetable {
transform-origin: center center;
transition: transform 320ms ease, box-shadow 320ms ease, outline-color 320ms ease;
}
.calendar-hover-target {
outline: 2px solid color-mix(in oklab, var(--color-primary) 66%, transparent);
outline-offset: 1px;
box-shadow: 0 0 0 1px color-mix(in oklab, var(--color-primary) 32%, transparent) inset;
}
.calendar-zoom-prime-active {
z-index: 2;
}
.calendar-zoom-inline {
position: relative;
display: flex;
align-items: center;
width: 128px;
height: 22px;
padding: 0 10px;
}
.calendar-zoom-slider {
width: 100%;
height: 18px;
margin: 0;
background: transparent;
-webkit-appearance: none;
appearance: none;
cursor: pointer;
}
.calendar-zoom-slider:focus-visible {
outline: none;
}
.calendar-zoom-slider::-webkit-slider-runnable-track {
height: 2px;
border-radius: 999px;
background: color-mix(in oklab, var(--color-base-content) 22%, transparent);
}
.calendar-zoom-slider::-webkit-slider-thumb {
-webkit-appearance: none;
margin-top: -4px;
width: 10px;
height: 10px;
border-radius: 999px;
border: 1px solid color-mix(in oklab, var(--color-base-content) 35%, transparent);
background: color-mix(in oklab, var(--color-base-100) 98%, var(--color-base-content));
}
.calendar-zoom-slider::-moz-range-track {
height: 2px;
border-radius: 999px;
background: color-mix(in oklab, var(--color-base-content) 22%, transparent);
}
.calendar-zoom-slider::-moz-range-progress {
background: transparent;
}
.calendar-zoom-slider::-moz-range-thumb {
width: 10px;
height: 10px;
border-radius: 999px;
border: 1px solid color-mix(in oklab, var(--color-base-content) 35%, transparent);
background: color-mix(in oklab, var(--color-base-100) 98%, var(--color-base-content));
}
.calendar-zoom-marks {
position: absolute;
inset-inline: 10px;
top: 50%;
transform: translateY(-50%);
display: flex;
justify-content: space-between;
pointer-events: none;
}
.calendar-zoom-mark {
width: 4px;
height: 4px;
border-radius: 999px;
background: color-mix(in oklab, var(--color-base-content) 30%, transparent);
}
.calendar-zoom-mark-active {
width: 6px;
height: 6px;
background: color-mix(in oklab, var(--color-base-content) 78%, transparent);
}
.calendar-zoom-overlay {
position: absolute;
z-index: 6;
border: 3px solid color-mix(in oklab, var(--color-primary) 64%, transparent);
border-radius: 12px;
background: color-mix(in oklab, var(--color-primary) 14%, transparent);
overflow: hidden;
pointer-events: none;
transition:
left 2400ms cubic-bezier(0.16, 0.86, 0.18, 1),
top 2400ms cubic-bezier(0.16, 0.86, 0.18, 1),
width 2400ms cubic-bezier(0.16, 0.86, 0.18, 1),
height 2400ms cubic-bezier(0.16, 0.86, 0.18, 1);
}
.calendar-zoom-overlay-content {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
gap: 4px;
padding: 10px;
color: color-mix(in oklab, var(--color-base-content) 86%, transparent);
}
.calendar-zoom-overlay-title {
margin: 0;
font-size: 14px;
font-weight: 700;
line-height: 1.2;
}
.calendar-zoom-overlay-subtitle {
margin: 0;
font-size: 11px;
line-height: 1.2;
opacity: 0.74;
}
@media (max-width: 960px) {
.calendar-content-wrap {
padding-left: 32px;
padding-right: 32px;
}
.calendar-week-grid {
grid-template-columns: repeat(7, minmax(150px, 1fr));
min-width: 1060px;
}
.calendar-side-nav {
width: 24px;
height: 24px;
}
.calendar-zoom-inline {
width: 108px;
}
}
.pilot-shell {
background:
radial-gradient(circle at 10% -10%, rgba(124, 144, 255, 0.25), transparent 40%),
radial-gradient(circle at 85% 110%, rgba(88, 101, 242, 0.2), transparent 45%),
#151821;
color: #f5f7ff;
}
.pilot-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
padding: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(8, 10, 16, 0.2);
}
.pilot-threads {
padding: 10px 10px 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.pilot-timeline {
padding: 10px 8px;
}
.pilot-stream-wrap {
position: relative;
}
.pilot-thread-overlay {
position: absolute;
inset: 0;
z-index: 20;
padding: 10px 8px;
background:
linear-gradient(180deg, rgba(15, 18, 28, 0.96), rgba(15, 18, 28, 0.92)),
rgba(15, 18, 28, 0.9);
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.pilot-row {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 6px;
border-radius: 10px;
}
.pilot-row:hover {
background: rgba(255, 255, 255, 0.04);
}
.pilot-avatar {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 30px;
width: 30px;
height: 30px;
border-radius: 999px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.05em;
color: #e6ebff;
background: linear-gradient(135deg, #5865f2, #7c90ff);
}
.pilot-avatar-user {
background: linear-gradient(135deg, #2a9d8f, #38b2a7);
}
.pilot-body {
min-width: 0;
width: 100%;
}
.pilot-meta {
display: flex;
align-items: center;
gap: 8px;
}
.pilot-author {
font-size: 13px;
font-weight: 700;
color: #f8f9ff;
}
.pilot-time {
font-size: 11px;
color: rgba(255, 255, 255, 0.45);
}
.pilot-message-text {
margin-top: 2px;
font-size: 13px;
line-height: 1.5;
white-space: pre-wrap;
color: rgba(255, 255, 255, 0.92);
}
.pilot-input-wrap {
display: grid;
gap: 6px;
padding: 10px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
background: transparent;
}
.pilot-input-shell {
position: relative;
}
.pilot-input-textarea {
width: 100%;
min-height: 96px;
resize: none;
border-radius: 0;
border: 0;
background: transparent;
color: #f5f7ff;
padding: 10px 88px 36px 12px;
font-size: 13px;
line-height: 1.4;
}
.pilot-input-textarea::placeholder {
color: rgba(255, 255, 255, 0.4);
}
.pilot-input-textarea:focus {
outline: none;
box-shadow: none;
}
.pilot-input-actions {
position: absolute;
right: 10px;
bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.pilot-input-context {
position: absolute;
left: 10px;
bottom: 8px;
right: 96px;
display: flex;
align-items: center;
min-height: 24px;
overflow: hidden;
}
.context-pipette-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.28);
background: rgba(255, 255, 255, 0.06);
color: rgba(245, 247, 255, 0.94);
padding: 0;
transition: transform 220ms ease, border-color 220ms ease, background-color 220ms ease;
}
.context-pipette-trigger:hover {
transform: scale(1.03);
border-color: color-mix(in oklab, var(--color-primary) 62%, transparent);
background: color-mix(in oklab, var(--color-primary) 18%, transparent);
}
.context-pipette-active {
border-color: color-mix(in oklab, var(--color-primary) 72%, transparent);
background: color-mix(in oklab, var(--color-primary) 24%, transparent);
box-shadow: 0 0 0 1px color-mix(in oklab, var(--color-primary) 38%, transparent) inset;
}
.pilot-context-chips {
display: flex;
align-items: center;
gap: 6px;
max-width: 100%;
overflow-x: auto;
padding-bottom: 1px;
}
.pilot-context-chips::-webkit-scrollbar {
height: 3px;
}
.pilot-context-chips::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.24);
border-radius: 999px;
}
.context-pipette-chip {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.28);
background: rgba(255, 255, 255, 0.08);
color: rgba(245, 247, 255, 0.95);
padding: 4px 9px;
font-size: 10px;
line-height: 1;
white-space: nowrap;
transition: transform 220ms ease, border-color 220ms ease, background-color 220ms ease;
}
.context-pipette-chip:hover {
transform: scale(1.03);
border-color: color-mix(in oklab, var(--color-primary) 62%, transparent);
background: color-mix(in oklab, var(--color-primary) 20%, transparent);
}
.pilot-meter {
position: absolute;
left: 12px;
right: 88px;
bottom: 9px;
height: 22px;
}
.pilot-wave-canvas {
width: 100%;
height: 100%;
display: block;
overflow: hidden;
}
.pilot-wave-canvas :deep(wave) {
display: block;
height: 100% !important;
}
.pilot-wave-canvas :deep(canvas) {
height: 100% !important;
}
.pilot-mic-active {
border-color: rgba(255, 95, 95, 0.8) !important;
background: rgba(255, 95, 95, 0.16) !important;
color: #ffd9d9 !important;
}
.pilot-mic-error {
margin: 0;
font-size: 11px;
color: rgba(255, 160, 160, 0.92);
}
.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-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;
}
.pilot-stream-status {
margin-top: 8px;
padding: 2px 4px 8px;
display: grid;
gap: 3px;
}
.pilot-stream-head {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: 2px;
}
.pilot-stream-caption {
margin: 0;
text-align: center;
font-size: 10px;
line-height: 1.3;
color: rgba(174, 185, 223, 0.72);
}
.pilot-stream-toggle {
border: 1px solid rgba(164, 179, 230, 0.35);
background: rgba(25, 33, 56, 0.45);
color: rgba(229, 235, 255, 0.92);
border-radius: 999px;
font-size: 10px;
line-height: 1.2;
padding: 3px 8px;
transition: background-color 0.15s ease, border-color 0.15s ease;
}
.pilot-stream-toggle:hover {
border-color: rgba(180, 194, 240, 0.6);
background: rgba(35, 45, 72, 0.7);
}
.pilot-stream-line {
margin: 0;
text-align: center;
font-size: 11px;
line-height: 1.35;
color: rgba(189, 199, 233, 0.72);
}
.pilot-stream-line-current {
color: rgba(234, 239, 255, 0.95);
}
.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 {
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-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>