5178 lines
196 KiB
Vue
5178 lines
196 KiB
Vue
<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 rollbackChangeSetItemsMutation from "./graphql/operations/rollback-change-set-items.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");
|
||
const peopleLeftMode = ref<PeopleLeftMode>("contacts");
|
||
|
||
function dayKey(date: Date) {
|
||
const y = date.getFullYear();
|
||
const m = String(date.getMonth() + 1).padStart(2, "0");
|
||
const d = String(date.getDate()).padStart(2, "0");
|
||
return `${y}-${m}-${d}`;
|
||
}
|
||
|
||
function formatDay(iso: string) {
|
||
return new Intl.DateTimeFormat("en-GB", {
|
||
day: "2-digit",
|
||
month: "short",
|
||
year: "numeric",
|
||
}).format(new Date(iso));
|
||
}
|
||
|
||
function formatTime(iso: string) {
|
||
return new Intl.DateTimeFormat("en-GB", {
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
}).format(new Date(iso));
|
||
}
|
||
|
||
function formatThreadTime(iso: string) {
|
||
return new Intl.DateTimeFormat("en-GB", {
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
hour12: false,
|
||
})
|
||
.format(new Date(iso))
|
||
.replace(":", ".");
|
||
}
|
||
|
||
function formatStamp(iso: string) {
|
||
return new Intl.DateTimeFormat("en-GB", {
|
||
day: "2-digit",
|
||
month: "short",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
}).format(new Date(iso));
|
||
}
|
||
|
||
function atOffset(days: number, hour: number, minute: number) {
|
||
const d = new Date();
|
||
d.setDate(d.getDate() + days);
|
||
d.setHours(hour, minute, 0, 0);
|
||
return d.toISOString();
|
||
}
|
||
|
||
function inMinutes(minutes: number) {
|
||
const d = new Date();
|
||
d.setMinutes(d.getMinutes() + minutes, 0, 0);
|
||
return d.toISOString();
|
||
}
|
||
|
||
function endAfter(startIso: string, minutes: number) {
|
||
const d = new Date(startIso);
|
||
d.setMinutes(d.getMinutes() + minutes);
|
||
return d.toISOString();
|
||
}
|
||
|
||
function isEventFinalStatus(isArchived: boolean) {
|
||
return Boolean(isArchived);
|
||
}
|
||
|
||
function eventPreDueAt(event: CalendarEvent) {
|
||
return new Date(new Date(event.start).getTime() - 30 * 60 * 1000).toISOString();
|
||
}
|
||
|
||
function eventDueAt(event: CalendarEvent) {
|
||
return event.start;
|
||
}
|
||
|
||
function eventLifecyclePhase(event: CalendarEvent, nowMs: number): EventLifecyclePhase {
|
||
if (event.isArchived) return "closed";
|
||
|
||
const dueMs = new Date(eventDueAt(event)).getTime();
|
||
const preDueMs = new Date(eventPreDueAt(event)).getTime();
|
||
if (nowMs >= dueMs) return "awaiting_outcome";
|
||
if (nowMs >= preDueMs) return "due_soon";
|
||
return "scheduled";
|
||
}
|
||
|
||
function eventTimelineAt(event: CalendarEvent, phase: EventLifecyclePhase) {
|
||
if (phase === "scheduled") return event.createdAt || event.start;
|
||
if (phase === "due_soon") return eventPreDueAt(event);
|
||
return eventDueAt(event);
|
||
}
|
||
|
||
function eventRelativeLabel(event: CalendarEvent, nowMs: number) {
|
||
if (event.isArchived) return "Archived";
|
||
const diffMs = new Date(event.start).getTime() - nowMs;
|
||
const minuteMs = 60 * 1000;
|
||
const hourMs = 60 * minuteMs;
|
||
const dayMs = 24 * hourMs;
|
||
const abs = Math.abs(diffMs);
|
||
|
||
if (diffMs >= 0) {
|
||
if (abs >= dayMs) {
|
||
const days = Math.round(abs / dayMs);
|
||
return `Event in ${days} day${days === 1 ? "" : "s"}`;
|
||
}
|
||
if (abs >= hourMs) {
|
||
const hours = Math.round(abs / hourMs);
|
||
return `Event in ${hours} hour${hours === 1 ? "" : "s"}`;
|
||
}
|
||
const minutes = Math.max(1, Math.round(abs / minuteMs));
|
||
return `Event in ${minutes} minute${minutes === 1 ? "" : "s"}`;
|
||
}
|
||
|
||
if (abs >= dayMs) {
|
||
const days = Math.round(abs / dayMs);
|
||
return `Overdue by ${days} day${days === 1 ? "" : "s"}`;
|
||
}
|
||
if (abs >= hourMs) {
|
||
const hours = Math.round(abs / hourMs);
|
||
return `Overdue by ${hours} hour${hours === 1 ? "" : "s"}`;
|
||
}
|
||
const minutes = Math.max(1, Math.round(abs / minuteMs));
|
||
return `Overdue by ${minutes} minute${minutes === 1 ? "" : "s"}`;
|
||
}
|
||
|
||
function eventPhaseToneClass(phase: EventLifecyclePhase) {
|
||
if (phase === "awaiting_outcome") return "border-warning/50 bg-warning/10";
|
||
if (phase === "due_soon") return "border-info/50 bg-info/10";
|
||
if (phase === "closed") return "border-success/40 bg-success/10";
|
||
return "border-base-300 bg-base-100";
|
||
}
|
||
|
||
function toInputDate(date: Date) {
|
||
const y = date.getFullYear();
|
||
const m = String(date.getMonth() + 1).padStart(2, "0");
|
||
const d = String(date.getDate()).padStart(2, "0");
|
||
return `${y}-${m}-${d}`;
|
||
}
|
||
|
||
function toInputTime(date: Date) {
|
||
const hh = String(date.getHours()).padStart(2, "0");
|
||
const mm = String(date.getMinutes()).padStart(2, "0");
|
||
return `${hh}:${mm}`;
|
||
}
|
||
|
||
function roundToNextQuarter(date = new Date()) {
|
||
const d = new Date(date);
|
||
d.setSeconds(0, 0);
|
||
const minutes = d.getMinutes();
|
||
const rounded = Math.ceil(minutes / 15) * 15;
|
||
if (rounded >= 60) {
|
||
d.setHours(d.getHours() + 1, 0, 0, 0);
|
||
} else {
|
||
d.setMinutes(rounded, 0, 0);
|
||
}
|
||
return d;
|
||
}
|
||
|
||
function roundToPrevQuarter(date = new Date()) {
|
||
const d = new Date(date);
|
||
d.setSeconds(0, 0);
|
||
const minutes = d.getMinutes();
|
||
const rounded = Math.floor(minutes / 15) * 15;
|
||
d.setMinutes(rounded, 0, 0);
|
||
return d;
|
||
}
|
||
|
||
const feedCards = ref<FeedCard[]>([]);
|
||
|
||
const contacts = ref<Contact[]>([]);
|
||
|
||
const calendarEvents = ref<CalendarEvent[]>([]);
|
||
|
||
const commItems = ref<CommItem[]>([]);
|
||
|
||
const commPins = ref<CommPin[]>([]);
|
||
|
||
const deals = ref<Deal[]>([]);
|
||
|
||
const documents = ref<WorkspaceDocument[]>([]);
|
||
|
||
type PilotMessage = {
|
||
id: string;
|
||
role: "user" | "assistant" | "system";
|
||
text: string;
|
||
messageKind?: string | null;
|
||
requestId?: string | null;
|
||
eventType?: string | null;
|
||
phase?: string | null;
|
||
transient?: boolean | null;
|
||
thinking?: string[] | null;
|
||
tools?: string[] | null;
|
||
toolRuns?: Array<{
|
||
name: string;
|
||
status: "ok" | "error";
|
||
input: string;
|
||
output: string;
|
||
at: string;
|
||
}> | null;
|
||
changeSetId?: string | null;
|
||
changeStatus?: "pending" | "confirmed" | "rolled_back" | null;
|
||
changeSummary?: string | null;
|
||
changeItems?: Array<{
|
||
id: string;
|
||
entity: string;
|
||
entityId?: string | null;
|
||
action: string;
|
||
title: string;
|
||
before: string;
|
||
after: string;
|
||
rolledBack?: boolean;
|
||
}> | null;
|
||
createdAt?: string;
|
||
_live?: boolean;
|
||
};
|
||
|
||
type PilotChangeItem = NonNullable<PilotMessage["changeItems"]>[number];
|
||
|
||
type 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 changeActionBusy = ref(false);
|
||
const activeChangeSetId = ref("");
|
||
const activeChangeStep = ref(0);
|
||
const changeSelectionByItemId = ref<Record<string, boolean>>({});
|
||
const focusedCalendarEventId = ref("");
|
||
const uiPathSyncLocked = ref(false);
|
||
let popstateHandler: (() => void) | null = null;
|
||
const pilotHeaderPhrases = [
|
||
"Every step moves you forward",
|
||
"Focus first, results follow",
|
||
"Break down hard things into simple moves",
|
||
"Finish what matters today",
|
||
"Less noise, more action",
|
||
"Systems beat chaos",
|
||
"Important before urgent",
|
||
"The best moment to start is now",
|
||
];
|
||
const pilotHeaderText = ref("Every step moves you forward");
|
||
|
||
const latestChangeMessage = computed(() => {
|
||
return (
|
||
[...pilotMessages.value]
|
||
.reverse()
|
||
.find((m) => m.role === "assistant" && m.changeSetId && m.changeStatus !== "rolled_back") ?? null
|
||
);
|
||
});
|
||
|
||
const activeChangeMessage = computed(() => {
|
||
const targetId = activeChangeSetId.value.trim();
|
||
if (!targetId) return latestChangeMessage.value;
|
||
return (
|
||
[...pilotMessages.value]
|
||
.reverse()
|
||
.find((m) => m.role === "assistant" && m.changeSetId === targetId) ?? null
|
||
);
|
||
});
|
||
|
||
const activeChangeItems = computed(() => activeChangeMessage.value?.changeItems ?? []);
|
||
const activeChangeIndex = computed(() => {
|
||
const items = activeChangeItems.value;
|
||
if (!items.length) return 0;
|
||
return Math.max(0, Math.min(activeChangeStep.value, items.length - 1));
|
||
});
|
||
const activeChangeItem = computed(() => {
|
||
const items = activeChangeItems.value;
|
||
if (!items.length) return null;
|
||
return items[activeChangeIndex.value] ?? null;
|
||
});
|
||
|
||
const reviewActive = computed(() => Boolean(activeChangeSetId.value.trim() && activeChangeItems.value.length > 0));
|
||
const activeChangeStepNumber = computed(() => activeChangeIndex.value + 1);
|
||
const activeChangeApproved = computed(() => {
|
||
const item = activeChangeItem.value;
|
||
if (!item || item.rolledBack) return true;
|
||
return changeSelectionByItemId.value[item.id] !== false;
|
||
});
|
||
const activeReviewCalendarEventId = computed(() => {
|
||
const item = activeChangeItem.value;
|
||
if (!item || item.entity !== "calendar_event" || !item.entityId) return "";
|
||
return item.entityId;
|
||
});
|
||
const activeReviewContactId = computed(() => {
|
||
const item = activeChangeItem.value;
|
||
if (!item || item.entity !== "contact_note" || !item.entityId) return "";
|
||
return item.entityId;
|
||
});
|
||
const activeReviewDealId = computed(() => {
|
||
const item = activeChangeItem.value;
|
||
if (!item || item.entity !== "deal" || !item.entityId) return "";
|
||
return item.entityId;
|
||
});
|
||
const activeReviewMessageId = computed(() => {
|
||
const item = activeChangeItem.value;
|
||
if (!item || item.entity !== "message" || !item.entityId) return "";
|
||
return item.entityId;
|
||
});
|
||
const activeReviewContactDiff = computed(() => {
|
||
const item = activeChangeItem.value;
|
||
if (!item || item.entity !== "contact_note" || !item.entityId) return null;
|
||
return {
|
||
contactId: item.entityId,
|
||
before: normalizeChangeText(item.before),
|
||
after: normalizeChangeText(item.after),
|
||
};
|
||
});
|
||
const selectedRollbackItemIds = computed(() =>
|
||
activeChangeItems.value
|
||
.filter((item) => !item.rolledBack && changeSelectionByItemId.value[item.id] === false)
|
||
.map((item) => item.id),
|
||
);
|
||
const selectedRollbackCount = computed(() => selectedRollbackItemIds.value.length);
|
||
|
||
function setReviewApprovalForAll(approved: boolean) {
|
||
const next: Record<string, boolean> = {};
|
||
for (const item of activeChangeItems.value) {
|
||
next[item.id] = item.rolledBack ? true : approved;
|
||
}
|
||
changeSelectionByItemId.value = next;
|
||
}
|
||
|
||
function normalizeChangeText(raw: string | null | undefined) {
|
||
const text = String(raw ?? "").trim();
|
||
if (!text) return "";
|
||
try {
|
||
const parsed = JSON.parse(text) as Record<string, unknown>;
|
||
if (typeof parsed === "object" && parsed) {
|
||
const candidate = [parsed.description, parsed.summary, parsed.note, parsed.text]
|
||
.find((value) => typeof value === "string");
|
||
if (typeof candidate === "string") return candidate.trim();
|
||
}
|
||
} catch {
|
||
// No-op: keep original text when it is not JSON payload.
|
||
}
|
||
return text;
|
||
}
|
||
|
||
function describeChangeEntity(entity: string) {
|
||
if (entity === "contact_note") return "Contact summary";
|
||
if (entity === "calendar_event") return "Calendar event";
|
||
if (entity === "message") return "Message";
|
||
if (entity === "deal") return "Deal";
|
||
return entity || "Change";
|
||
}
|
||
|
||
function describeChangeAction(action: string) {
|
||
if (action === "created") return "created";
|
||
if (action === "updated") return "updated";
|
||
if (action === "deleted") return "archived";
|
||
return action || "changed";
|
||
}
|
||
|
||
function isReviewItemApproved(item: PilotChangeItem | null | undefined) {
|
||
if (!item || item.rolledBack) return true;
|
||
return changeSelectionByItemId.value[item.id] !== false;
|
||
}
|
||
|
||
function setReviewItemApproval(itemId: string, approved: boolean) {
|
||
const target = activeChangeItems.value.find((item) => item.id === itemId);
|
||
if (!target || target.rolledBack) return;
|
||
changeSelectionByItemId.value = {
|
||
...changeSelectionByItemId.value,
|
||
[itemId]: approved,
|
||
};
|
||
}
|
||
|
||
function onReviewItemApprovalInput(itemId: string, event: Event) {
|
||
const input = event.target as HTMLInputElement | null;
|
||
setReviewItemApproval(itemId, Boolean(input?.checked));
|
||
}
|
||
|
||
function onActiveReviewApprovalInput(event: Event) {
|
||
const item = activeChangeItem.value;
|
||
if (!item) return;
|
||
const input = event.target as HTMLInputElement | null;
|
||
setReviewItemApproval(item.id, Boolean(input?.checked));
|
||
}
|
||
|
||
function calendarCursorToken(date: Date) {
|
||
const y = date.getFullYear();
|
||
const m = String(date.getMonth() + 1).padStart(2, "0");
|
||
return `${y}-${m}`;
|
||
}
|
||
|
||
function calendarRouteToken(view: CalendarView) {
|
||
if (view === "day" || view === "week") {
|
||
return selectedDateKey.value;
|
||
}
|
||
if (view === "year") {
|
||
return String(calendarCursor.value.getFullYear());
|
||
}
|
||
return calendarCursorToken(calendarCursor.value);
|
||
}
|
||
|
||
function parseCalendarCursorToken(token: string | null | undefined) {
|
||
const text = String(token ?? "").trim();
|
||
const m = text.match(/^(\d{4})-(\d{2})$/);
|
||
if (!m) return null;
|
||
const year = Number(m[1]);
|
||
const month = Number(m[2]);
|
||
if (!Number.isFinite(year) || !Number.isFinite(month) || month < 1 || month > 12) return null;
|
||
return new Date(year, month - 1, 1);
|
||
}
|
||
|
||
function parseCalendarDateToken(token: string | null | undefined) {
|
||
const text = String(token ?? "").trim();
|
||
const m = text.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||
if (!m) return null;
|
||
const year = Number(m[1]);
|
||
const month = Number(m[2]);
|
||
const day = Number(m[3]);
|
||
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return null;
|
||
if (month < 1 || month > 12 || day < 1 || day > 31) return null;
|
||
const parsed = new Date(year, month - 1, day);
|
||
if (Number.isNaN(parsed.getTime())) return null;
|
||
return parsed;
|
||
}
|
||
|
||
function parseCalendarYearToken(token: string | null | undefined) {
|
||
const text = String(token ?? "").trim();
|
||
const m = text.match(/^(\d{4})$/);
|
||
if (!m) return null;
|
||
const year = Number(m[1]);
|
||
if (!Number.isFinite(year)) return null;
|
||
return year;
|
||
}
|
||
|
||
function normalizedConversationId() {
|
||
return (selectedChatId.value || authMe.value?.conversation.id || "pilot").trim();
|
||
}
|
||
|
||
function currentUiPath() {
|
||
if (selectedTab.value !== "communications") {
|
||
return withReviewQuery(`/chat/${encodeURIComponent(normalizedConversationId())}`);
|
||
}
|
||
|
||
if (peopleLeftMode.value === "calendar") {
|
||
if (focusedCalendarEventId.value.trim()) {
|
||
return withReviewQuery(`/calendar/event/${encodeURIComponent(focusedCalendarEventId.value.trim())}`);
|
||
}
|
||
return withReviewQuery(`/calendar/${encodeURIComponent(calendarView.value)}/${encodeURIComponent(calendarRouteToken(calendarView.value))}`);
|
||
}
|
||
|
||
if (peopleListMode.value === "deals" && selectedDealId.value.trim()) {
|
||
return withReviewQuery(`/deal/${encodeURIComponent(selectedDealId.value.trim())}`);
|
||
}
|
||
|
||
if (selectedContactId.value.trim()) {
|
||
return withReviewQuery(`/contact/${encodeURIComponent(selectedContactId.value.trim())}`);
|
||
}
|
||
|
||
return withReviewQuery(`/chat/${encodeURIComponent(normalizedConversationId())}`);
|
||
}
|
||
|
||
function withReviewQuery(path: string) {
|
||
const reviewSet = activeChangeSetId.value.trim();
|
||
if (!reviewSet) return path;
|
||
const params = new URLSearchParams();
|
||
params.set("reviewSet", reviewSet);
|
||
params.set("reviewStep", String(Math.max(1, activeChangeStep.value + 1)));
|
||
return `${path}?${params.toString()}`;
|
||
}
|
||
|
||
function syncPathFromUi(push = false) {
|
||
if (process.server) return;
|
||
const nextPath = currentUiPath();
|
||
const currentPath = `${window.location.pathname}${window.location.search}`;
|
||
if (nextPath === currentPath) return;
|
||
if (push) {
|
||
window.history.pushState({}, "", nextPath);
|
||
} else {
|
||
window.history.replaceState({}, "", nextPath);
|
||
}
|
||
}
|
||
|
||
function ensureChangeSelectionSeeded(message: PilotMessage | null | undefined) {
|
||
if (!message?.changeItems?.length) {
|
||
changeSelectionByItemId.value = {};
|
||
return;
|
||
}
|
||
const next: Record<string, boolean> = {};
|
||
for (const item of message.changeItems) {
|
||
const prev = changeSelectionByItemId.value[item.id];
|
||
next[item.id] = typeof prev === "boolean" ? prev : true;
|
||
}
|
||
changeSelectionByItemId.value = next;
|
||
}
|
||
|
||
function setPeopleLeftMode(mode: PeopleLeftMode, push = false) {
|
||
selectedTab.value = "communications";
|
||
peopleLeftMode.value = mode;
|
||
focusedCalendarEventId.value = "";
|
||
syncPathFromUi(push);
|
||
}
|
||
|
||
function openChangeReview(changeSetId: string, step = 0, push = true) {
|
||
const targetId = String(changeSetId ?? "").trim();
|
||
if (!targetId) return;
|
||
activeChangeSetId.value = targetId;
|
||
const items = activeChangeMessage.value?.changeItems ?? [];
|
||
activeChangeStep.value = items.length ? Math.max(0, Math.min(step, items.length - 1)) : 0;
|
||
ensureChangeSelectionSeeded(activeChangeMessage.value);
|
||
applyReviewStepToUi(push);
|
||
}
|
||
|
||
function applyPathToUi(pathname: string, search = "") {
|
||
const path = String(pathname || "/").trim() || "/";
|
||
const params = new URLSearchParams(String(search || ""));
|
||
const reviewSet = (params.get("reviewSet") ?? "").trim();
|
||
const reviewStep = Number(params.get("reviewStep") ?? "1");
|
||
|
||
if (reviewSet) {
|
||
activeChangeSetId.value = reviewSet;
|
||
activeChangeStep.value = Number.isFinite(reviewStep) && reviewStep > 0 ? reviewStep - 1 : 0;
|
||
} else {
|
||
activeChangeSetId.value = "";
|
||
activeChangeStep.value = 0;
|
||
changeSelectionByItemId.value = {};
|
||
}
|
||
|
||
const calendarEventMatch = path.match(/^\/calendar\/event\/([^/]+)\/?$/i);
|
||
if (calendarEventMatch) {
|
||
const rawEventId = decodeURIComponent(calendarEventMatch[1] ?? "").trim();
|
||
selectedTab.value = "communications";
|
||
peopleLeftMode.value = "calendar";
|
||
const event = sortedEvents.value.find((x) => x.id === rawEventId);
|
||
if (event) {
|
||
pickDate(event.start.slice(0, 10));
|
||
}
|
||
focusedCalendarEventId.value = rawEventId;
|
||
return;
|
||
}
|
||
|
||
const calendarMatch = path.match(/^\/calendar\/([^/]+)\/([^/]+)\/?$/i);
|
||
if (calendarMatch) {
|
||
const rawView = decodeURIComponent(calendarMatch[1] ?? "").trim();
|
||
const rawCursor = decodeURIComponent(calendarMatch[2] ?? "").trim();
|
||
const view = (["day", "week", "month", "year", "agenda"] as CalendarView[]).includes(rawView as CalendarView)
|
||
? (rawView as CalendarView)
|
||
: "month";
|
||
const cursorByMonth = parseCalendarCursorToken(rawCursor);
|
||
const cursorByDate = parseCalendarDateToken(rawCursor);
|
||
const cursorByYear = parseCalendarYearToken(rawCursor);
|
||
selectedTab.value = "communications";
|
||
peopleLeftMode.value = "calendar";
|
||
focusedCalendarEventId.value = "";
|
||
calendarView.value = view;
|
||
if (view === "day" || view === "week") {
|
||
const parsed = cursorByDate;
|
||
if (parsed) {
|
||
selectedDateKey.value = dayKey(parsed);
|
||
calendarCursor.value = new Date(parsed.getFullYear(), parsed.getMonth(), 1);
|
||
}
|
||
} else if (view === "year") {
|
||
if (cursorByYear) {
|
||
calendarCursor.value = new Date(cursorByYear, 0, 1);
|
||
selectedDateKey.value = dayKey(new Date(cursorByYear, 0, 1));
|
||
}
|
||
} else if (cursorByMonth) {
|
||
calendarCursor.value = cursorByMonth;
|
||
selectedDateKey.value = dayKey(cursorByMonth);
|
||
}
|
||
return;
|
||
}
|
||
|
||
const contactMatch = path.match(/^\/contact\/([^/]+)\/?$/i);
|
||
if (contactMatch) {
|
||
const rawContactId = decodeURIComponent(contactMatch[1] ?? "").trim();
|
||
selectedTab.value = "communications";
|
||
peopleLeftMode.value = "contacts";
|
||
peopleListMode.value = "contacts";
|
||
if (rawContactId) {
|
||
selectedContactId.value = rawContactId;
|
||
const linkedThread = commThreads.value.find((thread) => thread.id === rawContactId);
|
||
if (linkedThread) selectedCommThreadId.value = linkedThread.id;
|
||
}
|
||
focusedCalendarEventId.value = "";
|
||
return;
|
||
}
|
||
|
||
const dealMatch = path.match(/^\/deal\/([^/]+)\/?$/i);
|
||
if (dealMatch) {
|
||
const rawDealId = decodeURIComponent(dealMatch[1] ?? "").trim();
|
||
selectedTab.value = "communications";
|
||
peopleLeftMode.value = "contacts";
|
||
peopleListMode.value = "deals";
|
||
if (rawDealId) {
|
||
selectedDealId.value = rawDealId;
|
||
const linkedDeal = deals.value.find((deal) => deal.id === rawDealId);
|
||
const linkedContact = linkedDeal
|
||
? contacts.value.find((contact) => contact.name === linkedDeal.contact)
|
||
: null;
|
||
if (linkedContact) {
|
||
selectedContactId.value = linkedContact.id;
|
||
selectedCommThreadId.value = linkedContact.id;
|
||
}
|
||
}
|
||
focusedCalendarEventId.value = "";
|
||
return;
|
||
}
|
||
|
||
const chatMatch = path.match(/^\/chat\/([^/]+)\/?$/i);
|
||
if (chatMatch) {
|
||
const rawChatId = decodeURIComponent(chatMatch[1] ?? "").trim();
|
||
selectedTab.value = "communications";
|
||
peopleLeftMode.value = "contacts";
|
||
peopleListMode.value = "contacts";
|
||
focusedCalendarEventId.value = "";
|
||
if (rawChatId) selectedChatId.value = rawChatId;
|
||
return;
|
||
}
|
||
|
||
const changesMatch = path.match(/^\/changes\/([^/]+)(?:\/step\/(\d+))?\/?$/i);
|
||
if (changesMatch) {
|
||
const rawId = decodeURIComponent(changesMatch[1] ?? "").trim();
|
||
const rawStep = Number(changesMatch[2] ?? "1");
|
||
if (rawId) {
|
||
activeChangeSetId.value = rawId;
|
||
activeChangeStep.value = Number.isFinite(rawStep) && rawStep > 0 ? rawStep - 1 : 0;
|
||
}
|
||
selectedTab.value = "communications";
|
||
peopleLeftMode.value = "contacts";
|
||
peopleListMode.value = "contacts";
|
||
focusedCalendarEventId.value = "";
|
||
return;
|
||
}
|
||
|
||
selectedTab.value = "communications";
|
||
peopleLeftMode.value = "contacts";
|
||
peopleListMode.value = "contacts";
|
||
focusedCalendarEventId.value = "";
|
||
}
|
||
|
||
async function confirmLatestChangeSet() {
|
||
if (changeActionBusy.value || !latestChangeMessage.value?.changeSetId) return;
|
||
changeActionBusy.value = true;
|
||
try {
|
||
await gqlFetch<{ confirmLatestChangeSet: { ok: boolean } }>(confirmLatestChangeSetMutation);
|
||
await loadPilotMessages();
|
||
} finally {
|
||
changeActionBusy.value = false;
|
||
}
|
||
}
|
||
|
||
async function rollbackLatestChangeSet() {
|
||
if (changeActionBusy.value || !latestChangeMessage.value?.changeSetId) return;
|
||
changeActionBusy.value = true;
|
||
try {
|
||
await gqlFetch<{ rollbackLatestChangeSet: { ok: boolean } }>(rollbackLatestChangeSetMutation);
|
||
await Promise.all([loadPilotMessages(), refreshCrmData(), loadChatConversations()]);
|
||
activeChangeSetId.value = "";
|
||
activeChangeStep.value = 0;
|
||
setPeopleLeftMode("contacts");
|
||
} finally {
|
||
changeActionBusy.value = false;
|
||
}
|
||
}
|
||
|
||
async function rollbackSelectedChangeItems() {
|
||
const targetChangeSetId = activeChangeMessage.value?.changeSetId?.trim() || activeChangeSetId.value.trim();
|
||
const itemIds = selectedRollbackItemIds.value;
|
||
if (changeActionBusy.value || !targetChangeSetId || itemIds.length === 0) return;
|
||
|
||
changeActionBusy.value = true;
|
||
try {
|
||
await gqlFetch<{ rollbackChangeSetItems: { ok: boolean } }>(rollbackChangeSetItemsMutation, {
|
||
changeSetId: targetChangeSetId,
|
||
itemIds,
|
||
});
|
||
await Promise.all([loadPilotMessages(), refreshCrmData(), loadChatConversations()]);
|
||
ensureChangeSelectionSeeded(activeChangeMessage.value);
|
||
} finally {
|
||
changeActionBusy.value = false;
|
||
}
|
||
}
|
||
|
||
function goToChangeStep(step: number) {
|
||
const items = activeChangeItems.value;
|
||
if (!items.length) return;
|
||
activeChangeStep.value = Math.max(0, Math.min(step, items.length - 1));
|
||
applyReviewStepToUi(true);
|
||
}
|
||
|
||
function goToPreviousChangeStep() {
|
||
goToChangeStep(activeChangeIndex.value - 1);
|
||
}
|
||
|
||
function goToNextChangeStep() {
|
||
goToChangeStep(activeChangeIndex.value + 1);
|
||
}
|
||
|
||
function openChangeItemTarget(item: PilotChangeItem) {
|
||
if (!item) return;
|
||
const idx = activeChangeItems.value.findIndex((candidate) => candidate.id === item.id);
|
||
if (idx >= 0) {
|
||
goToChangeStep(idx);
|
||
}
|
||
}
|
||
|
||
function isReviewHighlightedEvent(eventId: string) {
|
||
return Boolean(reviewActive.value && activeReviewCalendarEventId.value && activeReviewCalendarEventId.value === eventId);
|
||
}
|
||
|
||
function isReviewHighlightedContact(contactId: string) {
|
||
return Boolean(reviewActive.value && activeReviewContactId.value && activeReviewContactId.value === contactId);
|
||
}
|
||
|
||
function isReviewHighlightedDeal(dealId: string) {
|
||
return Boolean(reviewActive.value && activeReviewDealId.value && activeReviewDealId.value === dealId);
|
||
}
|
||
|
||
function isReviewHighlightedMessage(messageId: string) {
|
||
return Boolean(reviewActive.value && activeReviewMessageId.value && activeReviewMessageId.value === messageId);
|
||
}
|
||
|
||
function applyReviewStepToUi(push = false) {
|
||
const item = activeChangeItem.value;
|
||
if (!item) {
|
||
syncPathFromUi(push);
|
||
return;
|
||
}
|
||
|
||
selectedTab.value = "communications";
|
||
|
||
if (item.entity === "calendar_event" && item.entityId) {
|
||
peopleLeftMode.value = "calendar";
|
||
calendarView.value = "month";
|
||
const event = sortedEvents.value.find((x) => x.id === item.entityId);
|
||
if (event) {
|
||
pickDate(event.start.slice(0, 10));
|
||
}
|
||
focusedCalendarEventId.value = item.entityId;
|
||
syncPathFromUi(push);
|
||
return;
|
||
}
|
||
|
||
if (item.entity === "contact_note" && item.entityId) {
|
||
peopleLeftMode.value = "contacts";
|
||
peopleListMode.value = "contacts";
|
||
selectedContactId.value = item.entityId;
|
||
const thread = commThreads.value.find((entry) => entry.id === item.entityId);
|
||
if (thread) selectedCommThreadId.value = thread.id;
|
||
focusedCalendarEventId.value = "";
|
||
syncPathFromUi(push);
|
||
return;
|
||
}
|
||
|
||
if (item.entity === "deal" && item.entityId) {
|
||
peopleLeftMode.value = "contacts";
|
||
peopleListMode.value = "deals";
|
||
selectedDealId.value = item.entityId;
|
||
const deal = deals.value.find((entry) => entry.id === item.entityId);
|
||
if (deal) {
|
||
const contact = contacts.value.find((entry) => entry.name === deal.contact);
|
||
if (contact) {
|
||
selectedContactId.value = contact.id;
|
||
selectedCommThreadId.value = contact.id;
|
||
}
|
||
}
|
||
focusedCalendarEventId.value = "";
|
||
syncPathFromUi(push);
|
||
return;
|
||
}
|
||
|
||
if (item.entity === "message" && item.entityId) {
|
||
peopleLeftMode.value = "contacts";
|
||
peopleListMode.value = "contacts";
|
||
const message = commItems.value.find((entry) => entry.id === item.entityId);
|
||
if (message?.contact) {
|
||
openCommunicationThread(message.contact);
|
||
}
|
||
focusedCalendarEventId.value = "";
|
||
syncPathFromUi(push);
|
||
return;
|
||
}
|
||
|
||
peopleLeftMode.value = "contacts";
|
||
focusedCalendarEventId.value = "";
|
||
syncPathFromUi(push);
|
||
}
|
||
|
||
function finishReview(push = true) {
|
||
activeChangeSetId.value = "";
|
||
activeChangeStep.value = 0;
|
||
changeSelectionByItemId.value = {};
|
||
syncPathFromUi(push);
|
||
}
|
||
|
||
watch(
|
||
() => activeChangeMessage.value?.changeSetId,
|
||
() => {
|
||
if (!activeChangeSetId.value.trim()) return;
|
||
ensureChangeSelectionSeeded(activeChangeMessage.value);
|
||
const maxIndex = Math.max(0, (activeChangeItems.value.length || 1) - 1);
|
||
if (activeChangeStep.value > maxIndex) activeChangeStep.value = maxIndex;
|
||
applyReviewStepToUi(false);
|
||
},
|
||
);
|
||
|
||
if (process.server) {
|
||
await bootstrapSession();
|
||
}
|
||
|
||
onMounted(() => {
|
||
pilotHeaderText.value = pilotHeaderPhrases[Math.floor(Math.random() * pilotHeaderPhrases.length)] ?? "Every step moves you forward";
|
||
pilotMicSupported.value =
|
||
typeof navigator !== "undefined" &&
|
||
typeof MediaRecorder !== "undefined" &&
|
||
Boolean(navigator.mediaDevices?.getUserMedia);
|
||
lifecycleClock = setInterval(() => {
|
||
lifecycleNowMs.value = Date.now();
|
||
}, 15000);
|
||
|
||
uiPathSyncLocked.value = true;
|
||
try {
|
||
applyPathToUi(window.location.pathname, window.location.search);
|
||
} finally {
|
||
uiPathSyncLocked.value = false;
|
||
}
|
||
syncPathFromUi(false);
|
||
popstateHandler = () => {
|
||
uiPathSyncLocked.value = true;
|
||
try {
|
||
applyPathToUi(window.location.pathname, window.location.search);
|
||
} finally {
|
||
uiPathSyncLocked.value = false;
|
||
}
|
||
};
|
||
window.addEventListener("popstate", popstateHandler);
|
||
|
||
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 (popstateHandler) {
|
||
window.removeEventListener("popstate", popstateHandler);
|
||
popstateHandler = null;
|
||
}
|
||
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 focusedCalendarEvent = computed(() => {
|
||
const id = focusedCalendarEventId.value.trim();
|
||
if (!id) return null;
|
||
return sortedEvents.value.find((event) => event.id === id) ?? null;
|
||
});
|
||
|
||
const eventsByDate = computed(() => {
|
||
const map = new Map<string, CalendarEvent[]>();
|
||
|
||
for (const event of sortedEvents.value) {
|
||
const key = event.start.slice(0, 10);
|
||
if (!map.has(key)) {
|
||
map.set(key, []);
|
||
}
|
||
map.get(key)?.push(event);
|
||
}
|
||
|
||
return map;
|
||
});
|
||
|
||
function getEventsByDate(key: string) {
|
||
return eventsByDate.value.get(key) ?? [];
|
||
}
|
||
|
||
const monthLabel = computed(() =>
|
||
new Intl.DateTimeFormat("en-US", { month: "long", year: "numeric" }).format(calendarCursor.value),
|
||
);
|
||
|
||
const calendarViewOptions: { value: CalendarView; label: string }[] = [
|
||
{ value: "day", label: "Day" },
|
||
{ value: "week", label: "Week" },
|
||
{ value: "month", label: "Month" },
|
||
{ value: "year", label: "Year" },
|
||
{ value: "agenda", label: "Agenda" },
|
||
];
|
||
|
||
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),
|
||
};
|
||
});
|
||
});
|
||
|
||
function monthCellHasFocusedEvent(events: CalendarEvent[]) {
|
||
const id = focusedCalendarEventId.value.trim();
|
||
if (!id) return false;
|
||
return events.some((event) => event.id === id);
|
||
}
|
||
|
||
function monthCellEvents(events: CalendarEvent[]) {
|
||
const id = focusedCalendarEventId.value.trim();
|
||
if (!id) return events.slice(0, 2);
|
||
const focused = events.find((event) => event.id === id);
|
||
if (!focused) return events.slice(0, 2);
|
||
const rest = events.filter((event) => event.id !== id).slice(0, 1);
|
||
return [focused, ...rest];
|
||
}
|
||
|
||
const weekDays = computed(() => {
|
||
const base = new Date(`${selectedDateKey.value}T00:00:00`);
|
||
const mondayOffset = (base.getDay() + 6) % 7;
|
||
const monday = new Date(base);
|
||
monday.setDate(base.getDate() - mondayOffset);
|
||
|
||
return Array.from({ length: 7 }, (_, index) => {
|
||
const d = new Date(monday);
|
||
d.setDate(monday.getDate() + index);
|
||
const key = dayKey(d);
|
||
|
||
return {
|
||
key,
|
||
label: new Intl.DateTimeFormat("en-US", { weekday: "short" }).format(d),
|
||
day: d.getDate(),
|
||
events: getEventsByDate(key),
|
||
};
|
||
});
|
||
});
|
||
|
||
const calendarPeriodLabel = computed(() => {
|
||
if (calendarView.value === "month") {
|
||
return monthLabel.value;
|
||
}
|
||
|
||
if (calendarView.value === "year") {
|
||
return String(calendarCursor.value.getFullYear());
|
||
}
|
||
|
||
if (calendarView.value === "week") {
|
||
const first = weekDays.value[0];
|
||
const last = weekDays.value[weekDays.value.length - 1];
|
||
if (!first || !last) return "";
|
||
return `${formatDay(`${first.key}T00:00:00`)} - ${formatDay(`${last.key}T00:00:00`)}`;
|
||
}
|
||
|
||
if (calendarView.value === "day") {
|
||
return formatDay(`${selectedDateKey.value}T00:00:00`);
|
||
}
|
||
|
||
return `Agenda · ${monthLabel.value}`;
|
||
});
|
||
|
||
const yearMonths = computed(() => {
|
||
const year = calendarCursor.value.getFullYear();
|
||
return Array.from({ length: 12 }, (_, monthIndex) => {
|
||
const monthStart = new Date(year, monthIndex, 1);
|
||
const monthEnd = new Date(year, monthIndex + 1, 1);
|
||
const items = sortedEvents.value.filter((event) => {
|
||
const d = new Date(event.start);
|
||
return d >= monthStart && d < monthEnd;
|
||
});
|
||
|
||
return {
|
||
monthIndex,
|
||
label: new Intl.DateTimeFormat("en-US", { month: "long" }).format(monthStart),
|
||
count: items.length,
|
||
first: items[0],
|
||
};
|
||
});
|
||
});
|
||
|
||
const selectedDayEvents = computed(() => getEventsByDate(selectedDateKey.value));
|
||
|
||
function shiftCalendar(step: number) {
|
||
focusedCalendarEventId.value = "";
|
||
if (calendarView.value === "year") {
|
||
const next = new Date(calendarCursor.value);
|
||
next.setFullYear(next.getFullYear() + step);
|
||
calendarCursor.value = new Date(next.getFullYear(), next.getMonth(), 1);
|
||
const selected = new Date(`${selectedDateKey.value}T00:00:00`);
|
||
selected.setFullYear(selected.getFullYear() + step);
|
||
selectedDateKey.value = dayKey(selected);
|
||
return;
|
||
}
|
||
|
||
if (calendarView.value === "month" || calendarView.value === "agenda") {
|
||
const next = new Date(calendarCursor.value);
|
||
next.setMonth(next.getMonth() + step);
|
||
calendarCursor.value = new Date(next.getFullYear(), next.getMonth(), 1);
|
||
return;
|
||
}
|
||
|
||
const current = new Date(`${selectedDateKey.value}T00:00:00`);
|
||
const days = calendarView.value === "week" ? 7 : 1;
|
||
current.setDate(current.getDate() + days * step);
|
||
|
||
selectedDateKey.value = dayKey(current);
|
||
calendarCursor.value = new Date(current.getFullYear(), current.getMonth(), 1);
|
||
}
|
||
|
||
function setToday() {
|
||
focusedCalendarEventId.value = "";
|
||
const now = new Date();
|
||
selectedDateKey.value = dayKey(now);
|
||
calendarCursor.value = new Date(now.getFullYear(), now.getMonth(), 1);
|
||
}
|
||
|
||
function pickDate(key: string) {
|
||
focusedCalendarEventId.value = "";
|
||
selectedDateKey.value = key;
|
||
const d = new Date(`${key}T00:00:00`);
|
||
calendarCursor.value = new Date(d.getFullYear(), d.getMonth(), 1);
|
||
}
|
||
|
||
function openYearMonth(monthIndex: number) {
|
||
focusedCalendarEventId.value = "";
|
||
const year = calendarCursor.value.getFullYear();
|
||
calendarCursor.value = new Date(year, monthIndex, 1);
|
||
selectedDateKey.value = dayKey(new Date(year, monthIndex, 1));
|
||
calendarView.value = "month";
|
||
}
|
||
|
||
const contactSearch = ref("");
|
||
const selectedCountry = ref("All");
|
||
const selectedLocation = ref("All");
|
||
const selectedCompany = ref("All");
|
||
const selectedChannel = ref("All");
|
||
const sortMode = ref<SortMode>("name");
|
||
|
||
const countries = computed(() => ["All", ...new Set(contacts.value.map((c) => c.country))].sort());
|
||
|
||
const locationScopeContacts = computed(() =>
|
||
selectedCountry.value === "All"
|
||
? contacts.value
|
||
: contacts.value.filter((contact) => contact.country === selectedCountry.value),
|
||
);
|
||
|
||
const locations = computed(() => ["All", ...new Set(locationScopeContacts.value.map((c) => c.location))].sort());
|
||
|
||
const companyScopeContacts = computed(() =>
|
||
selectedLocation.value === "All"
|
||
? locationScopeContacts.value
|
||
: locationScopeContacts.value.filter((contact) => contact.location === selectedLocation.value),
|
||
);
|
||
|
||
const companies = computed(() => ["All", ...new Set(companyScopeContacts.value.map((c) => c.company))].sort());
|
||
const channels = computed(() => ["All", ...new Set(contacts.value.flatMap((c) => c.channels))].sort());
|
||
|
||
watch(selectedCountry, () => {
|
||
selectedLocation.value = "All";
|
||
selectedCompany.value = "All";
|
||
});
|
||
|
||
watch(selectedLocation, () => {
|
||
selectedCompany.value = "All";
|
||
});
|
||
|
||
function resetContactFilters() {
|
||
contactSearch.value = "";
|
||
selectedCountry.value = "All";
|
||
selectedLocation.value = "All";
|
||
selectedCompany.value = "All";
|
||
selectedChannel.value = "All";
|
||
sortMode.value = "name";
|
||
}
|
||
|
||
const filteredContacts = computed(() => {
|
||
const query = contactSearch.value.trim().toLowerCase();
|
||
const data = contacts.value.filter((contact) => {
|
||
if (selectedCountry.value !== "All" && contact.country !== selectedCountry.value) return false;
|
||
if (selectedLocation.value !== "All" && contact.location !== selectedLocation.value) return false;
|
||
if (selectedCompany.value !== "All" && contact.company !== selectedCompany.value) return false;
|
||
if (selectedChannel.value !== "All" && !contact.channels.includes(selectedChannel.value)) return false;
|
||
if (query) {
|
||
const haystack = [contact.name, contact.company, contact.country, contact.location, contact.description, contact.channels.join(" ")]
|
||
.join(" ")
|
||
.toLowerCase();
|
||
if (!haystack.includes(query)) return false;
|
||
}
|
||
return true;
|
||
});
|
||
|
||
return data.sort((a, b) => {
|
||
if (sortMode.value === "lastContact") {
|
||
return b.lastContactAt.localeCompare(a.lastContactAt);
|
||
}
|
||
return a.name.localeCompare(b.name);
|
||
});
|
||
});
|
||
|
||
const groupedContacts = computed(() => {
|
||
if (sortMode.value === "lastContact") {
|
||
return [["Recent", filteredContacts.value]] as [string, Contact[]][];
|
||
}
|
||
|
||
const map = new Map<string, Contact[]>();
|
||
|
||
for (const contact of filteredContacts.value) {
|
||
const key = (contact.name[0] ?? "#").toUpperCase();
|
||
if (!map.has(key)) {
|
||
map.set(key, []);
|
||
}
|
||
map.get(key)?.push(contact);
|
||
}
|
||
|
||
return [...map.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
||
});
|
||
|
||
const selectedContactId = ref(contacts.value[0]?.id ?? "");
|
||
|
||
watchEffect(() => {
|
||
if (!filteredContacts.value.length) {
|
||
selectedContactId.value = "";
|
||
return;
|
||
}
|
||
if (!filteredContacts.value.some((item) => item.id === selectedContactId.value)) {
|
||
const first = filteredContacts.value[0];
|
||
if (first) selectedContactId.value = first.id;
|
||
}
|
||
});
|
||
|
||
const selectedContact = computed(() => contacts.value.find((item) => item.id === selectedContactId.value));
|
||
const selectedContactEvents = computed(() => {
|
||
if (!selectedContact.value) return [];
|
||
const nowIso = new Date().toISOString();
|
||
const events = sortedEvents.value.filter((event) => event.contact === selectedContact.value?.name);
|
||
const upcoming = events.filter((event) => event.end >= nowIso);
|
||
const past = events.filter((event) => event.end < nowIso).reverse();
|
||
|
||
return [...upcoming, ...past].slice(0, 8);
|
||
});
|
||
|
||
const selectedContactRecentMessages = computed(() => {
|
||
if (!selectedContact.value) return [];
|
||
return commItems.value
|
||
.filter((item) => item.contact === selectedContact.value?.name && item.kind === "message")
|
||
.sort((a, b) => b.at.localeCompare(a.at))
|
||
.slice(0, 8);
|
||
});
|
||
|
||
const documentSearch = ref("");
|
||
const 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 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),
|
||
);
|
||
|
||
watch(
|
||
() => [
|
||
selectedTab.value,
|
||
peopleLeftMode.value,
|
||
peopleListMode.value,
|
||
selectedChatId.value,
|
||
calendarView.value,
|
||
calendarRouteToken(calendarView.value),
|
||
focusedCalendarEventId.value,
|
||
selectedContactId.value,
|
||
selectedDealId.value,
|
||
activeChangeSetId.value,
|
||
activeChangeStep.value,
|
||
],
|
||
() => {
|
||
if (process.server || uiPathSyncLocked.value) return;
|
||
syncPathFromUi(false);
|
||
},
|
||
);
|
||
|
||
const commSendChannel = ref<CommItem["channel"] | "">("");
|
||
const commPinnedOnly = ref(false);
|
||
const commDraft = ref("");
|
||
const commSending = ref(false);
|
||
const commRecording = ref(false);
|
||
const commComposerMode = ref<"message" | "planned" | "logged">("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(() => {
|
||
if (selectedContact.value) return selectedContact.value;
|
||
|
||
const threadContactId = (selectedCommThread.value?.id ?? "").trim();
|
||
if (threadContactId) {
|
||
const byId = contacts.value.find((contact) => contact.id === threadContactId);
|
||
if (byId) return byId;
|
||
}
|
||
|
||
const threadContactName = (selectedCommThread.value?.contact ?? "").trim();
|
||
if (threadContactName) {
|
||
const byName = contacts.value.find((contact) => contact.name === threadContactName);
|
||
if (byName) return byName;
|
||
}
|
||
return contacts.value[0] ?? null;
|
||
});
|
||
|
||
const selectedWorkspaceDeal = computed(() => {
|
||
const explicit = deals.value.find((deal) => deal.id === selectedDealId.value);
|
||
if (explicit) return explicit;
|
||
|
||
if (selectedWorkspaceContact.value) {
|
||
const linked = deals.value.find((deal) => deal.contact === selectedWorkspaceContact.value?.name);
|
||
if (linked) return linked;
|
||
}
|
||
return null;
|
||
});
|
||
|
||
function formatDealHeadline(deal: Deal) {
|
||
const title = deal.title.trim();
|
||
const amountRaw = deal.amount.trim();
|
||
if (!amountRaw) return title;
|
||
|
||
const normalized = amountRaw.replace(/\s+/g, "").replace(",", ".");
|
||
if (/^\d+(\.\d+)?$/.test(normalized)) {
|
||
return `${title} за ${new Intl.NumberFormat("ru-RU").format(Number(normalized))} $`;
|
||
}
|
||
|
||
return `${title} за ${amountRaw}`;
|
||
}
|
||
|
||
function getDealCurrentStep(deal: Deal) {
|
||
if (!deal.steps?.length) return null;
|
||
if (deal.currentStepId) {
|
||
const explicit = deal.steps.find((step) => step.id === deal.currentStepId);
|
||
if (explicit) return explicit;
|
||
}
|
||
const inProgress = deal.steps.find((step) => step.status === "in_progress");
|
||
if (inProgress) return inProgress;
|
||
const nextTodo = deal.steps.find((step) => step.status !== "done");
|
||
return nextTodo ?? deal.steps[deal.steps.length - 1];
|
||
}
|
||
|
||
function getDealCurrentStepLabel(deal: Deal) {
|
||
return getDealCurrentStep(deal)?.title?.trim() || deal.nextStep.trim() || deal.stage.trim() || "Без шага";
|
||
}
|
||
|
||
function parseDateFromText(input: string) {
|
||
const text = input.trim();
|
||
if (!text) return null;
|
||
|
||
const isoMatch = text.match(/\b(\d{4})-(\d{2})-(\d{2})\b/);
|
||
if (isoMatch) {
|
||
const [, y, m, d] = isoMatch;
|
||
const parsed = new Date(Number(y), Number(m) - 1, Number(d));
|
||
if (!Number.isNaN(parsed.getTime())) return parsed;
|
||
}
|
||
|
||
const ruMatch = text.match(/\b(\d{1,2})[./](\d{1,2})[./](\d{4})\b/);
|
||
if (ruMatch) {
|
||
const [, d, m, y] = ruMatch;
|
||
const parsed = new Date(Number(y), Number(m) - 1, Number(d));
|
||
if (!Number.isNaN(parsed.getTime())) return parsed;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function pluralizeRuDays(days: number) {
|
||
const mod10 = days % 10;
|
||
const mod100 = days % 100;
|
||
if (mod10 === 1 && mod100 !== 11) return "день";
|
||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return "дня";
|
||
return "дней";
|
||
}
|
||
|
||
function formatDealDeadline(dueDate: Date) {
|
||
const today = new Date();
|
||
const startOfToday = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
||
const startOfDue = new Date(dueDate.getFullYear(), dueDate.getMonth(), dueDate.getDate());
|
||
const dayDiff = Math.round((startOfDue.getTime() - startOfToday.getTime()) / 86_400_000);
|
||
|
||
if (dayDiff < 0) {
|
||
const overdue = Math.abs(dayDiff);
|
||
return `просрочено на ${overdue} ${pluralizeRuDays(overdue)}`;
|
||
}
|
||
if (dayDiff === 0) return "сегодня";
|
||
if (dayDiff === 1) return "завтра";
|
||
return `через ${dayDiff} ${pluralizeRuDays(dayDiff)}`;
|
||
}
|
||
|
||
function isDealStepDone(step: DealStep) {
|
||
return step.status === "done";
|
||
}
|
||
|
||
function formatDealStepMeta(step: DealStep) {
|
||
if (step.status === "done") return "выполнено";
|
||
if (step.status === "blocked") return "заблокировано";
|
||
if (!step.dueAt) {
|
||
if (step.status === "in_progress") return "в работе";
|
||
return "без дедлайна";
|
||
}
|
||
const parsed = new Date(step.dueAt);
|
||
if (Number.isNaN(parsed.getTime())) return "без дедлайна";
|
||
return formatDealDeadline(parsed);
|
||
}
|
||
|
||
function formatYearMonthFirst(item: { first?: CalendarEvent }) {
|
||
if (!item.first) return "";
|
||
return `${formatDay(item.first.start)} · ${item.first.title}`;
|
||
}
|
||
|
||
const selectedWorkspaceDealDueDate = computed(() => {
|
||
const deal = selectedWorkspaceDeal.value;
|
||
if (!deal) return null;
|
||
|
||
const currentStep = getDealCurrentStep(deal);
|
||
if (currentStep?.dueAt) {
|
||
const parsed = new Date(currentStep.dueAt);
|
||
if (!Number.isNaN(parsed.getTime())) return parsed;
|
||
}
|
||
|
||
const fromNextStep = parseDateFromText(currentStep?.title || deal.nextStep);
|
||
if (fromNextStep) return fromNextStep;
|
||
|
||
const now = Date.now();
|
||
const contactEvents = sortedEvents.value
|
||
.filter((event) => event.contact === deal.contact)
|
||
.map((event) => new Date(event.start))
|
||
.filter((date) => !Number.isNaN(date.getTime()))
|
||
.sort((a, b) => a.getTime() - b.getTime());
|
||
|
||
const nextUpcoming = contactEvents.find((date) => date.getTime() >= now);
|
||
if (nextUpcoming) return nextUpcoming;
|
||
|
||
return contactEvents.length ? contactEvents[contactEvents.length - 1] : null;
|
||
});
|
||
|
||
const selectedWorkspaceDealSubtitle = computed(() => {
|
||
const deal = selectedWorkspaceDeal.value;
|
||
if (!deal) return "";
|
||
const stepLabel = getDealCurrentStepLabel(deal);
|
||
const dueDate = selectedWorkspaceDealDueDate.value;
|
||
if (!dueDate) return `${stepLabel} · без дедлайна`;
|
||
return `${stepLabel} · ${formatDealDeadline(dueDate)}`;
|
||
});
|
||
|
||
const selectedWorkspaceDealSteps = computed(() => {
|
||
const deal = selectedWorkspaceDeal.value;
|
||
if (!deal?.steps?.length) return [];
|
||
return [...deal.steps].sort((a, b) => a.order - b.order);
|
||
});
|
||
|
||
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) {
|
||
setPeopleLeftMode("contacts", true);
|
||
peopleListMode.value = "contacts";
|
||
selectedDealStepsExpanded.value = false;
|
||
const linkedContact = contacts.value.find((item) => item.name === contact);
|
||
if (linkedContact) {
|
||
selectedContactId.value = linkedContact.id;
|
||
}
|
||
const linkedDeal = deals.value.find((deal) => deal.contact === contact);
|
||
if (linkedDeal) {
|
||
selectedDealId.value = linkedDeal.id;
|
||
}
|
||
const thread = commThreads.value.find((item) => item.contact === contact);
|
||
if (thread) {
|
||
selectedCommThreadId.value = thread.id;
|
||
}
|
||
}
|
||
|
||
function openDealThread(deal: Deal) {
|
||
selectedDealId.value = deal.id;
|
||
peopleListMode.value = "deals";
|
||
selectedDealStepsExpanded.value = false;
|
||
openCommunicationThread(deal.contact);
|
||
peopleListMode.value = "deals";
|
||
}
|
||
|
||
function openThreadFromCalendarItem(event: CalendarEvent) {
|
||
if (!event.contact?.trim()) {
|
||
setPeopleLeftMode("calendar", true);
|
||
pickDate(event.start.slice(0, 10));
|
||
focusedCalendarEventId.value = event.id;
|
||
syncPathFromUi(true);
|
||
return;
|
||
}
|
||
openCommunicationThread(event.contact);
|
||
}
|
||
|
||
function openEventFromContact(event: CalendarEvent) {
|
||
setPeopleLeftMode("calendar", true);
|
||
pickDate(event.start.slice(0, 10));
|
||
focusedCalendarEventId.value = event.id;
|
||
syncPathFromUi(true);
|
||
}
|
||
|
||
function openMessageFromContact(channel: CommItem["channel"]) {
|
||
if (!selectedContact.value) return;
|
||
openCommunicationThread(selectedContact.value.name);
|
||
commSendChannel.value = channel;
|
||
}
|
||
|
||
function setDefaultCommEventForm(mode: "planned" | "logged") {
|
||
const start = mode === "planned"
|
||
? roundToNextQuarter(new Date(Date.now() + 15 * 60 * 1000))
|
||
: roundToPrevQuarter(new Date(Date.now() - 30 * 60 * 1000));
|
||
commEventForm.value = {
|
||
startDate: toInputDate(start),
|
||
startTime: toInputTime(start),
|
||
durationMinutes: 30,
|
||
};
|
||
}
|
||
|
||
function 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);
|
||
setPeopleLeftMode("calendar", true);
|
||
return `Event created: Follow-up · ${formatDay(start.toISOString())} ${formatTime(start.toISOString())} · ${card.contact}`;
|
||
}
|
||
|
||
if (key === "open_comm") {
|
||
openCommunicationThread(card.contact);
|
||
return `Opened ${card.contact} communication thread.`;
|
||
}
|
||
|
||
if (key === "call") {
|
||
await gqlFetch<{ createCommunication: { ok: boolean; id: string } }>(createCommunicationMutation, {
|
||
input: {
|
||
contact: card.contact,
|
||
channel: "Phone",
|
||
kind: "call",
|
||
direction: "out",
|
||
text: "Call started from feed",
|
||
durationSec: 0,
|
||
},
|
||
});
|
||
await refreshCrmData();
|
||
openCommunicationThread(card.contact);
|
||
return `Call event created and ${card.contact} chat opened.`;
|
||
}
|
||
|
||
if (key === "draft_message") {
|
||
await gqlFetch<{ createCommunication: { ok: boolean; id: string } }>(createCommunicationMutation, {
|
||
input: {
|
||
contact: card.contact,
|
||
channel: "Email",
|
||
kind: "message",
|
||
direction: "out",
|
||
text: "Draft: onboarding plan + two slots for tomorrow.",
|
||
},
|
||
});
|
||
await refreshCrmData();
|
||
openCommunicationThread(card.contact);
|
||
return `Draft message added to ${card.contact} communications.`;
|
||
}
|
||
|
||
if (key === "run_summary") {
|
||
return "Call summary prepared: 5 next steps sent to Pilot.";
|
||
}
|
||
|
||
if (key === "prepare_question") {
|
||
await gqlFetch<{ createCommunication: { ok: boolean; id: string } }>(createCommunicationMutation, {
|
||
input: {
|
||
contact: card.contact,
|
||
channel: "Telegram",
|
||
kind: "message",
|
||
direction: "out",
|
||
text: "Draft: can you confirm your decision date for this cycle?",
|
||
},
|
||
});
|
||
await refreshCrmData();
|
||
openCommunicationThread(card.contact);
|
||
return `Question about decision date added to ${card.contact} chat.`;
|
||
}
|
||
|
||
return "Action completed.";
|
||
}
|
||
|
||
async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected") {
|
||
card.decision = decision;
|
||
|
||
if (decision === "rejected") {
|
||
const note = "Rejected. Nothing created.";
|
||
card.decisionNote = note;
|
||
await gqlFetch<{ updateFeedDecision: { ok: boolean; id: string } }>(updateFeedDecisionMutation, {
|
||
id: card.id,
|
||
decision: "rejected",
|
||
decisionNote: note,
|
||
});
|
||
pushPilotNote(`[${card.contact}] recommendation rejected: ${card.proposal.title}`);
|
||
return;
|
||
}
|
||
|
||
const result = await executeFeedAction(card);
|
||
card.decisionNote = result;
|
||
await gqlFetch<{ updateFeedDecision: { ok: boolean; id: string } }>(updateFeedDecisionMutation, {
|
||
id: card.id,
|
||
decision: "accepted",
|
||
decisionNote: result,
|
||
});
|
||
pushPilotNote(`[${card.contact}] ${result}`);
|
||
}
|
||
|
||
</script>
|
||
|
||
<template>
|
||
<div class="h-[100dvh] overflow-hidden bg-base-200/35">
|
||
<div v-if="!authResolved" class="flex h-full items-center justify-center">
|
||
<span class="loading loading-spinner loading-md text-base-content/70" />
|
||
</div>
|
||
|
||
<div v-else-if="!authMe" class="flex h-full items-center justify-center px-3">
|
||
<div class="card w-full max-w-sm border border-base-300 bg-base-100 shadow-sm">
|
||
<div class="card-body p-5">
|
||
<h1 class="text-lg font-semibold">Login</h1>
|
||
<p class="mt-1 text-xs text-base-content/65">Sign in with phone and password.</p>
|
||
<div class="mt-4 space-y-2">
|
||
<input
|
||
v-model="loginPhone"
|
||
type="tel"
|
||
class="input input-bordered w-full"
|
||
placeholder="+1 555 000 0001"
|
||
@keyup.enter="login"
|
||
>
|
||
<input
|
||
v-model="loginPassword"
|
||
type="password"
|
||
class="input input-bordered w-full"
|
||
placeholder="Password"
|
||
@keyup.enter="login"
|
||
>
|
||
<p v-if="loginError" class="text-xs text-error">{{ loginError }}</p>
|
||
<button class="btn w-full" :disabled="loginBusy" @click="login">
|
||
{{ loginBusy ? "Logging in..." : "Login" }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<template v-else>
|
||
<div class="grid h-full min-h-0 grid-cols-1 gap-0 lg:grid-cols-[320px_minmax(0,1fr)]">
|
||
<aside class="pilot-shell min-h-0 border-r border-base-300">
|
||
<div class="flex h-full min-h-0 flex-col p-0">
|
||
<div class="pilot-header">
|
||
<div>
|
||
<h2 class="text-sm font-semibold text-white/75">{{ pilotHeaderText }}</h2>
|
||
</div>
|
||
<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 class="mt-3 flex flex-wrap items-center gap-2">
|
||
<button
|
||
v-if="message.changeSetId"
|
||
class="btn btn-xs btn-outline"
|
||
@click="openChangeReview(message.changeSetId, 0, true)"
|
||
>
|
||
Review Changes
|
||
</button>
|
||
<span class="text-[10px] uppercase tracking-wide text-amber-100/80">
|
||
status: {{ message.changeStatus || "pending" }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else class="pilot-message-text">
|
||
{{ message.text }}
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
<div v-if="pilotLiveLogs.length" class="pilot-stream-status">
|
||
<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 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="relative min-h-0 bg-base-100">
|
||
<div class="flex h-full min-h-0 flex-col">
|
||
<div class="workspace-topbar border-b border-base-300 px-3 py-2 md:px-4">
|
||
<div class="flex items-center justify-between gap-3">
|
||
<div 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="setPeopleLeftMode('contacts', true)"
|
||
>
|
||
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="setPeopleLeftMode('calendar', true)"
|
||
>
|
||
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>
|
||
|
||
<article
|
||
v-if="focusedCalendarEvent"
|
||
class="rounded-xl border border-success/50 bg-success/10 px-3 py-2"
|
||
>
|
||
<p class="text-xs font-semibold uppercase tracking-wide text-success/80">Review focus event</p>
|
||
<p class="text-sm font-medium text-base-content">{{ focusedCalendarEvent.title }}</p>
|
||
<p class="text-xs text-base-content/70">
|
||
{{ formatDay(focusedCalendarEvent.start) }} · {{ formatTime(focusedCalendarEvent.start) }} - {{ formatTime(focusedCalendarEvent.end) }}
|
||
</p>
|
||
<p class="mt-1 text-xs text-base-content/80">{{ focusedCalendarEvent.note || "No note" }}</p>
|
||
</article>
|
||
|
||
<div 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' : '',
|
||
monthCellHasFocusedEvent(cell.events) ? 'border-success/60 bg-success/10' : '',
|
||
]"
|
||
@click="pickDate(cell.key)"
|
||
>
|
||
<p class="mb-1 text-xs font-semibold">{{ cell.day }}</p>
|
||
<button
|
||
v-for="event in monthCellEvents(cell.events)"
|
||
:key="event.id"
|
||
class="block w-full truncate rounded px-1 text-left text-[10px] text-base-content/70 transition hover:underline"
|
||
:class="isReviewHighlightedEvent(event.id) ? 'bg-success/20 text-success-content ring-1 ring-success/40' : ''"
|
||
@click.stop="openThreadFromCalendarItem(event)"
|
||
>
|
||
{{ formatTime(event.start) }} {{ event.title }}
|
||
</button>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div 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 px-2 py-1 text-left text-xs"
|
||
:class="isReviewHighlightedEvent(event.id) ? 'bg-success/20 ring-1 ring-success/45' : 'bg-base-200 hover:bg-base-300/80'"
|
||
@click.stop="openThreadFromCalendarItem(event)"
|
||
>
|
||
{{ formatTime(event.start) }} - {{ event.title }} ({{ event.contact }})
|
||
</button>
|
||
<p v-if="day.events.length === 0" class="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"
|
||
:class="isReviewHighlightedEvent(event.id) ? 'border-success/60 bg-success/10' : ''"
|
||
@click="openThreadFromCalendarItem(event)"
|
||
>
|
||
<p class="font-medium">{{ event.title }}</p>
|
||
<p class="text-xs text-base-content/60">{{ event.contact }}</p>
|
||
<p class="text-xs text-base-content/60">{{ formatTime(event.start) }} - {{ formatTime(event.end) }}</p>
|
||
<p class="mt-1 text-sm text-base-content/80">{{ event.note }}</p>
|
||
</button>
|
||
<p v-if="selectedDayEvents.length === 0" class="text-sm text-base-content/60">No events on this day.</p>
|
||
</div>
|
||
|
||
<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"
|
||
:class="isReviewHighlightedEvent(event.id) ? 'border-success/60 bg-success/10' : ''"
|
||
@click="openThreadFromCalendarItem(event)"
|
||
>
|
||
<p class="font-medium">{{ event.title }}</p>
|
||
<p class="text-xs text-base-content/60">{{ event.contact }}</p>
|
||
<p class="text-xs text-base-content/60">{{ 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="setPeopleLeftMode('contacts', true)">Contacts</button>
|
||
<button class="btn btn-sm join-item btn-ghost" @click="setPeopleLeftMode('calendar', true)">Calendar</button>
|
||
</div>
|
||
</div>
|
||
<div class="rounded-xl border border-base-300 p-3">
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<input
|
||
v-model="contactSearch"
|
||
type="text"
|
||
class="input input-bordered input-md w-full flex-1"
|
||
placeholder="Search contacts..."
|
||
>
|
||
|
||
<select v-model="sortMode" class="select select-bordered select-sm w-40">
|
||
<option value="name">Sort: Name</option>
|
||
<option value="lastContact">Sort: Last contact</option>
|
||
</select>
|
||
|
||
<div class="dropdown dropdown-end">
|
||
<button tabindex="0" class="btn btn-sm btn-ghost btn-square" title="Filters">
|
||
<svg viewBox="0 0 24 24" class="h-4 w-4 fill-current">
|
||
<path d="M3 5h18v2H3zm4 6h10v2H7zm3 6h4v2h-4z" />
|
||
</svg>
|
||
</button>
|
||
<div tabindex="0" class="dropdown-content z-20 mt-2 w-72 rounded-xl border border-base-300 bg-base-100 p-3 shadow-lg">
|
||
<div class="grid gap-2">
|
||
<label class="form-control">
|
||
<span class="label-text text-xs uppercase tracking-wide text-base-content/60">Country</span>
|
||
<select v-model="selectedCountry" class="select select-bordered select-sm">
|
||
<option v-for="country in countries" :key="`country-${country}`" :value="country">{{ country }}</option>
|
||
</select>
|
||
</label>
|
||
|
||
<label class="form-control">
|
||
<span class="label-text text-xs uppercase tracking-wide text-base-content/60">Location</span>
|
||
<select v-model="selectedLocation" class="select select-bordered select-sm">
|
||
<option v-for="location in locations" :key="`location-${location}`" :value="location">{{ location }}</option>
|
||
</select>
|
||
</label>
|
||
|
||
<label class="form-control">
|
||
<span class="label-text text-xs uppercase tracking-wide text-base-content/60">Company</span>
|
||
<select v-model="selectedCompany" class="select select-bordered select-sm">
|
||
<option v-for="company in companies" :key="`company-${company}`" :value="company">{{ company }}</option>
|
||
</select>
|
||
</label>
|
||
|
||
<label class="form-control">
|
||
<span class="label-text text-xs uppercase tracking-wide text-base-content/60">Channel</span>
|
||
<select v-model="selectedChannel" class="select select-bordered select-sm">
|
||
<option v-for="channel in channels" :key="`channel-${channel}`" :value="channel">{{ channel }}</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="mt-3 flex justify-end">
|
||
<button class="btn btn-ghost btn-sm" @click="resetContactFilters">Reset filters</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid gap-3 md:grid-cols-12">
|
||
<aside class="min-h-0 rounded-xl border border-base-300 md:col-span-4">
|
||
<div class="min-h-0 space-y-3 overflow-y-auto p-2">
|
||
<article v-for="group in groupedContacts" :key="group[0]" class="space-y-2">
|
||
<div class="sticky top-0 z-10 rounded-lg bg-base-200 px-3 py-1 text-sm font-semibold">{{ group[0] }}</div>
|
||
|
||
<button
|
||
v-for="contact in group[1]"
|
||
:key="contact.id"
|
||
class="w-full rounded-xl border border-base-300 p-3 text-left transition hover:bg-base-200/60"
|
||
:class="selectedContactId === contact.id ? 'border-primary bg-primary/5' : ''"
|
||
@click="selectedContactId = contact.id"
|
||
>
|
||
<p class="font-medium">{{ contact.name }}</p>
|
||
<p class="text-xs text-base-content/60">{{ contact.company }} · {{ contact.location }}, {{ contact.country }}</p>
|
||
<p class="mt-1 text-[11px] text-base-content/55">Last contact · {{ formatStamp(contact.lastContactAt) }}</p>
|
||
</button>
|
||
</article>
|
||
</div>
|
||
</aside>
|
||
|
||
<article class="min-h-0 rounded-xl border border-base-300 md:col-span-8">
|
||
<div v-if="selectedContact" class="p-3 md:p-4">
|
||
<div class="border-b border-base-300 pb-2">
|
||
<p class="font-medium">{{ selectedContact!.name }}</p>
|
||
<p class="text-xs text-base-content/60">
|
||
{{ selectedContact!.company }} · {{ selectedContact!.location }}, {{ selectedContact!.country }}
|
||
</p>
|
||
<p class="mt-1 text-xs text-base-content/55">Last contact · {{ formatStamp(selectedContact!.lastContactAt) }}</p>
|
||
</div>
|
||
|
||
<div class="mt-3">
|
||
<ContactCollaborativeEditor
|
||
:key="`contact-editor-${selectedContact!.id}`"
|
||
v-model="selectedContact!.description"
|
||
:room="`crm-contact-${selectedContact!.id}`"
|
||
placeholder="Describe contact context and next steps..."
|
||
/>
|
||
</div>
|
||
|
||
<div class="mt-4 grid gap-3 xl:grid-cols-2">
|
||
<section class="rounded-xl border border-base-300 p-3">
|
||
<div class="mb-2 flex items-center justify-between gap-2">
|
||
<p class="text-sm font-semibold">Upcoming events</p>
|
||
</div>
|
||
<div class="space-y-2">
|
||
<button
|
||
v-for="event in selectedContactEvents"
|
||
:key="`contact-event-${event.id}`"
|
||
class="w-full rounded-lg border border-base-300 px-3 py-2 text-left transition hover:bg-base-200/50"
|
||
:class="isReviewHighlightedEvent(event.id) ? 'border-success/60 bg-success/10' : ''"
|
||
@click="openEventFromContact(event)"
|
||
>
|
||
<p class="text-sm font-medium">{{ event.title }}</p>
|
||
<p class="text-xs text-base-content/65">
|
||
{{ formatDay(event.start) }} · {{ formatTime(event.start) }} - {{ formatTime(event.end) }}
|
||
</p>
|
||
<p class="mt-1 text-xs text-base-content/80">{{ event.note }}</p>
|
||
</button>
|
||
<p v-if="selectedContactEvents.length === 0" class="text-xs text-base-content/55">
|
||
No linked events yet.
|
||
</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="rounded-xl border border-base-300 p-3">
|
||
<div class="mb-2 flex items-center justify-between gap-2">
|
||
<p class="text-sm font-semibold">Recent messages</p>
|
||
<button class="btn btn-ghost btn-xs" @click="openCommunicationThread(selectedContact!.name)">Open chat</button>
|
||
</div>
|
||
<div class="space-y-2">
|
||
<button
|
||
v-for="item in selectedContactRecentMessages"
|
||
:key="`contact-message-${item.id}`"
|
||
class="w-full rounded-lg border border-base-300 px-3 py-2 text-left transition hover:bg-base-200/50"
|
||
@click="openMessageFromContact(item.channel)"
|
||
>
|
||
<p class="text-sm text-base-content/90">{{ item.text }}</p>
|
||
<p class="mt-1 text-xs text-base-content/60">
|
||
<span class="mr-1 inline-flex h-4 w-4 items-center justify-center align-middle">
|
||
<svg v-if="channelIcon(item.channel) === 'telegram'" viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
|
||
<path d="M9.04 15.51 8.7 20.27c.49 0 .7-.21.96-.46l2.3-2.2 4.77 3.49c.88.49 1.5.23 1.74-.81l3.15-14.77.01-.01c.29-1.35-.49-1.88-1.35-1.56L1.74 11.08c-1.28.5-1.26 1.22-.22 1.54l4.74 1.48L17.3 7.03c.52-.34 1-.15.61.19" />
|
||
</svg>
|
||
<svg v-else-if="channelIcon(item.channel) === 'whatsapp'" viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
|
||
<path d="M20 3.99A9.94 9.94 0 0 0 12.01 1C6.49 1 2 5.49 2 11c0 1.76.46 3.49 1.33 5.03L2 23l7.17-1.88A9.95 9.95 0 0 0 12 21h.01c5.51 0 9.99-4.49 9.99-10.01 0-2.67-1.04-5.18-2.99-7m-7.99 15.32h-.01a8.3 8.3 0 0 1-4.23-1.16l-.3-.18-4.26 1.12 1.14-4.15-.2-.32a8.28 8.28 0 0 1-1.27-4.4c0-4.58 3.73-8.31 8.32-8.31a8.27 8.27 0 0 1 5.88 2.44 8.25 8.25 0 0 1 2.43 5.87c0 4.59-3.73 8.32-8.31 8.32m4.56-6.23c-.25-.12-1.49-.74-1.73-.82-.23-.09-.4-.12-.57.12s-.66.82-.81.99-.3.18-.55.06a6.7 6.7 0 0 1-1.97-1.21 7.43 7.43 0 0 1-1.38-1.71c-.14-.24-.01-.37.1-.49.11-.11.24-.3.36-.45.12-.14.16-.24.25-.39.08-.18.05-.3-.02-.42-.07-.12-.56-1.35-.77-1.85-.2-.48-.41-.41-.57-.42h-.48c-.16 0-.42.06-.64.3-.22.24-.84.82-.84 2s.86 2.31.98 2.48c.12.16 1.69 2.57 4.09 3.6.57.24 1.01.38 1.36.48.58.18 1.11.15 1.52.09.46-.06 1.49-.61 1.7-1.19.21-.58.21-1.09.15-1.19-.06-.11-.23-.17-.48-.3" />
|
||
</svg>
|
||
<svg v-else-if="channelIcon(item.channel) === 'instagram'" viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
|
||
<path d="M7 2h10a5 5 0 0 1 5 5v10a5 5 0 0 1-5 5H7a5 5 0 0 1-5-5V7a5 5 0 0 1 5-5m10 2H7a3 3 0 0 0-3 3v10a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V7a3 3 0 0 0-3-3m-5 3.5A4.5 4.5 0 1 1 7.5 12 4.5 4.5 0 0 1 12 7.5m0 2A2.5 2.5 0 1 0 14.5 12 2.5 2.5 0 0 0 12 9.5m4.8-3.2a1.2 1.2 0 1 1-1.2 1.2 1.2 1.2 0 0 1 1.2-1.2" />
|
||
</svg>
|
||
<svg v-else-if="channelIcon(item.channel) === 'email'" viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
|
||
<path d="M20 4H4a2 2 0 0 0-2 2v.4l10 5.6 10-5.6V6a2 2 0 0 0-2-2m0 4.2-7.4 4.14a1.25 1.25 0 0 1-1.2 0L4 8.2V18a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2z" />
|
||
</svg>
|
||
<svg v-else viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
|
||
<path d="M6.62 10.79a15.47 15.47 0 0 0 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1C10.07 21 3 13.93 3 5c0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.24.2 2.45.57 3.57.11.35.03.74-.25 1.02z" />
|
||
</svg>
|
||
</span>
|
||
<span>{{ formatStamp(item.at) }}</span>
|
||
</p>
|
||
</button>
|
||
<p v-if="selectedContactRecentMessages.length === 0" class="text-xs text-base-content/55">
|
||
No messages yet.
|
||
</p>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else class="flex h-full items-center justify-center text-sm text-base-content/60">
|
||
No contact selected.
|
||
</div>
|
||
</article>
|
||
</div>
|
||
</section>
|
||
|
||
<section v-else-if="selectedTab === 'communications' && peopleLeftMode === 'contacts'" class="flex h-full min-h-0 flex-col gap-0">
|
||
<div class="grid h-full min-h-0 flex-1 gap-0 md:grid-cols-[248px_minmax(0,1fr)_320px] md:grid-rows-[auto_minmax(0,1fr)]">
|
||
<aside class="h-full min-h-0 border-r border-base-300 flex flex-col md:row-span-2">
|
||
<div class="sticky top-0 z-20 h-12 border-b border-base-300 bg-base-100 px-2">
|
||
<div class="flex h-full items-center gap-1">
|
||
<div class="join rounded-lg border border-base-300 overflow-hidden">
|
||
<button
|
||
class="btn btn-ghost btn-sm join-item rounded-none"
|
||
:class="peopleListMode === 'contacts' ? 'bg-base-200/80 text-base-content' : 'text-base-content/65 hover:text-base-content'"
|
||
title="Contacts"
|
||
@click="peopleListMode = 'contacts'"
|
||
>
|
||
<svg viewBox="0 0 24 24" class="h-4 w-4 fill-current">
|
||
<path d="M12 12a5 5 0 1 0-5-5 5 5 0 0 0 5 5m0 2c-4.42 0-8 2.24-8 5v1h16v-1c0-2.76-3.58-5-8-5" />
|
||
</svg>
|
||
</button>
|
||
<button
|
||
class="btn btn-ghost btn-sm join-item rounded-none border-l border-base-300/70"
|
||
:class="peopleListMode === 'deals' ? 'bg-base-200/80 text-base-content' : 'text-base-content/65 hover:text-base-content'"
|
||
title="Deals"
|
||
@click="peopleListMode = 'deals'"
|
||
>
|
||
<svg viewBox="0 0 24 24" class="h-4 w-4 fill-current">
|
||
<path d="M10 3h4a2 2 0 0 1 2 2v2h3a2 2 0 0 1 2 2v3H3V9a2 2 0 0 1 2-2h3V5a2 2 0 0 1 2-2m0 4h4V5h-4zm11 7v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-5h7v2h4v-2z" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<input
|
||
v-model="peopleSearch"
|
||
type="text"
|
||
class="input input-bordered input-sm w-full"
|
||
:placeholder="peopleListMode === 'contacts' ? 'Search contacts' : 'Search deals'"
|
||
>
|
||
|
||
<div class="dropdown dropdown-end">
|
||
<button
|
||
tabindex="0"
|
||
class="btn btn-ghost btn-sm btn-square"
|
||
:title="peopleListMode === 'contacts' ? 'Sort contacts' : 'Sort deals'"
|
||
>
|
||
<svg viewBox="0 0 24 24" class="h-4 w-4 fill-current">
|
||
<path d="M3 5h18v2H3zm3 6h12v2H6zm4 6h4v2h-4z" />
|
||
</svg>
|
||
</button>
|
||
|
||
<div tabindex="0" class="dropdown-content z-20 mt-2 w-52 rounded-xl border border-base-300 bg-base-100 p-2 shadow-lg">
|
||
<template v-if="peopleListMode === 'contacts'">
|
||
<p class="px-2 pb-1 text-[11px] font-semibold uppercase tracking-wide text-base-content/55">Sort contacts</p>
|
||
<button
|
||
v-for="option in peopleSortOptions"
|
||
:key="`people-sort-${option.value}`"
|
||
class="btn btn-ghost btn-sm w-full justify-between"
|
||
@click="peopleSortMode = option.value"
|
||
>
|
||
<span>{{ option.label }}</span>
|
||
<span v-if="peopleSortMode === option.value">✓</span>
|
||
</button>
|
||
</template>
|
||
<p v-else class="px-2 py-1 text-xs text-base-content/60">Deals are sorted by title.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="min-h-0 flex-1 overflow-y-auto p-0">
|
||
<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' : '',
|
||
isReviewHighlightedContact(thread.id) ? 'bg-primary/10 ring-1 ring-primary/45' : '',
|
||
]"
|
||
@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' : '',
|
||
isReviewHighlightedDeal(deal.id) ? 'bg-primary/10 ring-1 ring-primary/45' : '',
|
||
]"
|
||
@click="openDealThread(deal)"
|
||
>
|
||
<div class="flex items-start justify-between gap-2">
|
||
<p class="truncate text-xs font-semibold">{{ deal.title }}</p>
|
||
<span class="shrink-0 text-[10px] text-base-content/55">{{ deal.amount }}</span>
|
||
</div>
|
||
<p class="mt-0.5 truncate text-[11px] text-base-content/75">{{ deal.company }} · {{ deal.stage }}</p>
|
||
<p class="mt-0.5 truncate text-[11px] text-base-content/60">{{ getDealCurrentStepLabel(deal) }}</p>
|
||
</button>
|
||
|
||
<p v-if="peopleListMode === 'contacts' && peopleContactList.length === 0" class="px-1 py-2 text-xs text-base-content/55">
|
||
No contacts found.
|
||
</p>
|
||
<p v-if="peopleListMode === 'deals' && peopleDealList.length === 0" class="px-1 py-2 text-xs text-base-content/55">
|
||
No deals found.
|
||
</p>
|
||
</div>
|
||
</aside>
|
||
|
||
<div class="hidden h-12 items-center justify-between gap-2 border-b border-base-300 px-3 md:flex md:col-span-2">
|
||
<div v-if="selectedWorkspaceContact">
|
||
<p class="font-medium">{{ selectedWorkspaceContact.name }}</p>
|
||
<p class="text-xs text-base-content/60">
|
||
{{ selectedWorkspaceContact.company }} · {{ selectedWorkspaceContact.location }}, {{ selectedWorkspaceContact.country }}
|
||
</p>
|
||
</div>
|
||
<div v-else-if="selectedCommThread">
|
||
<p class="font-medium">{{ selectedCommThread.contact }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<article class="h-full min-h-0 border-r border-base-300 flex flex-col">
|
||
<div v-if="false" class="p-3">
|
||
<div class="grid grid-cols-[auto_1fr_auto] items-center gap-2">
|
||
<div class="flex items-center gap-1">
|
||
<button class="btn btn-xs" @click="setToday">Today</button>
|
||
<button class="btn btn-xs btn-ghost" @click="shiftCalendar(-1)">←</button>
|
||
<button class="btn btn-xs btn-ghost" @click="shiftCalendar(1)">→</button>
|
||
</div>
|
||
|
||
<div class="text-center text-sm font-medium">
|
||
{{ calendarPeriodLabel }}
|
||
</div>
|
||
|
||
<div class="justify-self-end">
|
||
<select v-model="calendarView" class="select select-bordered select-xs w-36">
|
||
<option
|
||
v-for="option in calendarViewOptions"
|
||
:key="`workspace-right-calendar-view-${option.value}`"
|
||
:value="option.value"
|
||
>
|
||
{{ option.label }}
|
||
</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="calendarView === 'month'" class="mt-3 space-y-1">
|
||
<div class="grid grid-cols-7 gap-1 text-center text-xs font-semibold text-base-content/60">
|
||
<span>Sun</span>
|
||
<span>Mon</span>
|
||
<span>Tue</span>
|
||
<span>Wed</span>
|
||
<span>Thu</span>
|
||
<span>Fri</span>
|
||
<span>Sat</span>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-7 gap-1">
|
||
<button
|
||
v-for="cell in monthCells"
|
||
:key="`workspace-right-month-${cell.key}`"
|
||
class="min-h-24 rounded-lg border p-1 text-left"
|
||
:class="[
|
||
cell.inMonth ? 'border-base-300 bg-base-100' : 'border-base-200 bg-base-200/40 text-base-content/40',
|
||
selectedDateKey === cell.key ? 'border-primary bg-primary/5' : '',
|
||
]"
|
||
@click="pickDate(cell.key)"
|
||
>
|
||
<p class="mb-1 text-xs font-semibold">{{ cell.day }}</p>
|
||
<button
|
||
v-for="event in cell.events.slice(0, 2)"
|
||
:key="`workspace-right-month-event-${event.id}`"
|
||
class="block w-full truncate text-left text-[10px] text-base-content/70 hover:underline"
|
||
@click.stop="openThreadFromCalendarItem(event)"
|
||
>
|
||
{{ formatTime(event.start) }} {{ event.title }}
|
||
</button>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else-if="calendarView === 'week'" class="mt-3 space-y-2">
|
||
<article
|
||
v-for="day in weekDays"
|
||
:key="`workspace-right-week-${day.key}`"
|
||
class="rounded-xl border border-base-300 p-3"
|
||
:class="selectedDateKey === day.key ? 'border-primary bg-primary/5' : ''"
|
||
@click="pickDate(day.key)"
|
||
>
|
||
<p class="mb-2 text-sm font-semibold">{{ day.label }} {{ day.day }}</p>
|
||
<div class="space-y-1">
|
||
<button
|
||
v-for="event in day.events"
|
||
:key="`workspace-right-week-event-${event.id}`"
|
||
class="block w-full rounded bg-base-200 px-2 py-1 text-left text-xs hover:bg-base-300/80"
|
||
@click.stop="openThreadFromCalendarItem(event)"
|
||
>
|
||
{{ formatTime(event.start) }} - {{ event.title }} ({{ event.contact }})
|
||
</button>
|
||
<p v-if="day.events.length === 0" class="text-xs text-base-content/50">No events</p>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
|
||
<div v-else-if="calendarView === 'day'" class="mt-3 space-y-2">
|
||
<button
|
||
v-for="event in selectedDayEvents"
|
||
:key="`workspace-right-day-event-${event.id}`"
|
||
class="block w-full rounded-xl border border-base-300 p-3 text-left hover:bg-base-200/60"
|
||
@click="openThreadFromCalendarItem(event)"
|
||
>
|
||
<p class="font-medium">{{ event.title }}</p>
|
||
<p class="text-xs text-base-content/60">{{ event.contact }}</p>
|
||
<p class="text-xs text-base-content/60">{{ formatTime(event.start) }} - {{ formatTime(event.end) }}</p>
|
||
<p class="mt-1 text-sm text-base-content/80">{{ event.note }}</p>
|
||
</button>
|
||
<p v-if="selectedDayEvents.length === 0" class="text-sm text-base-content/60">No events on this day.</p>
|
||
</div>
|
||
|
||
<div v-else-if="calendarView === 'year'" class="mt-3 grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||
<button
|
||
v-for="item in yearMonths"
|
||
:key="`workspace-right-year-${item.monthIndex}`"
|
||
class="rounded-xl border border-base-300 p-3 text-left transition hover:border-primary/50 hover:bg-primary/5"
|
||
@click="openYearMonth(item.monthIndex)"
|
||
>
|
||
<p class="font-medium">{{ item.label }}</p>
|
||
<p class="text-xs text-base-content/60">{{ item.count }} events</p>
|
||
<p v-if="item.first" class="mt-1 text-xs text-base-content/70">
|
||
{{ formatYearMonthFirst(item) }}
|
||
</p>
|
||
</button>
|
||
</div>
|
||
|
||
<div v-else class="mt-3 space-y-2">
|
||
<button
|
||
v-for="event in sortedEvents"
|
||
:key="`workspace-right-agenda-${event.id}`"
|
||
class="block w-full rounded-xl border border-base-300 p-3 text-left hover:bg-base-200/60"
|
||
@click="openThreadFromCalendarItem(event)"
|
||
>
|
||
<p class="font-medium">{{ event.title }}</p>
|
||
<p class="text-xs text-base-content/60">{{ event.contact }}</p>
|
||
<p class="text-xs text-base-content/60">{{ formatDay(event.start) }} · {{ formatTime(event.start) }} - {{ formatTime(event.end) }}</p>
|
||
<p class="mt-1 text-sm text-base-content/80">{{ event.note }}</p>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else-if="selectedCommThread" class="relative flex h-full min-h-0 flex-col">
|
||
<div class="comm-thread-surface min-h-0 flex-1 space-y-2 overflow-y-auto px-3 pb-2">
|
||
<button
|
||
class="sticky top-0 z-10 -mx-3 mb-2 flex w-[calc(100%+1.5rem)] items-center gap-2 border-b border-base-300 bg-base-100/80 px-3 py-2 text-left backdrop-blur-sm transition hover:bg-base-100"
|
||
@click="commPinnedOnly = !commPinnedOnly"
|
||
>
|
||
<svg viewBox="0 0 24 24" class="h-4 w-4 shrink-0 fill-current text-base-content/75">
|
||
<path d="M14 3a1 1 0 0 0-1 1v4.59l-1.7 1.7A2 2 0 0 0 10.7 12H8v2h2.7a2 2 0 0 0 .6 1.41L13 17.1V21l2-1.2v-2.7l1.7-1.7A2 2 0 0 0 17.3 14H20v-2h-2.7a2 2 0 0 0-.6-1.41L15 8.9V4a1 1 0 0 0-1-1Z" />
|
||
</svg>
|
||
<span class="min-w-0 flex-1 truncate text-xs text-base-content/80">{{ latestPinnedLabel }}</span>
|
||
<span class="shrink-0 text-xs text-base-content/75">{{ selectedCommPinnedStream.length }}</span>
|
||
</button>
|
||
|
||
<div
|
||
v-for="entry in (commPinnedOnly ? selectedCommPinnedStream : threadStreamItems)"
|
||
:key="entry.id"
|
||
@contextmenu.prevent="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"
|
||
:class="isReviewHighlightedMessage(entry.item.id) ? 'border-success/60 bg-success/10 ring-2 ring-success/40' : ''"
|
||
>
|
||
<p class="mb-2 text-xs text-base-content/65">
|
||
{{ formatDay(entry.item.at) }} · {{ formatTime(entry.item.at) }}
|
||
<span v-if="entry.item.duration"> · {{ entry.item.duration }}</span>
|
||
</p>
|
||
<div class="comm-call-wave mb-2" :ref="(el) => setCommCallWaveHost(entry.item.id, el as Element | null)" />
|
||
<div class="mt-2 flex justify-center">
|
||
<button class="call-transcript-toggle" @click="toggleCallTranscript(entry.item)">
|
||
<span>
|
||
{{
|
||
callTranscriptLoading[entry.item.id]
|
||
? "Generating transcript..."
|
||
: isCallTranscriptOpen(entry.item.id)
|
||
? "Hide transcript"
|
||
: "Show transcript"
|
||
}}
|
||
</span>
|
||
<svg
|
||
viewBox="0 0 20 20"
|
||
class="h-3.5 w-3.5 transition-transform"
|
||
:class="isCallTranscriptOpen(entry.item.id) ? 'rotate-180' : ''"
|
||
>
|
||
<path
|
||
fill="currentColor"
|
||
fill-rule="evenodd"
|
||
clip-rule="evenodd"
|
||
d="M5.22 7.22a.75.75 0 0 1 1.06 0L10 10.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 8.28a.75.75 0 0 1 0-1.06Z"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<transition name="accordion">
|
||
<div v-if="isCallTranscriptOpen(entry.item.id)" class="mt-2 rounded-xl border border-base-300 bg-base-100 p-2 text-left">
|
||
<div v-if="callTranscriptLoading[entry.item.id]" class="call-transcript-loader" aria-live="polite" aria-busy="true">
|
||
<span />
|
||
<span />
|
||
<span />
|
||
<span />
|
||
</div>
|
||
<div v-else-if="callTranscriptError[entry.item.id]" class="space-y-2">
|
||
<p class="text-xs leading-relaxed text-error">
|
||
{{ callTranscriptError[entry.item.id] }}
|
||
</p>
|
||
<button class="btn btn-xs btn-outline" @click="transcribeCallItem(entry.item)">Retry</button>
|
||
</div>
|
||
<p v-else class="text-xs leading-relaxed text-base-content/80">
|
||
{{ callTranscriptText[entry.item.id] || "No transcript yet" }}
|
||
</p>
|
||
</div>
|
||
</transition>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else-if="entry.kind === 'eventLifecycle'" class="flex justify-center">
|
||
<article
|
||
class="w-full max-w-[460px] rounded-xl border p-3 text-center"
|
||
:class="[eventPhaseToneClass(entry.phase), isReviewHighlightedEvent(entry.event.id) ? 'ring-2 ring-success/45' : '']"
|
||
>
|
||
<p class="text-xs text-base-content/70">
|
||
{{ eventRelativeLabel(entry.event, lifecycleNowMs) }} · {{ formatDay(entry.event.start) }} {{ formatTime(entry.event.start) }}
|
||
</p>
|
||
<p class="mt-1 text-sm text-base-content/90">{{ entry.event.note || entry.event.title }}</p>
|
||
<p v-if="entry.event.archiveNote" class="mt-2 text-xs text-base-content/70">Archive note: {{ entry.event.archiveNote }}</p>
|
||
|
||
<div v-if="canManuallyCloseEvent(entry)" class="mt-2">
|
||
<button class="btn btn-xs btn-outline" @click="toggleEventClose(entry.event.id)">
|
||
{{ isEventCloseOpen(entry.event.id) ? "Cancel" : "Archive event" }}
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="canManuallyCloseEvent(entry) && isEventCloseOpen(entry.event.id)" class="mt-2 space-y-2 text-left">
|
||
<textarea
|
||
v-model="eventCloseDraft[entry.event.id]"
|
||
class="textarea textarea-bordered w-full text-xs"
|
||
rows="3"
|
||
placeholder="Archive note (optional)"
|
||
/>
|
||
<div class="flex justify-between gap-2">
|
||
<button
|
||
class="btn btn-xs btn-outline"
|
||
:disabled="isEventArchiveTranscribing(entry.event.id)"
|
||
@click="toggleEventArchiveRecording(entry.event.id)"
|
||
>
|
||
{{
|
||
isEventArchiveTranscribing(entry.event.id)
|
||
? "Transcribing..."
|
||
: isEventArchiveRecording(entry.event.id)
|
||
? "Stop mic"
|
||
: "Voice note"
|
||
}}
|
||
</button>
|
||
</div>
|
||
<p v-if="eventArchiveMicErrorById[entry.event.id]" class="text-xs text-error">{{ eventArchiveMicErrorById[entry.event.id] }}</p>
|
||
<p v-if="eventCloseError[entry.event.id]" class="text-xs text-error">{{ eventCloseError[entry.event.id] }}</p>
|
||
<div class="flex justify-end">
|
||
<button
|
||
class="btn btn-xs"
|
||
:disabled="eventCloseSaving[entry.event.id]"
|
||
@click="archiveEventManually(entry.event)"
|
||
>
|
||
{{ eventCloseSaving[entry.event.id] ? "Saving..." : "Confirm archive" }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
|
||
<div v-else-if="entry.kind === 'recommendation'" class="flex justify-center">
|
||
<article class="w-full max-w-[460px] rounded-xl border border-base-300 bg-base-100 p-3">
|
||
<p class="text-sm">{{ entry.card.text }}</p>
|
||
|
||
<div class="mt-2 rounded-lg border border-base-300 bg-base-200/30 p-2">
|
||
<p class="text-xs font-semibold uppercase tracking-wide text-base-content/70">{{ entry.card.proposal.title }}</p>
|
||
<p
|
||
v-for="line in entry.card.proposal.details"
|
||
:key="`${entry.card.id}-${line}`"
|
||
class="mt-1 text-xs text-base-content/80"
|
||
>
|
||
{{ line }}
|
||
</p>
|
||
</div>
|
||
|
||
<div v-if="entry.card.decision === 'pending'" class="mt-2 flex gap-2">
|
||
<button class="btn btn-xs flex-1" @click="decideFeedCard(entry.card, 'accepted')">Yes</button>
|
||
<button class="btn btn-xs btn-outline flex-1" @click="decideFeedCard(entry.card, 'rejected')">No</button>
|
||
</div>
|
||
<p v-else class="mt-2 text-xs text-base-content/70">{{ entry.card.decisionNote }}</p>
|
||
</article>
|
||
</div>
|
||
|
||
<div
|
||
v-else
|
||
class="flex"
|
||
:class="entry.item.direction === 'out' ? 'justify-end' : 'justify-start'"
|
||
>
|
||
<div
|
||
class="max-w-[88%] rounded-xl border border-base-300 p-3"
|
||
:class="[
|
||
entry.item.direction === 'out' ? 'bg-base-200' : 'bg-base-100',
|
||
isReviewHighlightedMessage(entry.item.id) ? 'border-success/60 bg-success/10 ring-2 ring-success/40' : '',
|
||
]"
|
||
>
|
||
<p class="text-sm">{{ entry.item.text }}</p>
|
||
<p class="mt-1 text-xs text-base-content/60">
|
||
<span class="mr-1 inline-flex h-4 w-4 items-center justify-center align-middle">
|
||
<svg v-if="channelIcon(entry.item.channel) === 'telegram'" viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
|
||
<path d="M9.04 15.51 8.7 20.27c.49 0 .7-.21.96-.46l2.3-2.2 4.77 3.49c.88.49 1.5.23 1.74-.81l3.15-14.77.01-.01c.29-1.35-.49-1.88-1.35-1.56L1.74 11.08c-1.28.5-1.26 1.22-.22 1.54l4.74 1.48L17.3 7.03c.52-.34 1-.15.61.19" />
|
||
</svg>
|
||
<svg v-else-if="channelIcon(entry.item.channel) === 'whatsapp'" viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
|
||
<path d="M20 3.99A9.94 9.94 0 0 0 12.01 1C6.49 1 2 5.49 2 11c0 1.76.46 3.49 1.33 5.03L2 23l7.17-1.88A9.95 9.95 0 0 0 12 21h.01c5.51 0 9.99-4.49 9.99-10.01 0-2.67-1.04-5.18-2.99-7m-7.99 15.32h-.01a8.3 8.3 0 0 1-4.23-1.16l-.3-.18-4.26 1.12 1.14-4.15-.2-.32a8.28 8.28 0 0 1-1.27-4.4c0-4.58 3.73-8.31 8.32-8.31a8.27 8.27 0 0 1 5.88 2.44 8.25 8.25 0 0 1 2.43 5.87c0 4.59-3.73 8.32-8.31 8.32m4.56-6.23c-.25-.12-1.49-.74-1.73-.82-.23-.09-.4-.12-.57.12s-.66.82-.81.99-.3.18-.55.06a6.7 6.7 0 0 1-1.97-1.21 7.43 7.43 0 0 1-1.38-1.71c-.14-.24-.01-.37.1-.49.11-.11.24-.3.36-.45.12-.14.16-.24.25-.39.08-.18.05-.3-.02-.42-.07-.12-.56-1.35-.77-1.85-.2-.48-.41-.41-.57-.42h-.48c-.16 0-.42.06-.64.3-.22.24-.84.82-.84 2s.86 2.31.98 2.48c.12.16 1.69 2.57 4.09 3.6.57.24 1.01.38 1.36.48.58.18 1.11.15 1.52.09.46-.06 1.49-.61 1.7-1.19.21-.58.21-1.09.15-1.19-.06-.11-.23-.17-.48-.3" />
|
||
</svg>
|
||
<svg v-else-if="channelIcon(entry.item.channel) === 'instagram'" viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
|
||
<path d="M7 2h10a5 5 0 0 1 5 5v10a5 5 0 0 1-5 5H7a5 5 0 0 1-5-5V7a5 5 0 0 1 5-5m10 2H7a3 3 0 0 0-3 3v10a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V7a3 3 0 0 0-3-3m-5 3.5A4.5 4.5 0 1 1 7.5 12 4.5 4.5 0 0 1 12 7.5m0 2A2.5 2.5 0 1 0 14.5 12 2.5 2.5 0 0 0 12 9.5m4.8-3.2a1.2 1.2 0 1 1-1.2 1.2 1.2 1.2 0 0 1 1.2-1.2" />
|
||
</svg>
|
||
<svg v-else-if="channelIcon(entry.item.channel) === 'email'" viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
|
||
<path d="M20 4H4a2 2 0 0 0-2 2v.4l10 5.6 10-5.6V6a2 2 0 0 0-2-2m0 4.2-7.4 4.14a1.25 1.25 0 0 1-1.2 0L4 8.2V18a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2z" />
|
||
</svg>
|
||
<svg v-else viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
|
||
<path d="M6.62 10.79a15.47 15.47 0 0 0 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1C10.07 21 3 13.93 3 5c0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.24.2 2.45.57 3.57.11.35.03.74-.25 1.02z" />
|
||
</svg>
|
||
</span>
|
||
<span>{{ formatStamp(entry.item.at) }}</span>
|
||
</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"
|
||
:class="isReviewHighlightedDeal(selectedWorkspaceDeal.id) ? 'border-primary/60 bg-primary/10' : ''"
|
||
>
|
||
<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>
|
||
<div
|
||
v-if="activeReviewContactDiff && selectedWorkspaceContact && activeReviewContactDiff.contactId === selectedWorkspaceContact.id"
|
||
class="mb-2 rounded-xl border border-primary/35 bg-primary/5 p-2"
|
||
>
|
||
<p class="text-[11px] font-semibold uppercase tracking-wide text-primary/80">Review diff</p>
|
||
<p class="mt-1 text-[11px] text-base-content/65">Before</p>
|
||
<pre class="mt-1 whitespace-pre-wrap rounded-lg border border-base-300/70 bg-base-100 px-2 py-1.5 text-[11px] leading-relaxed text-base-content/65 line-through">{{ activeReviewContactDiff.before || "Empty" }}</pre>
|
||
<p class="mt-2 text-[11px] text-base-content/65">After</p>
|
||
<pre class="mt-1 whitespace-pre-wrap rounded-lg border border-success/40 bg-success/10 px-2 py-1.5 text-[11px] leading-relaxed text-base-content">{{ activeReviewContactDiff.after || "Empty" }}</pre>
|
||
</div>
|
||
<ContactCollaborativeEditor
|
||
v-if="selectedWorkspaceContact"
|
||
:key="`contact-summary-${selectedWorkspaceContact.id}`"
|
||
v-model="selectedWorkspaceContact.description"
|
||
:room="`crm-contact-${selectedWorkspaceContact.id}`"
|
||
placeholder="Contact summary..."
|
||
:plain="true"
|
||
/>
|
||
<p v-else class="text-xs text-base-content/60">No contact selected.</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
</section>
|
||
|
||
<section v-else-if="selectedTab === 'documents'" class="flex h-full min-h-0 flex-col gap-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
|
||
v-if="reviewActive && selectedTab === 'communications'"
|
||
class="pointer-events-none fixed inset-x-2 bottom-2 z-40 md:inset-auto md:right-4 md:bottom-4 md:w-[390px]"
|
||
>
|
||
<section class="pointer-events-auto rounded-2xl border border-base-300 bg-base-100/95 p-3 shadow-2xl backdrop-blur">
|
||
<div class="flex items-start justify-between gap-2">
|
||
<div class="min-w-0">
|
||
<p class="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
|
||
Review {{ activeChangeStepNumber }}/{{ activeChangeItems.length }}
|
||
</p>
|
||
<p class="truncate text-sm font-semibold text-base-content">
|
||
{{ activeChangeItem?.title || "Change step" }}
|
||
</p>
|
||
</div>
|
||
<button class="btn btn-ghost btn-xs" @click="finishReview(true)">Close</button>
|
||
</div>
|
||
|
||
<div v-if="activeChangeItem" class="mt-2 rounded-xl border border-base-300 bg-base-200/35 p-2">
|
||
<p class="text-xs text-base-content/80">
|
||
{{ describeChangeEntity(activeChangeItem.entity) }} {{ describeChangeAction(activeChangeItem.action) }}
|
||
</p>
|
||
<label class="mt-1 inline-flex items-center gap-2 text-xs">
|
||
<input
|
||
type="checkbox"
|
||
class="checkbox checkbox-xs"
|
||
:checked="activeChangeApproved"
|
||
:disabled="activeChangeItem.rolledBack"
|
||
@change="onActiveReviewApprovalInput"
|
||
>
|
||
<span>{{ activeChangeItem.rolledBack ? "Already rolled back" : "Approve this step" }}</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div class="mt-2 max-h-40 space-y-1 overflow-y-auto pr-1">
|
||
<div
|
||
v-for="(item, index) in activeChangeItems"
|
||
:key="`review-step-${item.id}`"
|
||
class="flex items-center gap-2 rounded-lg border px-2 py-1"
|
||
:class="index === activeChangeIndex ? 'border-primary/45 bg-primary/10' : 'border-base-300 bg-base-100'"
|
||
>
|
||
<button
|
||
class="min-w-0 flex-1 text-left"
|
||
@click="openChangeItemTarget(item)"
|
||
>
|
||
<p class="truncate text-xs font-medium text-base-content">
|
||
{{ index + 1 }}. {{ item.title }}
|
||
</p>
|
||
<p class="truncate text-[11px] text-base-content/65">
|
||
{{ describeChangeEntity(item.entity) }}
|
||
</p>
|
||
</button>
|
||
<input
|
||
type="checkbox"
|
||
class="checkbox checkbox-xs"
|
||
:checked="isReviewItemApproved(item)"
|
||
:disabled="item.rolledBack"
|
||
@change="onReviewItemApprovalInput(item.id, $event)"
|
||
>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-3 flex items-center justify-between gap-2">
|
||
<div class="join">
|
||
<button
|
||
class="btn btn-xs join-item"
|
||
:disabled="activeChangeIndex <= 0"
|
||
@click="goToPreviousChangeStep"
|
||
>
|
||
Prev
|
||
</button>
|
||
<button
|
||
class="btn btn-xs join-item"
|
||
:disabled="activeChangeIndex >= activeChangeItems.length - 1"
|
||
@click="goToNextChangeStep"
|
||
>
|
||
Next
|
||
</button>
|
||
</div>
|
||
<p class="text-[11px] text-base-content/70">
|
||
Rollback marked: {{ selectedRollbackCount }}
|
||
</p>
|
||
</div>
|
||
|
||
<div class="mt-2 flex flex-wrap gap-2">
|
||
<button class="btn btn-xs btn-outline" @click="setReviewApprovalForAll(true)">Approve all</button>
|
||
<button class="btn btn-xs btn-outline" @click="setReviewApprovalForAll(false)">Mark all rollback</button>
|
||
<button
|
||
class="btn btn-xs btn-warning"
|
||
:disabled="changeActionBusy || selectedRollbackCount === 0"
|
||
@click="rollbackSelectedChangeItems"
|
||
>
|
||
{{ changeActionBusy ? "Applying..." : "Rollback selected" }}
|
||
</button>
|
||
<button class="btn btn-xs btn-primary ml-auto" @click="finishReview(true)">Done</button>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
</div>
|
||
</main>
|
||
</div>
|
||
|
||
</template>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.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>
|