Files
clientsflow/frontend/app.vue
2026-02-20 12:10:25 +07:00

4454 lines
169 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 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 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 confirmLatestChangeSetMutation from "./graphql/operations/confirm-latest-change-set.graphql?raw";
import rollbackLatestChangeSetMutation from "./graphql/operations/rollback-latest-change-set.graphql?raw";
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 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;
channel: "Telegram" | "WhatsApp" | "Instagram" | "Phone" | "Email";
kind: "message" | "call";
direction: "in" | "out";
text: string;
audioUrl?: string;
duration?: string;
transcript?: 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;
};
const selectedTab = ref<TabId>("communications");
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 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<{
entity: string;
action: string;
title: string;
before: string;
after: string;
}> | null;
createdAt?: string;
_live?: boolean;
};
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 pilotLiveLogs = ref<Array<{ id: string; text: string; at: string }>>([]);
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;
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("");
});
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) {
pilotMessages.value = [];
chatConversations.value = [];
return;
}
await Promise.all([loadPilotMessages(), loadChatConversations(), refreshCrmData()]);
} catch {
authMe.value = null;
pilotMessages.value = [];
chatConversations.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();
await Promise.all([loadPilotMessages(), loadChatConversations(), refreshCrmData()]);
} 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);
stopPilotBackgroundPolling();
authMe.value = null;
pilotMessages.value = [];
livePilotUserText.value = "";
livePilotAssistantText.value = "";
pilotChat.messages = [];
chatConversations.value = [];
}
async function refreshCrmData() {
const data = await gqlFetch<{
dashboard: {
contacts: Contact[];
communications: CommItem[];
calendar: CalendarEvent[];
deals: Deal[];
feed: FeedCard[];
pins: CommPin[];
documents: WorkspaceDocument[];
};
}>(dashboardQuery);
contacts.value = data.dashboard.contacts ?? [];
commItems.value = data.dashboard.communications ?? [];
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) ?? []),
}));
}
async function sendPilotText(rawText: string) {
const text = rawText.trim();
if (!text || pilotSending.value) return;
pilotSending.value = true;
pilotInput.value = "";
livePilotUserText.value = text;
livePilotAssistantText.value = "";
pilotLiveLogs.value = [];
try {
await pilotChat.sendMessage({ text });
} 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 changePanelOpen = ref(true);
const changeActionBusy = ref(false);
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
);
});
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()]);
} finally {
changeActionBusy.value = 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);
if (!authResolved.value) {
void bootstrapSession().finally(() => {
if (authMe.value) startPilotBackgroundPolling();
});
return;
}
if (authMe.value) startPilotBackgroundPolling();
});
onBeforeUnmount(() => {
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 (lifecycleClock) {
clearInterval(lifecycleClock);
lifecycleClock = null;
}
});
const calendarView = ref<CalendarView>("month");
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 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" },
];
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 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) {
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() {
const now = new Date();
selectedDateKey.value = dayKey(now);
calendarCursor.value = new Date(now.getFullYear(), now.getMonth(), 1);
}
function pickDate(key: string) {
selectedDateKey.value = key;
const d = new Date(`${key}T00:00:00`);
calendarCursor.value = new Date(d.getFullYear(), d.getMonth(), 1);
}
function openYearMonth(monthIndex: number) {
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 selectedDocumentType = ref<"All" | WorkspaceDocument["type"]>("All");
const documentTypes = computed(() =>
["All", ...new Set(documents.value.map((item) => item.type))] as ("All" | WorkspaceDocument["type"])[],
);
const filteredDocuments = computed(() => {
const query = documentSearch.value.trim().toLowerCase();
return documents.value
.filter((item) => {
if (selectedDocumentType.value !== "All" && item.type !== selectedDocumentType.value) return false;
if (!query) return true;
const haystack = [item.title, item.summary, item.owner, item.scope, item.body].join(" ").toLowerCase();
return haystack.includes(query);
})
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
});
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 openPilotInstructions() {
selectedTab.value = "documents";
if (!selectedDocumentId.value && filteredDocuments.value.length) {
const first = filteredDocuments.value[0];
if (first) selectedDocumentId.value = first.id;
}
}
const peopleLeftMode = ref<PeopleLeftMode>("contacts");
const peopleListMode = ref<"contacts" | "deals">("contacts");
const peopleSearch = ref("");
const peopleSortMode = ref<PeopleSortMode>("lastContact");
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);
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,
};
})
.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),
);
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">("message");
const commQuickMenuOpen = ref(false);
const commEventSaving = ref(false);
const commEventError = ref("");
const commEventMode = ref<"planned" | "logged">("planned");
const commEventForm = ref({
startDate: "",
startTime: "",
durationMinutes: 30,
});
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;
commEventError.value = "";
eventCloseOpen.value = {};
eventCloseDraft.value = {};
eventCloseSaving.value = {};
eventCloseError.value = {};
eventArchiveRecordingById.value = {};
eventArchiveTranscribingById.value = {};
eventArchiveMicErrorById.value = {};
const preferred = selectedCommThread.value?.channels.find((channel) => channel !== "Phone") ?? "";
commSendChannel.value = preferred;
});
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 selectedThreadRecommendation = computed(() => {
if (!selectedCommThread.value) return null;
const cards = feedCards.value
.filter((card) => card.contact === selectedCommThread.value?.contact)
.sort((a, b) => a.at.localeCompare(b.at));
return cards[cards.length - 1] ?? null;
});
const selectedCommPins = computed(() => {
if (!selectedCommThread.value) return [];
return commPins.value.filter((item) => item.contact === selectedCommThread.value?.contact);
});
const selectedCommLifecycleEvents = computed(() => {
if (!selectedCommThread.value) return [];
const nowMs = lifecycleNowMs.value;
return sortedEvents.value
.filter((event) => event.contact === selectedCommThread.value?.contact)
.map((event) => {
const phase = eventLifecyclePhase(event, nowMs);
return {
event,
phase,
timelineAt: eventTimelineAt(event, phase),
};
})
.sort((a, b) => a.timelineAt.localeCompare(b.timelineAt))
.slice(-12);
});
const threadStreamItems = computed(() => {
const messageRows = visibleThreadItems.value.map((item) => ({
id: `comm-${item.id}`,
at: item.at,
kind: item.kind,
item,
})).sort((a, b) => a.at.localeCompare(b.at));
const centeredRows: Array<
| {
id: string;
at: string;
kind: "eventLifecycle";
event: CalendarEvent;
phase: EventLifecyclePhase;
}
| {
id: string;
at: string;
kind: "recommendation";
card: FeedCard;
}
> = [];
for (const entry of selectedCommLifecycleEvents.value) {
centeredRows.push({
id: `event-${entry.event.id}`,
at: entry.timelineAt,
kind: "eventLifecycle",
event: entry.event,
phase: entry.phase,
});
}
if (selectedThreadRecommendation.value) {
centeredRows.push({
id: `rec-${selectedThreadRecommendation.value.id}`,
at: selectedThreadRecommendation.value.at,
kind: "recommendation",
card: selectedThreadRecommendation.value,
});
}
return [...messageRows, ...centeredRows].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) => ({
id: `pin-${pin.id}`,
kind: "pin" as const,
text: pin.text,
}));
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 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(() => {
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;
}
if (selectedContact.value) return selectedContact.value;
return contacts.value[0] ?? null;
});
const selectedWorkspaceDeal = computed(() => {
if (selectedWorkspaceContact.value) {
const linked = deals.value.find((deal) => deal.contact === selectedWorkspaceContact.value?.name);
if (linked) return linked;
}
return deals.value.find((deal) => deal.id === selectedDealId.value) ?? 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);
});
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 threadTone(thread: { contact: string; items: CommItem[] }) {
const now = Date.now();
const oneDay = 24 * 60 * 60 * 1000;
const hasEvent = sortedEvents.value.some((event) => {
if (event.contact !== thread.contact) return false;
const start = new Date(event.start).getTime();
const end = new Date(event.end).getTime();
return end <= now || start - now <= oneDay;
});
if (hasEvent) return "event";
const hasRecommendation = feedCards.value.some((card) => card.contact === thread.contact && card.decision === "pending");
if (hasRecommendation) return "recommendation";
const last = thread.items[thread.items.length - 1];
if (!last) return "neutral";
if (last.direction === "in") return "message";
return "neutral";
}
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 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) {
selectedTab.value = "communications";
peopleLeftMode.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;
selectedDealStepsExpanded.value = false;
openCommunicationThread(deal.contact);
}
function openThreadFromCalendarItem(event: CalendarEvent) {
if (!event.contact?.trim()) {
selectedTab.value = "communications";
peopleLeftMode.value = "calendar";
pickDate(event.start.slice(0, 10));
return;
}
openCommunicationThread(event.contact);
}
function openEventFromContact(event: CalendarEvent) {
selectedTab.value = "communications";
peopleLeftMode.value = "calendar";
pickDate(event.start.slice(0, 10));
}
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 openCommEventModal(mode: "planned" | "logged") {
if (!selectedCommThread.value) return;
commEventMode.value = mode;
setDefaultCommEventForm(mode);
commEventError.value = "";
commComposerMode.value = mode;
commQuickMenuOpen.value = false;
}
function closeCommEventModal() {
if (commEventSaving.value) return;
commComposerMode.value = "message";
commEventError.value = "";
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 "Опиши итог/отчёт по прошедшему событию...";
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}`;
}
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 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();
if (commComposerMode.value === "message") {
void sendCommMessage();
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);
selectedTab.value = "communications";
peopleLeftMode.value = "calendar";
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>
<button
class="btn btn-ghost btn-xs btn-square text-white/80 hover:bg-white/10"
title="Instructions"
@click="openPilotInstructions"
>
<svg viewBox="0 0 24 24" class="h-4 w-4 fill-current">
<path d="M19.14 12.94a7.43 7.43 0 0 0 .05-.94 7.43 7.43 0 0 0-.05-.94l2.03-1.58a.5.5 0 0 0 .12-.64l-1.92-3.32a.5.5 0 0 0-.6-.22l-2.39.96a7.28 7.28 0 0 0-1.62-.94l-.36-2.54A.5.5 0 0 0 14.9 2h-3.8a.5.5 0 0 0-.49.42l-.36 2.54c-.58.22-1.12.53-1.62.94l-2.39-.96a.5.5 0 0 0-.6.22L3.72 8.84a.5.5 0 0 0 .12.64l2.03 1.58c-.03.31-.05.63-.05.94s.02.63.05.94l-2.03 1.58a.5.5 0 0 0-.12.64l1.92 3.32c.13.23.4.32.64.22l2.39-.96c.5.41 1.04.72 1.62.94l.36 2.54c.04.24.25.42.49.42h3.8c.24 0 .45-.18.49-.42l.36-2.54c.58-.22 1.12-.53 1.62-.94l2.39.96c.24.1.51.01.64-.22l1.92-3.32a.5.5 0 0 0-.12-.64zM13 15.5A3.5 3.5 0 1 1 13 8.5a3.5 3.5 0 0 1 0 7z" />
</svg>
</button>
</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>
<div v-else class="pilot-message-text">
{{ message.text }}
</div>
</div>
</div>
<div v-if="pilotLiveLogs.length" class="pilot-stream-status">
<p
v-for="log in pilotLiveLogs"
: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
v-if="latestChangeMessage && latestChangeMessage.changeItems && latestChangeMessage.changeItems.length"
class="mb-2 rounded-xl border border-amber-300/40 bg-amber-500/10 p-2"
>
<div class="flex items-center justify-between gap-2">
<div>
<p class="text-xs font-semibold text-amber-100">Detected changes</p>
<p class="text-[11px] text-amber-100/80">
{{ latestChangeMessage.changeSummary || `Changed: ${latestChangeMessage.changeItems.length}` }}
· status: {{ latestChangeMessage.changeStatus || "pending" }}
</p>
</div>
<div class="flex items-center gap-1">
<button class="btn btn-xs btn-ghost" @click="changePanelOpen = !changePanelOpen">
{{ changePanelOpen ? "Hide" : "Show" }}
</button>
<button
class="btn btn-xs"
:disabled="changeActionBusy || latestChangeMessage.changeStatus === 'confirmed'"
@click="confirmLatestChangeSet"
>
Keep
</button>
<button
class="btn btn-xs btn-outline"
:disabled="changeActionBusy || latestChangeMessage.changeStatus === 'rolled_back'"
@click="rollbackLatestChangeSet"
>
Rollback
</button>
</div>
</div>
<div v-if="changePanelOpen" class="mt-2 max-h-44 space-y-2 overflow-y-auto pr-1">
<div
v-for="(item, idx) in latestChangeMessage.changeItems"
:key="`change-item-${idx}`"
class="rounded-lg border border-amber-200/30 bg-[#1e2230] p-2"
>
<p class="text-[11px] font-semibold text-white/90">{{ item.title }}</p>
<p class="text-[11px] text-white/60">{{ item.entity }} · {{ item.action }}</p>
<p v-if="item.before" class="mt-1 text-[11px] text-red-300/80">- {{ item.before }}</p>
<p v-if="item.after" class="text-[11px] text-emerald-300/80">+ {{ item.after }}</p>
</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 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="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 v-if="selectedTab === 'communications'" class="join">
<button
class="btn btn-sm join-item"
:class="
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="peopleLeftMode = 'contacts'"
>
Contacts
</button>
<button
class="btn btn-sm join-item"
:class="
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="peopleLeftMode = 'calendar'"
>
Calendar
</button>
</div>
<div v-else />
<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>
<ul tabindex="0" class="menu dropdown-content z-30 mt-2 w-52 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg">
<li class="menu-title px-2 py-1">
<span>{{ authDisplayName }}</span>
</li>
<li><button @click="logout">Logout</button></li>
</ul>
</div>
</div>
</div>
<div
class="min-h-0 flex-1"
:class="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="flex h-full min-h-0 flex-col gap-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="`calendar-view-${option.value}`"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</div>
</div>
<div class="min-h-0 flex-1 overflow-y-auto pr-1">
<div v-if="calendarView === 'month'" class="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="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="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="space-y-2">
<article
v-for="day in weekDays"
:key="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="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="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"
@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="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
<button
v-for="item in yearMonths"
:key="`year-month-${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>
<button
v-if="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>
</button>
</div>
<div v-else class="space-y-2">
<button
v-for="event in sortedEvents"
:key="event.id"
class="block w-full rounded-xl border border-base-300 p-3 text-left transition 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>
</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="peopleLeftMode = 'contacts'">Contacts</button>
<button class="btn btn-sm join-item btn-ghost" @click="peopleLeftMode = 'calendar'">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"
@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">
<button
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' : ''"
@click="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 :src="thread.avatar" :alt="thread.contact">
</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 gap-2">
<p class="min-w-0 flex-1 truncate text-[11px] text-base-content/75">{{ thread.lastText }}</p>
<span
class="inline-block h-2 w-2 rounded-full"
:class="
threadTone(thread) === 'event'
? 'bg-red-500'
: threadTone(thread) === 'recommendation'
? 'bg-violet-500'
: threadTone(thread) === 'message'
? 'bg-blue-500'
: 'bg-base-300'
"
/>
</div>
</div>
</div>
</button>
<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' : ''"
@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="togglePinForEntry(entry)"
>
<div v-if="entry.kind === 'pin'" 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 text-base-content/85">{{ stripPinnedPrefix(entry.text) }}</p>
</article>
</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">
<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)">
<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 === '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'">
<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>
</p>
</div>
</div>
</div>
</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 event"
@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>
</ul>
</div>
</div>
<div class="comm-input-wrap">
<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 !== 'message'"
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>
<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' : 'Create event')"
@click="commComposerMode === 'message' ? sendCommMessage() : createCommEvent()"
>
<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 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">
<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>
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-base-content/60">Summary</p>
<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-3">
<div class="rounded-xl border border-base-300 p-3">
<div class="grid gap-2 md:grid-cols-[1fr_220px]">
<label class="form-control">
<span class="label-text text-xs uppercase tracking-wide text-base-content/60">Search docs</span>
<input
v-model="documentSearch"
type="text"
class="input input-bordered input-sm"
placeholder="Title, owner, scope, content"
>
</label>
<label class="form-control">
<span class="label-text text-xs uppercase tracking-wide text-base-content/60">Type</span>
<select v-model="selectedDocumentType" class="select select-bordered select-sm">
<option v-for="item in documentTypes" :key="`doc-type-${item}`" :value="item">{{ item }}</option>
</select>
</label>
</div>
</div>
<div class="grid min-h-0 flex-1 gap-0 md:grid-cols-12">
<aside class="min-h-0 border-r border-base-300 md:col-span-4 flex flex-col">
<div class="min-h-0 flex-1 space-y-2 overflow-y-auto p-2">
<button
v-for="doc in filteredDocuments"
:key="doc.id"
class="w-full rounded-xl border border-base-300 p-3 text-left transition hover:bg-base-200/60"
:class="selectedDocumentId === doc.id ? 'border-primary bg-primary/5' : ''"
@click="selectedDocumentId = doc.id"
>
<p class="font-medium">{{ doc.title }}</p>
<p class="mt-1 text-xs text-base-content/60">{{ doc.type }} · {{ doc.owner }}</p>
<p class="mt-1 line-clamp-2 text-xs text-base-content/75">{{ doc.summary }}</p>
<p class="mt-1 text-xs text-base-content/55">Updated {{ formatStamp(doc.updatedAt) }}</p>
</button>
</div>
</aside>
<article class="min-h-0 md:col-span-8 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">
{{ selectedDocument.type }} · {{ 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>
</div>
</main>
</div>
</template>
</div>
</template>
<style scoped>
.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-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-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-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);
}
</style>