Files
clientsflow/Frontend/app.vue

2504 lines
100 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

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

<script setup lang="ts">
import { 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 sendPilotMessageMutation from "./graphql/operations/send-pilot-message.graphql?raw";
import logPilotNoteMutation from "./graphql/operations/log-pilot-note.graphql?raw";
import createCalendarEventMutation from "./graphql/operations/create-calendar-event.graphql?raw";
import createCommunicationMutation from "./graphql/operations/create-communication.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";
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;
};
type CommItem = {
id: string;
at: string;
contact: string;
channel: "Telegram" | "WhatsApp" | "Instagram" | "Phone" | "Email";
kind: "message" | "call";
direction: "in" | "out";
text: 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;
};
type WorkspaceDocument = {
id: string;
title: string;
type: "Regulation" | "Playbook" | "Policy" | "Template";
owner: string;
scope: string;
updatedAt: string;
summary: string;
body: string;
};
const tabs: { id: TabId; label: string }[] = [
{ id: "communications", label: "Workspace" },
{ id: "documents", label: "Documents" },
];
const selectedTab = ref<TabId>("communications");
function dayKey(date: Date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0");
return `${y}-${m}-${d}`;
}
function formatDay(iso: string) {
return new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
year: "numeric",
}).format(new Date(iso));
}
function formatTime(iso: string) {
return new Intl.DateTimeFormat("en-GB", {
hour: "2-digit",
minute: "2-digit",
}).format(new Date(iso));
}
function formatThreadTime(iso: string) {
return new Intl.DateTimeFormat("en-GB", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
})
.format(new Date(iso))
.replace(":", ".");
}
function formatStamp(iso: string) {
return new Intl.DateTimeFormat("en-GB", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
}).format(new Date(iso));
}
function atOffset(days: number, hour: number, minute: number) {
const d = new Date();
d.setDate(d.getDate() + days);
d.setHours(hour, minute, 0, 0);
return d.toISOString();
}
function inMinutes(minutes: number) {
const d = new Date();
d.setMinutes(d.getMinutes() + minutes, 0, 0);
return d.toISOString();
}
function endAfter(startIso: string, minutes: number) {
const d = new Date(startIso);
d.setMinutes(d.getMinutes() + minutes);
return d.toISOString();
}
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;
plan?: string[] | null;
thinking?: string[] | null;
tools?: string[] | null;
toolRuns?: Array<{
name: string;
status: "ok" | "error";
input: string;
output: string;
at: string;
}> | null;
createdAt?: string;
pending?: boolean;
};
type ChatConversation = {
id: string;
title: string;
createdAt: string;
updatedAt: string;
lastMessageAt?: string | null;
lastMessageText?: string | null;
};
const pilotMessages = ref<PilotMessage[]>([]);
const pilotInput = ref("");
const pilotSending = ref(false);
const 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 selectedChatId = ref("");
const loginPhone = ref("");
const loginPassword = ref("");
const loginError = ref<string | null>(null);
const loginBusy = ref(false);
const pilotTimeline = computed<PilotMessage[]>(() => {
if (!pilotSending.value) return pilotMessages.value;
return [
...pilotMessages.value,
{
id: "__pilot_pending__",
role: "assistant",
text: "Working on it...",
thinking: ["Reading your request", "Planning the next actions", "Preparing the final answer"],
tools: [],
toolRuns: [],
createdAt: new Date().toISOString(),
pending: true,
},
];
});
const activeChatConversation = computed<ChatConversation | null>(() => {
const activeId = authMe.value?.conversation.id;
if (!activeId) return null;
const fromList = chatConversations.value.find((item) => item.id === activeId);
if (fromList) return fromList;
return {
id: activeId,
title: authMe.value?.conversation.title ?? "Current chat",
createdAt: "",
updatedAt: "",
lastMessageAt: null,
lastMessageText: 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 "System";
return "Pilot";
}
function pilotRoleBadge(role: PilotMessage["role"]) {
if (role === "user") return "You";
if (role === "system") return "SYS";
return "AI";
}
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));
}
async function gqlFetch<TData>(query: string, variables?: Record<string, unknown>) {
const result = await $fetch<{ data?: TData; errors?: Array<{ message: string }> }>("/api/graphql", {
method: "POST",
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 ?? [];
}
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;
}
async function createNewChatConversation() {
if (chatCreating.value) return;
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;
chatSwitching.value = true;
try {
await gqlFetch<{ selectChatConversation: { ok: boolean } }>(selectChatConversationMutation, { id });
await Promise.all([loadMe(), loadPilotMessages(), loadChatConversations()]);
} finally {
chatSwitching.value = false;
}
}
async function login() {
loginError.value = null;
loginBusy.value = true;
try {
await gqlFetch<{ login: { ok: boolean } }>(loginMutation, {
phone: loginPhone.value,
password: loginPassword.value,
});
await loadMe();
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);
authMe.value = null;
pilotMessages.value = [];
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 sendPilotMessage() {
const text = pilotInput.value.trim();
if (!text || pilotSending.value) return;
pilotSending.value = true;
pilotInput.value = "";
try {
await gqlFetch<{ sendPilotMessage: { ok: boolean } }>(sendPilotMessageMutation, { text });
await Promise.all([loadPilotMessages(), loadChatConversations()]);
} catch {
pilotInput.value = text;
} finally {
pilotSending.value = false;
}
}
onMounted(() => {
loadMe()
.then(() => Promise.all([loadPilotMessages(), loadChatConversations(), refreshCrmData()]))
.catch(() => {});
});
const calendarView = ref<CalendarView>("month");
const calendarCursor = ref(new Date(new Date().getFullYear(), new Date().getMonth(), 1));
const selectedDateKey = ref(dayKey(new Date()));
const sortedEvents = computed(() => [...calendarEvents.value].sort((a, b) => a.start.localeCompare(b.start)));
const eventsByDate = computed(() => {
const map = new Map<string, CalendarEvent[]>();
for (const event of sortedEvents.value) {
const key = event.start.slice(0, 10);
if (!map.has(key)) {
map.set(key, []);
}
map.get(key)?.push(event);
}
return map;
});
function getEventsByDate(key: string) {
return eventsByDate.value.get(key) ?? [];
}
const monthLabel = computed(() =>
new Intl.DateTimeFormat("en-US", { month: "long", year: "numeric" }).format(calendarCursor.value),
);
const calendarViewOptions: { value: CalendarView; label: string }[] = [
{ value: "day", label: "Day" },
{ value: "week", label: "Week" },
{ value: "month", label: "Month" },
{ value: "year", label: "Year" },
{ value: "agenda", label: "Agenda" },
];
const monthCells = computed(() => {
const year = calendarCursor.value.getFullYear();
const month = calendarCursor.value.getMonth();
const first = new Date(year, month, 1);
const start = new Date(year, month, 1 - first.getDay());
return Array.from({ length: 42 }, (_, index) => {
const d = new Date(start);
d.setDate(start.getDate() + index);
const key = dayKey(d);
return {
key,
day: d.getDate(),
inMonth: d.getMonth() === month,
events: getEventsByDate(key),
};
});
});
const weekDays = computed(() => {
const base = new Date(`${selectedDateKey.value}T00:00:00`);
const mondayOffset = (base.getDay() + 6) % 7;
const monday = new Date(base);
monday.setDate(base.getDate() - mondayOffset);
return Array.from({ length: 7 }, (_, index) => {
const d = new Date(monday);
d.setDate(monday.getDate() + index);
const key = dayKey(d);
return {
key,
label: new Intl.DateTimeFormat("en-US", { weekday: "short" }).format(d),
day: d.getDate(),
events: getEventsByDate(key),
};
});
});
const calendarPeriodLabel = computed(() => {
if (calendarView.value === "month") {
return monthLabel.value;
}
if (calendarView.value === "year") {
return String(calendarCursor.value.getFullYear());
}
if (calendarView.value === "week") {
const first = weekDays.value[0];
const last = weekDays.value[weekDays.value.length - 1];
if (!first || !last) return "";
return `${formatDay(`${first.key}T00:00:00`)} - ${formatDay(`${last.key}T00:00:00`)}`;
}
if (calendarView.value === "day") {
return formatDay(`${selectedDateKey.value}T00:00:00`);
}
return `Agenda · ${monthLabel.value}`;
});
const yearMonths = computed(() => {
const year = calendarCursor.value.getFullYear();
return Array.from({ length: 12 }, (_, monthIndex) => {
const monthStart = new Date(year, monthIndex, 1);
const monthEnd = new Date(year, monthIndex + 1, 1);
const items = sortedEvents.value.filter((event) => {
const d = new Date(event.start);
return d >= monthStart && d < monthEnd;
});
return {
monthIndex,
label: new Intl.DateTimeFormat("en-US", { month: "long" }).format(monthStart),
count: items.length,
first: items[0],
};
});
});
const selectedDayEvents = computed(() => getEventsByDate(selectedDateKey.value));
function shiftCalendar(step: number) {
if (calendarView.value === "year") {
const next = new Date(calendarCursor.value);
next.setFullYear(next.getFullYear() + step);
calendarCursor.value = new Date(next.getFullYear(), next.getMonth(), 1);
const selected = new Date(`${selectedDateKey.value}T00:00:00`);
selected.setFullYear(selected.getFullYear() + step);
selectedDateKey.value = dayKey(selected);
return;
}
if (calendarView.value === "month" || calendarView.value === "agenda") {
const next = new Date(calendarCursor.value);
next.setMonth(next.getMonth() + step);
calendarCursor.value = new Date(next.getFullYear(), next.getMonth(), 1);
return;
}
const current = new Date(`${selectedDateKey.value}T00:00:00`);
const days = calendarView.value === "week" ? 7 : 1;
current.setDate(current.getDate() + days * step);
selectedDateKey.value = dayKey(current);
calendarCursor.value = new Date(current.getFullYear(), current.getMonth(), 1);
}
function setToday() {
const now = new Date();
selectedDateKey.value = dayKey(now);
calendarCursor.value = new Date(now.getFullYear(), now.getMonth(), 1);
}
function pickDate(key: string) {
selectedDateKey.value = key;
const d = new Date(`${key}T00:00:00`);
calendarCursor.value = new Date(d.getFullYear(), d.getMonth(), 1);
}
function openYearMonth(monthIndex: number) {
const year = calendarCursor.value.getFullYear();
calendarCursor.value = new Date(year, monthIndex, 1);
selectedDateKey.value = dayKey(new Date(year, monthIndex, 1));
calendarView.value = "month";
}
const contactSearch = ref("");
const selectedCountry = ref("All");
const selectedLocation = ref("All");
const selectedCompany = ref("All");
const selectedChannel = ref("All");
const sortMode = ref<SortMode>("name");
const countries = computed(() => ["All", ...new Set(contacts.value.map((c) => c.country))].sort());
const locationScopeContacts = computed(() =>
selectedCountry.value === "All"
? contacts.value
: contacts.value.filter((contact) => contact.country === selectedCountry.value),
);
const locations = computed(() => ["All", ...new Set(locationScopeContacts.value.map((c) => c.location))].sort());
const companyScopeContacts = computed(() =>
selectedLocation.value === "All"
? locationScopeContacts.value
: locationScopeContacts.value.filter((contact) => contact.location === selectedLocation.value),
);
const companies = computed(() => ["All", ...new Set(companyScopeContacts.value.map((c) => c.company))].sort());
const channels = computed(() => ["All", ...new Set(contacts.value.flatMap((c) => c.channels))].sort());
watch(selectedCountry, () => {
selectedLocation.value = "All";
selectedCompany.value = "All";
});
watch(selectedLocation, () => {
selectedCompany.value = "All";
});
function resetContactFilters() {
contactSearch.value = "";
selectedCountry.value = "All";
selectedLocation.value = "All";
selectedCompany.value = "All";
selectedChannel.value = "All";
sortMode.value = "name";
}
const filteredContacts = computed(() => {
const query = contactSearch.value.trim().toLowerCase();
const data = contacts.value.filter((contact) => {
if (selectedCountry.value !== "All" && contact.country !== selectedCountry.value) return false;
if (selectedLocation.value !== "All" && contact.location !== selectedLocation.value) return false;
if (selectedCompany.value !== "All" && contact.company !== selectedCompany.value) return false;
if (selectedChannel.value !== "All" && !contact.channels.includes(selectedChannel.value)) return false;
if (query) {
const haystack = [contact.name, contact.company, contact.country, contact.location, contact.description, contact.channels.join(" ")]
.join(" ")
.toLowerCase();
if (!haystack.includes(query)) return false;
}
return true;
});
return data.sort((a, b) => {
if (sortMode.value === "lastContact") {
return b.lastContactAt.localeCompare(a.lastContactAt);
}
return a.name.localeCompare(b.name);
});
});
const groupedContacts = computed(() => {
if (sortMode.value === "lastContact") {
return [["Recent", filteredContacts.value]] as [string, Contact[]][];
}
const map = new Map<string, Contact[]>();
for (const contact of filteredContacts.value) {
const key = contact.name[0].toUpperCase();
if (!map.has(key)) {
map.set(key, []);
}
map.get(key)?.push(contact);
}
return [...map.entries()].sort((a, b) => a[0].localeCompare(b[0]));
});
const selectedContactId = ref(contacts.value[0]?.id ?? "");
watchEffect(() => {
if (!filteredContacts.value.length) {
selectedContactId.value = "";
return;
}
if (!filteredContacts.value.some((item) => item.id === selectedContactId.value)) {
selectedContactId.value = filteredContacts.value[0].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)) {
selectedDocumentId.value = filteredDocuments.value[0].id;
}
});
const selectedDocument = computed(() => documents.value.find((item) => item.id === selectedDocumentId.value));
const peopleLeftMode = ref<PeopleLeftMode>("contacts");
const peopleListMode = ref<"contacts" | "deals">("contacts");
const peopleSearch = ref("");
const peopleSortMode = ref<PeopleSortMode>("lastContact");
const peopleSortOptions: Array<{ value: PeopleSortMode; label: string }> = [
{ value: "lastContact", label: "Last contact" },
{ value: "name", label: "Name" },
{ value: "company", label: "Company" },
{ value: "country", label: "Country" },
];
const selectedDealId = ref(deals.value[0]?.id ?? "");
const 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)) {
selectedCommThreadId.value = commThreads.value[0].id;
}
});
const selectedCommThread = computed(() =>
commThreads.value.find((thread) => thread.id === selectedCommThreadId.value),
);
const selectedCommChannel = ref<"All" | CommItem["channel"]>("All");
const rightSidebarMode = ref<"summary" | "pinned">("summary");
watch(selectedCommThreadId, () => {
selectedCommChannel.value = "All";
rightSidebarMode.value = "summary";
});
const commChannelTabs = computed(() => {
if (!selectedCommThread.value) return ["All"] as ("All" | CommItem["channel"])[];
return ["All", ...selectedCommThread.value.channels] as ("All" | CommItem["channel"])[];
});
const visibleThreadItems = computed(() => {
if (!selectedCommThread.value) return [];
if (selectedCommChannel.value === "All") return selectedCommThread.value.items;
return selectedCommThread.value.items.filter((item) => item.channel === selectedCommChannel.value);
});
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 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));
if (selectedCommChannel.value !== "All") {
return messageRows;
}
const centeredRows: Array<
| {
id: string;
at: string;
kind: "eventAlert";
event: CalendarEvent;
}
| {
id: string;
at: string;
kind: "recommendation";
card: FeedCard;
}
> = [];
if (selectedCommUrgentEvent.value) {
centeredRows.push({
id: `event-alert-${selectedCommUrgentEvent.value.id}`,
at: selectedCommUrgentEvent.value.start,
kind: "eventAlert",
event: selectedCommUrgentEvent.value,
});
}
if (selectedThreadRecommendation.value) {
centeredRows.push({
id: `rec-${selectedThreadRecommendation.value.id}`,
at: selectedThreadRecommendation.value.at,
kind: "recommendation",
card: selectedThreadRecommendation.value,
});
}
return [...messageRows, ...centeredRows];
});
const selectedCommPins = computed(() => {
if (!selectedCommThread.value) return [];
return commPins.value.filter((item) => item.contact === selectedCommThread.value?.contact);
});
const selectedCommEvents = computed(() => {
if (!selectedCommThread.value) return [];
const now = new Date();
return sortedEvents.value
.filter((event) => event.contact === selectedCommThread.value?.contact)
.filter((event) => new Date(event.end) >= now)
.slice(0, 6);
});
const selectedCommUrgentEvent = computed(() => {
if (!selectedCommThread.value) return null;
const now = Date.now();
const inFifteenMinutes = now + 15 * 60 * 1000;
const event = sortedEvents.value
.filter((item) => item.contact === selectedCommThread.value?.contact)
.filter((item) => {
const start = new Date(item.start).getTime();
return start >= now && start <= inFifteenMinutes;
})
.sort((a, b) => a.start.localeCompare(b.start))[0];
return event ?? null;
});
const selectedCommPinnedStream = computed(() => {
const pins = selectedCommPins.value.map((pin) => ({
id: `pin-${pin.id}`,
kind: "pin" as const,
text: pin.text,
}));
const events = selectedCommEvents.value.map((event) => ({
id: `event-${event.id}`,
kind: "event" as const,
event,
}));
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 latestPinnedItem.value.text;
return `${latestPinnedItem.value.event.title} · ${formatDay(latestPinnedItem.value.event.start)}`;
});
const selectedWorkspaceContact = computed(() => {
if (selectedContact.value) return selectedContact.value;
if (!selectedCommThread.value) return null;
return contacts.value.find((contact) => contact.name === selectedCommThread.value?.contact) ?? null;
});
const selectedWorkspaceDeal = computed(() => {
if (selectedWorkspaceContact.value) {
const linked = deals.value.find((deal) => deal.contact === selectedWorkspaceContact.value?.name);
if (linked) return linked;
}
return deals.value.find((deal) => deal.id === selectedDealId.value) ?? null;
});
const openCallTranscripts = ref<Record<string, boolean>>({});
function toggleCallTranscript(itemId: string) {
openCallTranscripts.value[itemId] = !openCallTranscripts.value[itemId];
}
function isCallTranscriptOpen(itemId: string) {
return Boolean(openCallTranscripts.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 openEventFromCommunication(event: CalendarEvent) {
selectedTab.value = "communications";
peopleLeftMode.value = "calendar";
pickDate(event.start.slice(0, 10));
}
function channelIcon(channel: "All" | CommItem["channel"]) {
if (channel === "All") return "all";
if (channel === "Telegram") return "telegram";
if (channel === "WhatsApp") return "whatsapp";
if (channel === "Instagram") return "instagram";
if (channel === "Email") return "email";
return "phone";
}
function makeId(prefix: string) {
return `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
}
function pushPilotNote(text: string) {
// Fire-and-forget: log assistant note to the same conversation.
gqlFetch<{ logPilotNote: { ok: boolean } }>(logPilotNoteMutation, { text })
.then(() => Promise.all([loadPilotMessages(), loadChatConversations()]))
.catch(() => {});
}
function openCommunicationThread(contact: string) {
selectedTab.value = "communications";
peopleLeftMode.value = "contacts";
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;
openCommunicationThread(deal.contact);
}
function openThreadFromCalendarItem(event: CalendarEvent) {
openCommunicationThread(event.contact);
selectedCommChannel.value = "All";
}
function openEventFromContact(event: CalendarEvent) {
selectedTab.value = "communications";
peopleLeftMode.value = "calendar";
pickDate(event.start.slice(0, 10));
}
function openMessageFromContact(channel: CommItem["channel"]) {
if (!selectedContact.value) return;
openCommunicationThread(selectedContact.value.name);
selectedCommChannel.value = channel;
}
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.",
status: "planned",
},
});
calendarEvents.value = [res.createCalendarEvent, ...calendarEvents.value];
selectedDateKey.value = dayKey(start);
calendarCursor.value = new Date(start.getFullYear(), start.getMonth(), 1);
selectedTab.value = "communications";
peopleLeftMode.value = "calendar";
return `Event created: Follow-up · ${formatDay(start.toISOString())} ${formatTime(start.toISOString())} · ${card.contact}`;
}
if (key === "open_comm") {
openCommunicationThread(card.contact);
return `Opened ${card.contact} communication thread.`;
}
if (key === "call") {
await gqlFetch<{ createCommunication: { ok: boolean; id: string } }>(createCommunicationMutation, {
input: {
contact: card.contact,
channel: "Phone",
kind: "call",
direction: "out",
text: "Call started from feed",
durationSec: 0,
},
});
await refreshCrmData();
openCommunicationThread(card.contact);
return `Call event created and ${card.contact} chat opened.`;
}
if (key === "draft_message") {
await gqlFetch<{ createCommunication: { ok: boolean; id: string } }>(createCommunicationMutation, {
input: {
contact: card.contact,
channel: "Email",
kind: "message",
direction: "out",
text: "Draft: onboarding plan + two slots for tomorrow.",
},
});
await refreshCrmData();
openCommunicationThread(card.contact);
return `Draft message added to ${card.contact} communications.`;
}
if (key === "run_summary") {
return "Call summary prepared: 5 next steps sent to Pilot.";
}
if (key === "prepare_question") {
await gqlFetch<{ createCommunication: { ok: boolean; id: string } }>(createCommunicationMutation, {
input: {
contact: card.contact,
channel: "Telegram",
kind: "message",
direction: "out",
text: "Draft: can you confirm your decision date for this cycle?",
},
});
await refreshCrmData();
openCommunicationThread(card.contact);
return `Question about decision date added to ${card.contact} chat.`;
}
return "Action completed.";
}
async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected") {
card.decision = decision;
if (decision === "rejected") {
const note = "Rejected. Nothing created.";
card.decisionNote = note;
await gqlFetch<{ updateFeedDecision: { ok: boolean; id: string } }>(updateFeedDecisionMutation, {
id: card.id,
decision: "rejected",
decisionNote: note,
});
pushPilotNote(`[${card.contact}] recommendation rejected: ${card.proposal.title}`);
return;
}
const result = await executeFeedAction(card);
card.decisionNote = result;
await gqlFetch<{ updateFeedDecision: { ok: boolean; id: string } }>(updateFeedDecisionMutation, {
id: card.id,
decision: "accepted",
decisionNote: result,
});
pushPilotNote(`[${card.contact}] ${result}`);
}
</script>
<template>
<div class="min-h-screen p-3 md:p-5">
<div v-if="!authMe" class="flex min-h-[calc(100vh-2.5rem)] items-center justify-center">
<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 gap-3 lg:grid-cols-12 lg:gap-4">
<aside class="pilot-shell card min-h-0 border border-base-300 shadow-sm lg:col-span-3 lg:sticky lg:top-5 lg:h-[calc(100vh-2.5rem)]">
<div class="card-body h-full min-h-0 p-0">
<div class="pilot-header">
<div>
<h2 class="text-sm font-semibold uppercase tracking-wide text-white/75">Pilot Chat</h2>
<p class="mt-1 text-xs text-white/60">
{{ authMe.team.name }} · {{ authMe.user.name }} · {{ authMe.conversation.title }}
</p>
</div>
<button class="btn btn-ghost btn-xs text-white/80 hover:bg-white/10" @click="logout">Logout</button>
</div>
<div class="pilot-threads">
<div class="pilot-threads-head">
<span class="text-[11px] uppercase tracking-wide text-white/60">Thread</span>
<button class="btn btn-xs border-0 bg-[#5865f2] text-white hover:bg-[#4752c4]" :disabled="chatCreating" @click="createNewChatConversation">
{{ chatCreating ? "..." : "New chat" }}
</button>
</div>
<div class="pilot-thread-current">
<p class="truncate text-xs font-semibold text-white/90">{{ activeChatConversation?.title || "Current chat" }}</p>
<p class="truncate text-[11px] text-white/55">{{ activeChatConversation?.lastMessageText || "No messages yet" }}</p>
</div>
<div class="mt-2">
<select
v-model="selectedChatId"
class="select select-sm w-full border-white/15 bg-[#1e2230] text-white"
:disabled="chatSwitching || chatThreadsLoading || chatConversations.length === 0"
@change="switchChatConversation(selectedChatId)"
>
<option v-for="thread in chatConversations" :key="`thread-option-${thread.id}`" :value="thread.id">
{{ thread.title }}
</option>
</select>
<p v-if="chatThreadsLoading" class="mt-1 text-[11px] text-white/50">Loading threads...</p>
</div>
</div>
<div class="pilot-timeline min-h-0 flex-1 overflow-y-auto">
<div
v-for="message in pilotTimeline"
: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>
<span v-if="message.pending" class="pilot-live-dot">Live</span>
</div>
<div class="pilot-message-text">
{{ message.text }}
</div>
<div
v-if="message.role !== 'user' && ((message.thinking && message.thinking.length) || (message.toolRuns && message.toolRuns.length) || (message.tools && message.tools.length))"
class="pilot-debug mt-2"
>
<div v-if="message.thinking && message.thinking.length" class="pilot-debug-block">
<p class="pilot-debug-title">Thinking</p>
<ol class="pilot-debug-list">
<li v-for="(step, idx) in message.thinking" :key="`thinking-${message.id}-${idx}`">{{ step }}</li>
</ol>
</div>
<div v-if="message.toolRuns && message.toolRuns.length" class="pilot-debug-block">
<p class="pilot-debug-title">Tool Runs</p>
<ul class="pilot-tool-list">
<li v-for="(run, idx) in message.toolRuns" :key="`toolrun-${message.id}-${idx}`" class="pilot-tool-row">
<div class="pilot-tool-head">
<code>{{ run.name }}</code>
<span :class="run.status === 'error' ? 'pilot-tool-status-error' : 'pilot-tool-status-ok'">{{ run.status }}</span>
</div>
<p class="pilot-tool-io"><strong>Input:</strong> {{ run.input }}</p>
<p class="pilot-tool-io"><strong>Output:</strong> {{ run.output }}</p>
</li>
</ul>
</div>
<div v-else-if="message.tools && message.tools.length" class="pilot-debug-block">
<p class="pilot-debug-title">Tools</p>
<ul class="pilot-debug-list">
<li v-for="(tool, idx) in message.tools" :key="`tools-${message.id}-${idx}`">{{ tool }}</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<div class="pilot-input-wrap">
<input
v-model="pilotInput"
type="text"
class="input input-sm w-full border-white/15 bg-[#1e2230] text-white placeholder:text-white/40"
placeholder="Type a message for Pilot..."
@keyup.enter="sendPilotMessage"
>
<button class="btn btn-sm border-0 bg-[#5865f2] text-white hover:bg-[#4752c4]" :disabled="pilotSending" @click="sendPilotMessage">
{{ pilotSending ? "..." : "Send" }}
</button>
</div>
</div>
</aside>
<main class="card border border-base-300 bg-base-100 shadow-sm lg:col-span-9">
<div class="card-body flex flex-col p-3 pb-20 md:p-4 md:pb-24">
<div class="flex-1">
<section v-if="selectedTab === 'communications' && peopleLeftMode === 'calendar'" class="space-y-3">
<div class="mb-1 flex justify-end">
<div class="join">
<button class="btn btn-sm join-item btn-ghost" @click="peopleLeftMode = 'contacts'">Contacts</button>
<button class="btn btn-sm join-item btn-primary" @click="peopleLeftMode = 'calendar'">Calendar</button>
</div>
</div>
<div class="grid grid-cols-[auto_1fr_auto] items-center gap-2">
<div class="flex items-center gap-1">
<button class="btn btn-xs" @click="setToday">Today</button>
<button class="btn btn-xs btn-ghost" @click="shiftCalendar(-1)"></button>
<button class="btn btn-xs btn-ghost" @click="shiftCalendar(1)"></button>
</div>
<div class="text-center text-sm font-medium">
{{ calendarPeriodLabel }}
</div>
<div class="justify-self-end">
<select v-model="calendarView" class="select select-bordered select-xs w-36">
<option
v-for="option in calendarViewOptions"
:key="`calendar-view-${option.value}`"
:value="option.value"
>
{{ option.label }}
</option>
</select>
</div>
</div>
<div v-if="calendarView === 'month'" class="space-y-1">
<div class="grid grid-cols-7 gap-1 text-center text-xs font-semibold text-base-content/60">
<span>Sun</span>
<span>Mon</span>
<span>Tue</span>
<span>Wed</span>
<span>Thu</span>
<span>Fri</span>
<span>Sat</span>
</div>
<div class="grid grid-cols-7 gap-1">
<button
v-for="cell in monthCells"
:key="cell.key"
class="min-h-24 rounded-lg border p-1 text-left"
:class="[
cell.inMonth ? 'border-base-300 bg-base-100' : 'border-base-200 bg-base-200/40 text-base-content/40',
selectedDateKey === cell.key ? 'border-primary bg-primary/5' : '',
]"
@click="pickDate(cell.key)"
>
<p class="mb-1 text-xs font-semibold">{{ cell.day }}</p>
<p v-for="event in cell.events.slice(0, 2)" :key="event.id" class="truncate text-[10px] text-base-content/70">
{{ formatTime(event.start) }} {{ event.title }}
</p>
</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">
<p v-for="event in day.events" :key="event.id" class="rounded bg-base-200 px-2 py-1 text-xs">
{{ formatTime(event.start) }} - {{ event.title }} ({{ event.contact }})
</p>
<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">
<article v-for="event in selectedDayEvents" :key="event.id" class="rounded-xl border border-base-300 p-3">
<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>
</article>
<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>
<p v-if="item.first" class="mt-1 text-xs text-base-content/70">
{{ formatDay(item.first.start) }} · {{ item.first.title }}
</p>
</button>
</div>
<div v-else class="space-y-2">
<article v-for="event in sortedEvents" :key="event.id" class="rounded-xl border border-base-300 p-3">
<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>
</article>
</div>
</section>
<section v-else-if="selectedTab === 'communications' && false" class="space-y-3">
<div class="mb-1 flex justify-end">
<div class="join">
<button class="btn btn-sm join-item btn-primary" @click="peopleLeftMode = 'contacts'">Contacts</button>
<button class="btn btn-sm join-item btn-ghost" @click="peopleLeftMode = 'calendar'">Calendar</button>
</div>
</div>
<div class="rounded-xl border border-base-300 p-3">
<div class="flex flex-wrap items-center gap-2">
<input
v-model="contactSearch"
type="text"
class="input input-bordered input-md w-full flex-1"
placeholder="Search contacts..."
>
<select v-model="sortMode" class="select select-bordered select-sm w-40">
<option value="name">Sort: Name</option>
<option value="lastContact">Sort: Last contact</option>
</select>
<div class="dropdown dropdown-end">
<button tabindex="0" class="btn btn-sm btn-ghost btn-square" title="Filters">
<svg viewBox="0 0 24 24" class="h-4 w-4 fill-current">
<path d="M3 5h18v2H3zm4 6h10v2H7zm3 6h4v2h-4z" />
</svg>
</button>
<div tabindex="0" class="dropdown-content z-20 mt-2 w-72 rounded-xl border border-base-300 bg-base-100 p-3 shadow-lg">
<div class="grid gap-2">
<label class="form-control">
<span class="label-text text-xs uppercase tracking-wide text-base-content/60">Country</span>
<select v-model="selectedCountry" class="select select-bordered select-sm">
<option v-for="country in countries" :key="`country-${country}`" :value="country">{{ country }}</option>
</select>
</label>
<label class="form-control">
<span class="label-text text-xs uppercase tracking-wide text-base-content/60">Location</span>
<select v-model="selectedLocation" class="select select-bordered select-sm">
<option v-for="location in locations" :key="`location-${location}`" :value="location">{{ location }}</option>
</select>
</label>
<label class="form-control">
<span class="label-text text-xs uppercase tracking-wide text-base-content/60">Company</span>
<select v-model="selectedCompany" class="select select-bordered select-sm">
<option v-for="company in companies" :key="`company-${company}`" :value="company">{{ company }}</option>
</select>
</label>
<label class="form-control">
<span class="label-text text-xs uppercase tracking-wide text-base-content/60">Channel</span>
<select v-model="selectedChannel" class="select select-bordered select-sm">
<option v-for="channel in channels" :key="`channel-${channel}`" :value="channel">{{ channel }}</option>
</select>
</label>
</div>
<div class="mt-3 flex justify-end">
<button class="btn btn-ghost btn-sm" @click="resetContactFilters">Reset filters</button>
</div>
</div>
</div>
</div>
</div>
<div class="grid gap-3 md:grid-cols-12">
<aside class="min-h-0 rounded-xl border border-base-300 md:col-span-4">
<div class="min-h-0 space-y-3 overflow-y-auto p-2">
<article v-for="group in groupedContacts" :key="group[0]" class="space-y-2">
<div class="sticky top-0 z-10 rounded-lg bg-base-200 px-3 py-1 text-sm font-semibold">{{ group[0] }}</div>
<button
v-for="contact in group[1]"
:key="contact.id"
class="w-full rounded-xl border border-base-300 p-3 text-left transition hover:bg-base-200/60"
:class="selectedContactId === contact.id ? 'border-primary bg-primary/5' : ''"
@click="selectedContactId = contact.id"
>
<p class="font-medium">{{ contact.name }}</p>
<p class="text-xs text-base-content/60">{{ contact.company }} · {{ contact.location }}, {{ contact.country }}</p>
<p class="mt-1 text-[11px] text-base-content/55">Last contact · {{ formatStamp(contact.lastContactAt) }}</p>
</button>
</article>
</div>
</aside>
<article class="min-h-0 rounded-xl border border-base-300 md:col-span-8">
<div v-if="selectedContact" class="p-3 md:p-4">
<div class="border-b border-base-300 pb-2">
<p class="font-medium">{{ selectedContact.name }}</p>
<p class="text-xs text-base-content/60">
{{ selectedContact.company }} · {{ selectedContact.location }}, {{ selectedContact.country }}
</p>
<p class="mt-1 text-xs text-base-content/55">Last contact · {{ formatStamp(selectedContact.lastContactAt) }}</p>
</div>
<div class="mt-3">
<ContactCollaborativeEditor
:key="`contact-editor-${selectedContact.id}`"
v-model="selectedContact.description"
:room="`crm-contact-${selectedContact.id}`"
placeholder="Describe contact context and next steps..."
/>
</div>
<div class="mt-4 grid gap-3 xl:grid-cols-2">
<section class="rounded-xl border border-base-300 p-3">
<div class="mb-2 flex items-center justify-between gap-2">
<p class="text-sm font-semibold">Upcoming events</p>
</div>
<div class="space-y-2">
<button
v-for="event in selectedContactEvents"
:key="`contact-event-${event.id}`"
class="w-full rounded-lg border border-base-300 px-3 py-2 text-left transition hover:bg-base-200/50"
@click="openEventFromContact(event)"
>
<p class="text-sm font-medium">{{ event.title }}</p>
<p class="text-xs text-base-content/65">
{{ formatDay(event.start) }} · {{ formatTime(event.start) }} - {{ formatTime(event.end) }}
</p>
<p class="mt-1 text-xs text-base-content/80">{{ event.note }}</p>
</button>
<p v-if="selectedContactEvents.length === 0" class="text-xs text-base-content/55">
No linked events yet.
</p>
</div>
</section>
<section class="rounded-xl border border-base-300 p-3">
<div class="mb-2 flex items-center justify-between gap-2">
<p class="text-sm font-semibold">Recent messages</p>
<button class="btn btn-ghost btn-xs" @click="openCommunicationThread(selectedContact.name)">Open chat</button>
</div>
<div class="space-y-2">
<button
v-for="item in selectedContactRecentMessages"
:key="`contact-message-${item.id}`"
class="w-full rounded-lg border border-base-300 px-3 py-2 text-left transition hover:bg-base-200/50"
@click="openMessageFromContact(item.channel)"
>
<p class="text-sm text-base-content/90">{{ item.text }}</p>
<p class="mt-1 text-xs text-base-content/60">
<span class="mr-1 inline-flex h-4 w-4 items-center justify-center align-middle">
<svg v-if="channelIcon(item.channel) === 'telegram'" viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
<path d="M9.04 15.51 8.7 20.27c.49 0 .7-.21.96-.46l2.3-2.2 4.77 3.49c.88.49 1.5.23 1.74-.81l3.15-14.77.01-.01c.29-1.35-.49-1.88-1.35-1.56L1.74 11.08c-1.28.5-1.26 1.22-.22 1.54l4.74 1.48L17.3 7.03c.52-.34 1-.15.61.19" />
</svg>
<svg v-else-if="channelIcon(item.channel) === 'whatsapp'" viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
<path d="M20 3.99A9.94 9.94 0 0 0 12.01 1C6.49 1 2 5.49 2 11c0 1.76.46 3.49 1.33 5.03L2 23l7.17-1.88A9.95 9.95 0 0 0 12 21h.01c5.51 0 9.99-4.49 9.99-10.01 0-2.67-1.04-5.18-2.99-7m-7.99 15.32h-.01a8.3 8.3 0 0 1-4.23-1.16l-.3-.18-4.26 1.12 1.14-4.15-.2-.32a8.28 8.28 0 0 1-1.27-4.4c0-4.58 3.73-8.31 8.32-8.31a8.27 8.27 0 0 1 5.88 2.44 8.25 8.25 0 0 1 2.43 5.87c0 4.59-3.73 8.32-8.31 8.32m4.56-6.23c-.25-.12-1.49-.74-1.73-.82-.23-.09-.4-.12-.57.12s-.66.82-.81.99-.3.18-.55.06a6.7 6.7 0 0 1-1.97-1.21 7.43 7.43 0 0 1-1.38-1.71c-.14-.24-.01-.37.1-.49.11-.11.24-.3.36-.45.12-.14.16-.24.25-.39.08-.18.05-.3-.02-.42-.07-.12-.56-1.35-.77-1.85-.2-.48-.41-.41-.57-.42h-.48c-.16 0-.42.06-.64.3-.22.24-.84.82-.84 2s.86 2.31.98 2.48c.12.16 1.69 2.57 4.09 3.6.57.24 1.01.38 1.36.48.58.18 1.11.15 1.52.09.46-.06 1.49-.61 1.7-1.19.21-.58.21-1.09.15-1.19-.06-.11-.23-.17-.48-.3" />
</svg>
<svg v-else-if="channelIcon(item.channel) === 'instagram'" viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
<path d="M7 2h10a5 5 0 0 1 5 5v10a5 5 0 0 1-5 5H7a5 5 0 0 1-5-5V7a5 5 0 0 1 5-5m10 2H7a3 3 0 0 0-3 3v10a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V7a3 3 0 0 0-3-3m-5 3.5A4.5 4.5 0 1 1 7.5 12 4.5 4.5 0 0 1 12 7.5m0 2A2.5 2.5 0 1 0 14.5 12 2.5 2.5 0 0 0 12 9.5m4.8-3.2a1.2 1.2 0 1 1-1.2 1.2 1.2 1.2 0 0 1 1.2-1.2" />
</svg>
<svg v-else-if="channelIcon(item.channel) === 'email'" viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
<path d="M20 4H4a2 2 0 0 0-2 2v.4l10 5.6 10-5.6V6a2 2 0 0 0-2-2m0 4.2-7.4 4.14a1.25 1.25 0 0 1-1.2 0L4 8.2V18a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2z" />
</svg>
<svg v-else viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
<path d="M6.62 10.79a15.47 15.47 0 0 0 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1C10.07 21 3 13.93 3 5c0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.24.2 2.45.57 3.57.11.35.03.74-.25 1.02z" />
</svg>
</span>
<span>{{ formatStamp(item.at) }}</span>
</p>
</button>
<p v-if="selectedContactRecentMessages.length === 0" class="text-xs text-base-content/55">
No messages yet.
</p>
</div>
</section>
</div>
</div>
<div v-else class="flex h-full items-center justify-center text-sm text-base-content/60">
No contact selected.
</div>
</article>
</div>
</section>
<section v-else-if="selectedTab === 'communications' && peopleLeftMode === 'contacts'" class="space-y-3">
<div class="mb-1 flex justify-end">
<div class="join">
<button class="btn btn-sm join-item btn-primary" @click="peopleLeftMode = 'contacts'">Contacts</button>
<button class="btn btn-sm join-item btn-ghost" @click="peopleLeftMode = 'calendar'">Calendar</button>
</div>
</div>
<div class="grid gap-3 md:grid-cols-[220px_minmax(0,1fr)_320px]">
<aside class="min-h-0 rounded-xl border border-base-300">
<div class="sticky top-0 z-20 border-b border-base-300 bg-base-100 p-2">
<div class="mb-2 flex items-center gap-1">
<button
class="btn btn-xs flex-1"
:class="peopleListMode === 'contacts' ? 'btn-primary' : 'btn-ghost'"
@click="peopleListMode = 'contacts'"
>
Contacts
</button>
<button
class="btn btn-xs flex-1"
:class="peopleListMode === 'deals' ? 'btn-primary' : 'btn-ghost'"
@click="peopleListMode = 'deals'"
>
Deals
</button>
</div>
<div class="flex items-center gap-1">
<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 space-y-1.5 overflow-y-auto p-2">
<button
v-if="peopleListMode === 'contacts'"
v-for="thread in peopleContactList"
:key="thread.id"
class="w-full rounded-xl border border-base-300 px-2 py-2 text-left transition hover:bg-base-200/60"
:class="selectedCommThreadId === thread.id ? 'border-primary bg-primary/5' : ''"
@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 rounded-xl border border-base-300 px-2 py-2 text-left transition hover:bg-base-200/60"
:class="selectedDealId === deal.id ? 'border-primary bg-primary/5' : ''"
@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">{{ deal.nextStep }}</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>
<article class="min-h-0 rounded-xl border border-base-300">
<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">
{{ formatDay(item.first.start) }} · {{ item.first.title }}
</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 flex-col p-3">
<div class="mb-3 flex items-start justify-between gap-2 border-b border-base-300 pb-2">
<div>
<p class="font-medium">{{ selectedCommThread.contact }}</p>
<p class="text-xs text-base-content/60">{{ selectedCommThread.channels.join(" · ") }}</p>
</div>
<button class="btn btn-xs btn-outline">Call</button>
</div>
<div class="mb-3 flex items-center justify-between gap-2">
<div class="flex flex-wrap gap-2">
<button
v-for="channel in commChannelTabs"
:key="`comm-tab-${channel}`"
class="btn btn-xs btn-square"
:class="selectedCommChannel === channel ? 'btn-primary' : 'btn-ghost'"
:title="channel"
@click="selectedCommChannel = channel"
>
<svg v-if="channelIcon(channel) === 'all'" viewBox="0 0 24 24" class="h-4 w-4 fill-current">
<circle cx="7" cy="7" r="2.2" />
<circle cx="17" cy="7" r="2.2" />
<circle cx="7" cy="17" r="2.2" />
<circle cx="17" cy="17" r="2.2" />
</svg>
<svg v-else-if="channelIcon(channel) === 'telegram'" viewBox="0 0 24 24" class="h-4 w-4 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(channel) === 'whatsapp'" viewBox="0 0 24 24" class="h-4 w-4 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(channel) === 'instagram'" viewBox="0 0 24 24" class="h-4 w-4 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(channel) === 'email'" viewBox="0 0 24 24" class="h-4 w-4 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-4 w-4 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>
</button>
</div>
<button
class="btn btn-ghost btn-sm max-w-[320px] justify-start rounded-lg border border-base-300"
@click="rightSidebarMode = 'pinned'"
>
<span class="shrink-0 text-xs font-semibold">Pinned {{ selectedCommPinnedStream.length }}</span>
<span class="truncate text-xs text-base-content/70">{{ latestPinnedLabel }}</span>
</button>
</div>
<div class="space-y-2 pr-1">
<div v-for="entry in threadStreamItems" :key="entry.id">
<div v-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">
<div class="mb-2 flex items-center justify-center gap-2">
<button class="btn btn-xs btn-outline" @click="toggleCallTranscript(entry.item.id)">
<svg viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
<path d="M8 5v14l11-7z" />
</svg>
</button>
<span class="text-xs text-base-content/70">Transcript</span>
</div>
<div class="wave-bars mb-2">
<span v-for="n in 12" :key="`wave-${entry.item.id}-${n}`" :style="{ animationDelay: `${n * 0.08}s` }" />
</div>
<p class="mt-1 text-xs text-base-content/60">
<span>{{ entry.item.direction === "out" ? "↗" : "↙" }}</span>
<span class="mx-1">·</span>
<span>{{ formatStamp(entry.item.at) }}</span>
<span v-if="entry.item.duration"> · {{ entry.item.duration }}</span>
</p>
<div v-if="isCallTranscriptOpen(entry.item.id)" class="mt-2 rounded-xl border border-base-300 bg-base-100/70 p-2 text-left">
<p
v-for="(line, idx) in (entry.item.transcript ?? [entry.item.text])"
:key="`transcript-${entry.item.id}-${idx}`"
class="text-xs leading-relaxed text-base-content/80"
>
{{ line }}
</p>
</div>
</div>
</div>
<div v-else-if="entry.kind === 'eventAlert'" class="flex justify-center">
<article class="w-full max-w-[460px] rounded-xl border border-error/45 bg-error/10 p-3 text-center">
<p class="text-[11px] font-semibold uppercase tracking-wide text-error/80">Event in 15 minutes</p>
<p class="mt-1 text-sm font-semibold text-base-content">{{ entry.event.title }}</p>
<p class="mt-1 text-xs text-base-content/75">{{ formatTime(entry.event.start) }} · {{ entry.event.contact }}</p>
<button class="btn btn-xs btn-outline mt-2" @click="openEventFromCommunication(entry.event)">Open calendar</button>
</article>
</div>
<div v-else-if="entry.kind === 'recommendation'" class="flex justify-center">
<article class="w-full max-w-[460px] rounded-xl border border-base-300 bg-base-100 p-3">
<p class="text-sm">{{ entry.card.text }}</p>
<div class="mt-2 rounded-lg border border-base-300 bg-base-200/30 p-2">
<p class="text-xs font-semibold uppercase tracking-wide text-base-content/70">{{ entry.card.proposal.title }}</p>
<p
v-for="line in entry.card.proposal.details"
:key="`${entry.card.id}-${line}`"
class="mt-1 text-xs text-base-content/80"
>
{{ line }}
</p>
</div>
<div v-if="entry.card.decision === 'pending'" class="mt-2 flex gap-2">
<button class="btn btn-xs flex-1" @click="decideFeedCard(entry.card, 'accepted')">Yes</button>
<button class="btn btn-xs btn-outline flex-1" @click="decideFeedCard(entry.card, 'rejected')">No</button>
</div>
<p v-else class="mt-2 text-xs text-base-content/70">{{ entry.card.decisionNote }}</p>
</article>
</div>
<div
v-else
class="flex"
:class="entry.item.direction === 'out' ? 'justify-end' : 'justify-start'"
>
<div class="max-w-[88%] rounded-xl border border-base-300 p-3" :class="entry.item.direction === 'out' ? 'bg-base-200' : 'bg-base-100'">
<p class="text-sm">{{ entry.item.text }}</p>
<p class="mt-1 text-xs text-base-content/60">
<span class="mr-1 inline-flex h-4 w-4 items-center justify-center align-middle">
<svg v-if="channelIcon(entry.item.channel) === 'telegram'" viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
<path d="M9.04 15.51 8.7 20.27c.49 0 .7-.21.96-.46l2.3-2.2 4.77 3.49c.88.49 1.5.23 1.74-.81l3.15-14.77.01-.01c.29-1.35-.49-1.88-1.35-1.56L1.74 11.08c-1.28.5-1.26 1.22-.22 1.54l4.74 1.48L17.3 7.03c.52-.34 1-.15.61.19" />
</svg>
<svg v-else-if="channelIcon(entry.item.channel) === 'whatsapp'" viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
<path d="M20 3.99A9.94 9.94 0 0 0 12.01 1C6.49 1 2 5.49 2 11c0 1.76.46 3.49 1.33 5.03L2 23l7.17-1.88A9.95 9.95 0 0 0 12 21h.01c5.51 0 9.99-4.49 9.99-10.01 0-2.67-1.04-5.18-2.99-7m-7.99 15.32h-.01a8.3 8.3 0 0 1-4.23-1.16l-.3-.18-4.26 1.12 1.14-4.15-.2-.32a8.28 8.28 0 0 1-1.27-4.4c0-4.58 3.73-8.31 8.32-8.31a8.27 8.27 0 0 1 5.88 2.44 8.25 8.25 0 0 1 2.43 5.87c0 4.59-3.73 8.32-8.31 8.32m4.56-6.23c-.25-.12-1.49-.74-1.73-.82-.23-.09-.4-.12-.57.12s-.66.82-.81.99-.3.18-.55.06a6.7 6.7 0 0 1-1.97-1.21 7.43 7.43 0 0 1-1.38-1.71c-.14-.24-.01-.37.1-.49.11-.11.24-.3.36-.45.12-.14.16-.24.25-.39.08-.18.05-.3-.02-.42-.07-.12-.56-1.35-.77-1.85-.2-.48-.41-.41-.57-.42h-.48c-.16 0-.42.06-.64.3-.22.24-.84.82-.84 2s.86 2.31.98 2.48c.12.16 1.69 2.57 4.09 3.6.57.24 1.01.38 1.36.48.58.18 1.11.15 1.52.09.46-.06 1.49-.61 1.7-1.19.21-.58.21-1.09.15-1.19-.06-.11-.23-.17-.48-.3" />
</svg>
<svg v-else-if="channelIcon(entry.item.channel) === 'instagram'" viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
<path d="M7 2h10a5 5 0 0 1 5 5v10a5 5 0 0 1-5 5H7a5 5 0 0 1-5-5V7a5 5 0 0 1 5-5m10 2H7a3 3 0 0 0-3 3v10a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3V7a3 3 0 0 0-3-3m-5 3.5A4.5 4.5 0 1 1 7.5 12 4.5 4.5 0 0 1 12 7.5m0 2A2.5 2.5 0 1 0 14.5 12 2.5 2.5 0 0 0 12 9.5m4.8-3.2a1.2 1.2 0 1 1-1.2 1.2 1.2 1.2 0 0 1 1.2-1.2" />
</svg>
<svg v-else-if="channelIcon(entry.item.channel) === 'email'" viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
<path d="M20 4H4a2 2 0 0 0-2 2v.4l10 5.6 10-5.6V6a2 2 0 0 0-2-2m0 4.2-7.4 4.14a1.25 1.25 0 0 1-1.2 0L4 8.2V18a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2z" />
</svg>
<svg v-else viewBox="0 0 24 24" class="h-3.5 w-3.5 fill-current">
<path d="M6.62 10.79a15.47 15.47 0 0 0 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1C10.07 21 3 13.93 3 5c0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.24.2 2.45.57 3.57.11.35.03.74-.25 1.02z" />
</svg>
</span>
<span>{{ formatStamp(entry.item.at) }}</span>
</p>
</div>
</div>
</div>
</div>
</div>
<div v-else class="flex h-full items-center justify-center text-sm text-base-content/60">
No communication history.
</div>
</article>
<aside class="min-h-0 rounded-xl border border-base-300">
<div v-if="selectedWorkspaceContact" class="h-full p-3">
<div class="mb-3 flex items-start justify-between gap-2 border-b border-base-300 pb-2">
<div>
<p class="font-medium">{{ selectedWorkspaceContact.name }}</p>
<p class="text-xs text-base-content/60">
{{ selectedWorkspaceContact.company }} · {{ selectedWorkspaceContact.location }}, {{ selectedWorkspaceContact.country }}
</p>
<p v-if="rightSidebarMode === 'pinned'" class="mt-1 text-xs text-base-content/55">
Pinned {{ selectedCommPinnedStream.length }}
</p>
</div>
<button
v-if="rightSidebarMode === 'pinned'"
class="btn btn-ghost btn-xs btn-square"
title="Close pinned"
@click="rightSidebarMode = 'summary'"
>
×
</button>
</div>
<div v-if="rightSidebarMode === 'pinned'" class="space-y-2">
<article
v-for="item in selectedCommPinnedStream"
:key="item.id"
class="rounded-lg border border-base-300 px-3 py-2"
>
<template v-if="item.kind === 'pin'">
<p class="text-[11px] uppercase tracking-wide text-base-content/55">Note</p>
<p class="mt-1 text-xs leading-relaxed text-base-content/85">{{ item.text }}</p>
</template>
<template v-else>
<p class="text-[11px] uppercase tracking-wide text-base-content/55">Event</p>
<button class="mt-1 w-full text-left" @click="openEventFromCommunication(item.event)">
<p class="text-sm font-medium">{{ item.event.title }}</p>
<p class="text-xs text-base-content/65">
{{ formatDay(item.event.start) }} · {{ formatTime(item.event.start) }}
</p>
</button>
</template>
</article>
<p v-if="selectedCommPinnedStream.length === 0" class="text-xs text-base-content/55">
No pinned notes or events.
</p>
</div>
<template v-else>
<p class="text-xs text-base-content/55">
Last contact · {{ formatStamp(selectedWorkspaceContact.lastContactAt) }}
</p>
<div v-if="selectedWorkspaceDeal" class="mt-3 rounded-xl border border-base-300 bg-base-200/30 p-2.5">
<p class="text-[10px] font-semibold uppercase tracking-wide text-base-content/55">Deal</p>
<p class="mt-1 text-sm font-medium">{{ selectedWorkspaceDeal.title }}</p>
<p class="mt-1 text-[11px] text-base-content/65">{{ selectedWorkspaceDeal.company }} · {{ selectedWorkspaceDeal.stage }}</p>
<p class="mt-1 text-[11px] text-base-content/80">{{ selectedWorkspaceDeal.nextStep }}</p>
</div>
<div class="mt-3">
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-base-content/60">Summary</p>
<ContactCollaborativeEditor
:key="`contact-summary-${selectedWorkspaceContact.id}`"
v-model="selectedWorkspaceContact.description"
:room="`crm-contact-${selectedWorkspaceContact.id}`"
placeholder="Contact summary..."
/>
</div>
</template>
</div>
<div v-else class="flex h-full items-center justify-center text-sm text-base-content/60">
No contact selected.
</div>
</aside>
</div>
</section>
<section v-else-if="selectedTab === 'documents'" class="space-y-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 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-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 rounded-xl border border-base-300 md:col-span-8">
<div v-if="selectedDocument" class="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">
<ContactCollaborativeEditor
:key="`doc-editor-${selectedDocument.id}`"
v-model="selectedDocument.body"
:room="`crm-doc-${selectedDocument.id}`"
placeholder="Describe policy, steps, rules, and exceptions..."
/>
</div>
</div>
<div v-else class="flex h-full items-center justify-center text-sm text-base-content/60">
No document selected.
</div>
</article>
</div>
</section>
</div>
</div>
</main>
</div>
<nav class="dock-fixed pointer-events-none fixed left-1/2 z-40 -translate-x-1/2">
<div class="dock-nav pointer-events-auto inline-flex items-center gap-1 rounded-2xl border border-base-300 px-2 py-1.5">
<button
v-for="tab in tabs"
:key="`dock-${tab.id}`"
class="btn btn-ghost btn-sm btn-square"
:class="selectedTab === tab.id ? 'btn-primary' : ''"
:title="tab.label"
@click="selectedTab = tab.id"
>
<svg v-if="tab.id === 'communications'" viewBox="0 0 24 24" class="h-4 w-4 fill-current">
<path d="M20 2H4a2 2 0 0 0-2 2v18l4-4h14a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2" />
</svg>
<svg v-else-if="tab.id === 'documents'" viewBox="0 0 24 24" class="h-4 w-4 fill-current">
<path d="M6 2h9l5 5v15a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2m8 1.5V8h4.5z" />
</svg>
<svg v-else viewBox="0 0 24 24" class="h-4 w-4 fill-current">
<path d="M3 3h18v4H3zm0 7h12v4H3zm0 7h18v4H3z" />
</svg>
</button>
</div>
</nav>
</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;
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-threads-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.pilot-thread-current {
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
padding: 8px;
background: rgba(255, 255, 255, 0.02);
}
.pilot-timeline {
padding: 10px 8px;
}
.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-live-dot {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
color: #c6ceff;
border-radius: 999px;
border: 1px solid rgba(124, 144, 255, 0.55);
padding: 1px 7px;
}
.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-debug {
display: grid;
gap: 8px;
}
.pilot-debug-block {
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(9, 11, 19, 0.52);
padding: 8px;
}
.pilot-debug-title {
margin: 0 0 6px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: rgba(172, 187, 255, 0.95);
}
.pilot-debug-list {
margin: 0;
padding-left: 18px;
display: grid;
gap: 4px;
font-size: 12px;
color: rgba(255, 255, 255, 0.82);
}
.pilot-tool-list {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 8px;
}
.pilot-tool-row {
border-radius: 8px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.06);
padding: 7px;
}
.pilot-tool-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 4px;
font-size: 11px;
}
.pilot-tool-head code {
color: #cfd7ff;
}
.pilot-tool-status-ok {
color: #6be2a1;
font-weight: 700;
}
.pilot-tool-status-error {
color: #ff8f8f;
font-weight: 700;
}
.pilot-tool-io {
margin: 0;
font-size: 11px;
line-height: 1.35;
color: rgba(255, 255, 255, 0.74);
}
.pilot-input-wrap {
display: flex;
gap: 8px;
padding: 10px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(11, 13, 21, 0.65);
}
.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);
}
.dock-nav {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(255, 255, 255, 0.86));
backdrop-filter: blur(8px);
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.1);
}
.dock-fixed {
bottom: calc(0.75rem + env(safe-area-inset-bottom));
}
.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:
radial-gradient(circle at 50% 0%, rgba(59, 130, 246, 0.14), transparent 50%),
linear-gradient(180deg, rgba(59, 130, 246, 0.05), transparent);
}
.wave-bars {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 4px;
height: 30px;
}
.wave-bars span {
width: 4px;
height: 18%;
border-radius: 999px;
background: rgba(30, 107, 255, 0.75);
animation: wave-pulse 1.2s ease-in-out infinite;
}
@keyframes wave-pulse {
0%, 100% {
height: 20%;
opacity: 0.45;
}
50% {
height: 100%;
opacity: 1;
}
}
</style>