Files
clientsflow/Frontend/app.vue
2026-02-17 21:17:25 +07:00

2243 lines
93 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">
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[]>([
{
id: "f1",
at: atOffset(0, 9, 35),
contact: "Anna Meyer",
text: "I analyzed Anna Meyer's latest activity: after a demo, the decision window is usually open for 1-2 hours. I suggest scheduling a follow-up immediately to keep momentum.",
proposal: {
title: "Add event to calendar",
details: [
"Contact: Anna Meyer",
"Start: 30 minutes from now",
"Duration: 30 minutes",
],
key: "create_followup",
},
decision: "pending",
},
{
id: "f2",
at: atOffset(0, 10, 8),
contact: "Murat Ali",
text: "I found that Murat Ali gave 3 quick replies in a row over the last hour. I suggest moving to a short call now while engagement is high.",
proposal: {
title: "Start a call and open chat",
details: [
"Contact: Murat Ali",
"Channel: Phone",
"After action: open the communication thread for this contact",
],
key: "call",
},
decision: "pending",
},
]);
const contacts = ref<Contact[]>([
{
id: "c1",
name: "Anna Meyer",
avatar: "https://randomuser.me/api/portraits/women/44.jpg",
company: "Nordline GmbH",
country: "Germany",
location: "Berlin",
channels: ["Telegram", "Phone", "Email"],
lastContactAt: atOffset(-0, 10, 20),
description: "Decision owner for procurement. Prefers short, concrete updates with clear deadlines.\n\nBest pattern: one clear question per message, then one explicit next step.\n\nRisk: if timeline question is delayed, decision date keeps slipping.",
},
{
id: "c2",
name: "Murat Ali",
avatar: "https://randomuser.me/api/portraits/men/32.jpg",
company: "Connect FZCO",
country: "UAE",
location: "Dubai",
channels: ["WhatsApp", "Instagram", "Phone", "Email"],
lastContactAt: atOffset(-1, 18, 10),
description: "High activity in chat, fast response cycle.\n\nNeeds legal path clarity and explicit owner from his side.\n\nBest move: in next call, lock legal owner and target signature date.",
},
{
id: "c3",
name: "Ilya Petroff",
avatar: "https://randomuser.me/api/portraits/men/18.jpg",
company: "Volta Tech",
country: "Armenia",
location: "Yerevan",
channels: ["Email", "Phone"],
lastContactAt: atOffset(-1, 11, 12),
description: "Early-stage lead. Interested, but wants structured onboarding before commercial details.\n\nBest move: send concise onboarding plan + 2 time slots for intro session.",
},
{
id: "c4",
name: "Carlos Rivera",
avatar: "https://randomuser.me/api/portraits/men/65.jpg",
company: "BluePort",
country: "Spain",
location: "Barcelona",
channels: ["WhatsApp", "Email"],
lastContactAt: atOffset(-3, 14, 35),
description: "Pilot interest for two teams. Focus on rollout timeline and ownership per team.\n\nBest move: propose phased pilot with weekly checkpoints.",
},
{
id: "c5",
name: "Daria Ivanova",
avatar: "https://randomuser.me/api/portraits/women/22.jpg",
company: "Skyline Trade",
country: "Kazakhstan",
location: "Almaty",
channels: ["Telegram", "Phone"],
lastContactAt: atOffset(-4, 16, 10),
description: "Commercial discussion is blocked by ROI framing.\n\nBest move: provide short ROI model with three measurable outcomes.",
},
{
id: "c6",
name: "Ethan Moore",
avatar: "https://randomuser.me/api/portraits/men/76.jpg",
company: "NorthBridge",
country: "USA",
location: "Austin",
channels: ["Email", "Phone"],
lastContactAt: atOffset(-5, 12, 45),
description: "Prefers stable weekly cadence and structured updates.\n\nBest move: fixed weekly summary + one priority ask per cycle.",
},
]);
const calendarEvents = ref<CalendarEvent[]>([
{
id: "e1",
title: "Anna follow-up",
start: inMinutes(12),
end: endAfter(inMinutes(12), 30),
contact: "Anna Meyer",
note: "Confirm decision date and next owner.",
},
{
id: "e2",
title: "Murat contract call",
start: atOffset(2, 16, 30),
end: endAfter(atOffset(2, 16, 30), 30),
contact: "Murat Ali",
note: "Lock legal owner and signing target.",
},
{
id: "e3",
title: "Ilya discovery",
start: atOffset(3, 11, 0),
end: endAfter(atOffset(3, 11, 0), 30),
contact: "Ilya Petroff",
note: "Qualify onboarding readiness.",
},
{
id: "e4",
title: "Internal sync",
start: atOffset(0, 9, 0),
end: endAfter(atOffset(0, 9, 0), 30),
contact: "Team",
note: "Align next actions across accounts.",
},
{
id: "e5",
title: "BluePort intro",
start: atOffset(4, 13, 0),
end: endAfter(atOffset(4, 13, 0), 30),
contact: "Carlos Rivera",
note: "Check pilot scope and timeline.",
},
]);
const commItems = ref<CommItem[]>([
{
id: "m1",
at: atOffset(0, 9, 20),
contact: "Anna Meyer",
channel: "Telegram",
kind: "message",
direction: "in",
text: "Can you share final pricing today?",
},
{
id: "m2",
at: atOffset(0, 9, 22),
contact: "Anna Meyer",
channel: "Telegram",
kind: "message",
direction: "out",
text: "Yes, sending after demo.",
},
{
id: "m3",
at: atOffset(0, 10, 0),
contact: "Anna Meyer",
channel: "Phone",
kind: "call",
direction: "out",
text: "Quick sync call",
duration: "14m",
transcript: [
"Anna: We can review pricing today, but I need one clear next step.",
"You: Agreed. I will send the final version right after this call.",
"Anna: Perfect. Add a follow-up tomorrow morning.",
],
},
{
id: "m4",
at: atOffset(-1, 15, 5),
contact: "Murat Ali",
channel: "WhatsApp",
kind: "message",
direction: "in",
text: "Need to shift call by one day.",
},
{
id: "m5",
at: atOffset(-1, 15, 8),
contact: "Murat Ali",
channel: "WhatsApp",
kind: "message",
direction: "out",
text: "Works. Let us do 12:00 tomorrow.",
},
{
id: "m6",
at: atOffset(-1, 18, 10),
contact: "Murat Ali",
channel: "Phone",
kind: "call",
direction: "in",
text: "Contract call",
duration: "9m",
transcript: [
"Murat: We can move forward if legal owner is confirmed today.",
"You: Understood. I will lock owner and send the timeline.",
"Murat: Then we target signature this week.",
],
},
{
id: "m9",
at: atOffset(-1, 13, 40),
contact: "Murat Ali",
channel: "Instagram",
kind: "message",
direction: "in",
text: "Sent details in DM, can we align tomorrow?",
},
{
id: "m7",
at: atOffset(-1, 11, 12),
contact: "Ilya Petroff",
channel: "Email",
kind: "message",
direction: "in",
text: "Interested in pilot, what are next steps?",
},
{
id: "m8",
at: atOffset(-1, 12, 0),
contact: "Ilya Petroff",
channel: "Phone",
kind: "call",
direction: "out",
text: "Missed callback",
duration: "-",
transcript: [
"No full transcript available. Call was not connected.",
],
},
]);
const commPins = ref<CommPin[]>([
{
id: "cp-1",
contact: "Anna Meyer",
text: "First lock the decision date, then send the final offer.",
},
{
id: "cp-2",
contact: "Anna Meyer",
text: "A short follow-up is needed no later than 30 minutes after the demo.",
},
{
id: "cp-3",
contact: "Murat Ali",
text: "In every update, confirm the legal owner on the client side.",
},
{
id: "cp-4",
contact: "Ilya Petroff",
text: "Work through a structured onboarding plan, not pricing first.",
},
]);
const deals = ref<Deal[]>([
{
id: "d1",
contact: "Anna Meyer",
title: "Nordline pilot",
company: "Nordline GmbH",
stage: "Negotiation",
amount: "$28,000",
nextStep: "Send post-demo offer within 30 minutes",
summary: "Active thread, fast feedback, high close probability this week.",
},
{
id: "d2",
contact: "Murat Ali",
title: "Connect annual",
company: "Connect FZCO",
stage: "Legal review",
amount: "$74,000",
nextStep: "Lock legal owner and expected sign date",
summary: "Strong engagement in chat and calls, pending legal alignment.",
},
{
id: "d3",
contact: "Ilya Petroff",
title: "Volta onboarding",
company: "Volta Tech",
stage: "Discovery",
amount: "$12,000",
nextStep: "Offer two onboarding slots for tomorrow",
summary: "Early stage interest, needs concise onboarding plan.",
},
]);
const documents = ref<WorkspaceDocument[]>([
{
id: "doc-1",
title: "Outbound cadence v1",
type: "Regulation",
owner: "Revenue Ops",
scope: "All B2B accounts",
updatedAt: atOffset(-1, 10, 0),
summary: "Unified sequence for first touch, follow-up, and qualification.",
body: "## Goal\nMove a new contact to the first qualified call within 5 business days.\n\n## Base sequence\n- Day 0: first message in the primary channel.\n- Day 1: short follow-up with one clear ask.\n- Day 3: second follow-up + alternate channel.\n- Day 5: final ping and move to \"later\".\n\n## Rules\n- Always keep one explicit next step in each message.\n- Avoid long text walls.\n- After each reply, update context in the contact card.",
},
{
id: "doc-2",
title: "Discovery call playbook",
type: "Playbook",
owner: "Sales Lead",
scope: "Discovery calls",
updatedAt: atOffset(-2, 12, 15),
summary: "Call structure, mandatory questions, and outcome logging format.",
body: "## Structure\n1. Context check (2 min)\n2. Current pain (8 min)\n3. Success criteria (6 min)\n4. Next step lock (4 min)\n\n## Mandatory outcomes\n- Confirmed business owner\n- Confirmed decision timeline\n- Confirmed next meeting date\n\n## Notes format\nAlways log: pain, impact, owner, ETA, risks.",
},
{
id: "doc-3",
title: "AI assistant operating policy",
type: "Policy",
owner: "Founders",
scope: "AI recommendations and automations",
updatedAt: atOffset(-3, 9, 40),
summary: "What actions AI can suggest and what requires explicit approval.",
body: "## Allowed without approval\n- Draft message suggestions\n- Calendar proposal suggestions\n- Conversation summaries\n\n## Requires explicit approval\n- Sending a message to an external contact\n- Creating a calendar event\n- Changing deal stage\n\n## Logging\nEvery AI action must leave a short trace in the feed.",
},
{
id: "doc-4",
title: "Post-call follow-up template",
type: "Template",
owner: "Enablement",
scope: "Any completed client call",
updatedAt: atOffset(-4, 16, 20),
summary: "Template for short post-call follow-up with aligned actions.",
body: "## Message template\nThanks for the call. Summary below:\n- What we aligned on\n- What remains open\n- Owner per action\n- Exact date for next sync\n\n## Quality bar\nThe client should understand the next step within 10 seconds.",
},
]);
const pilotMessages = ref([
{ id: "p1", role: "assistant", text: "I monitor calendar, contacts, and communications in one flow." },
{ id: "p2", role: "user", text: "What is critical to do today?" },
{ id: "p3", role: "assistant", text: "First Anna after demo, then Murat on legal owner." },
]);
const pilotInput = ref("");
function sendPilotMessage() {
const text = pilotInput.value.trim();
if (!text) return;
pilotMessages.value.push({ id: `p-${Date.now()}`, role: "user", text });
pilotMessages.value.push({
id: `p-${Date.now() + 1}`,
role: "assistant",
text: "Got it. Added to context and will use it in the next recommendations.",
});
pilotInput.value = "";
}
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 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 showCommPinsOverlay = ref(false);
const selectedPinnedIndex = ref(0);
watch(selectedCommThreadId, () => {
selectedCommChannel.value = "All";
showCommPinsOverlay.value = false;
selectedPinnedIndex.value = 0;
});
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 currentPinnedItem = computed(() => {
if (!selectedCommPinnedStream.value.length) return null;
const index = Math.max(0, Math.min(selectedPinnedIndex.value, selectedCommPinnedStream.value.length - 1));
return selectedCommPinnedStream.value[index];
});
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 shiftPinned(direction: -1 | 1) {
if (!selectedCommPinnedStream.value.length) return;
const total = selectedCommPinnedStream.value.length;
selectedPinnedIndex.value = (selectedPinnedIndex.value + direction + total) % total;
}
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) {
showCommPinsOverlay.value = false;
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) {
pilotMessages.value.push({ id: makeId("p"), role: "assistant", text });
}
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;
}
function executeFeedAction(key: FeedCard["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);
calendarEvents.value.unshift({
id: makeId("e"),
title: "Follow-up: Anna",
start: start.toISOString(),
end: end.toISOString(),
contact: "Anna Meyer",
note: "Created from feed action.",
});
selectedDateKey.value = dayKey(start);
calendarCursor.value = new Date(start.getFullYear(), start.getMonth(), 1);
selectedTab.value = "communications";
peopleLeftMode.value = "calendar";
return `Event created: Follow-up: Anna · ${formatDay(start.toISOString())} ${formatTime(start.toISOString())}`;
}
if (key === "open_comm") {
openCommunicationThread("Murat Ali");
return "Opened Murat Ali communication thread.";
}
if (key === "call") {
commItems.value.push({
id: makeId("m"),
at: new Date().toISOString(),
contact: "Murat Ali",
channel: "Phone",
kind: "call",
direction: "out",
text: "Call started from feed",
duration: "00:00",
});
openCommunicationThread("Murat Ali");
return "Call event created and Murat Ali chat opened.";
}
if (key === "draft_message") {
commItems.value.push({
id: makeId("m"),
at: new Date().toISOString(),
contact: "Ilya Petroff",
channel: "Email",
kind: "message",
direction: "out",
text: "Draft: onboarding plan + two slots for tomorrow.",
});
openCommunicationThread("Ilya Petroff");
return "Draft message added to Ilya Petroff communications.";
}
if (key === "run_summary") {
return "Call summary prepared: 5 next steps sent to Pilot.";
}
if (key === "prepare_question") {
commItems.value.push({
id: makeId("m"),
at: new Date().toISOString(),
contact: "Anna Meyer",
channel: "Telegram",
kind: "message",
direction: "out",
text: "Draft: can you confirm your decision date for this cycle?",
});
openCommunicationThread("Anna Meyer");
return "Question about decision date added to Anna Meyer chat.";
}
return "Action completed.";
}
function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected") {
card.decision = decision;
if (decision === "rejected") {
card.decisionNote = "Rejected. Nothing created.";
pushPilotNote(`[${card.contact}] recommendation rejected: ${card.proposal.title}`);
return;
}
const result = executeFeedAction(card.proposal.key);
card.decisionNote = result;
pushPilotNote(`[${card.contact}] ${result}`);
}
</script>
<template>
<div class="min-h-screen p-3 md:p-5">
<div class="grid gap-3 lg:grid-cols-12 lg:gap-4">
<aside class="card min-h-0 border border-base-300 bg-base-100 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-3 md:p-4">
<div class="mb-2">
<h2 class="text-sm font-semibold uppercase tracking-wide text-base-content/70">Pilot</h2>
</div>
<div class="min-h-0 flex-1 space-y-2 overflow-y-auto rounded-xl border border-base-300 p-2">
<div
v-for="message in pilotMessages"
:key="message.id"
class="chat"
:class="message.role === 'assistant' ? 'chat-start' : 'chat-end'"
>
<div class="chat-bubble text-sm" :class="message.role === 'assistant' ? 'chat-bubble-neutral' : ''">
{{ message.text }}
</div>
</div>
</div>
<div class="mt-2 flex gap-2">
<input
v-model="pilotInput"
type="text"
class="input input-bordered input-sm w-full"
placeholder="Write a message..."
@keyup.enter="sendPilotMessage"
>
<button class="btn btn-sm" @click="sendPilotMessage">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>
<input
v-model="peopleSearch"
type="text"
class="input input-bordered input-sm w-full"
:placeholder="peopleListMode === 'contacts' ? 'Search contacts' : 'Search deals'"
>
</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>
<div class="flex min-w-0 items-center gap-1 rounded-lg border border-base-300 bg-base-100 px-1 py-1">
<button class="btn btn-ghost btn-xs btn-square" title="Previous pinned" @click="shiftPinned(-1)"></button>
<div class="min-w-0 px-1 text-xs">
<p v-if="currentPinnedItem?.kind === 'pin'" class="truncate text-base-content/85">
{{ currentPinnedItem.text }}
</p>
<p v-else-if="currentPinnedItem?.kind === 'event'" class="truncate text-base-content/85">
{{ currentPinnedItem.event.title }}
</p>
<p v-else class="truncate text-base-content/50">No pinned yet</p>
</div>
<button class="btn btn-ghost btn-xs btn-square" title="Next pinned" @click="shiftPinned(1)"></button>
<button class="btn btn-ghost btn-xs btn-square" title="Pinned and events" @click="showCommPinsOverlay = !showCommPinsOverlay"></button>
<button class="btn btn-ghost btn-xs" @click="showCommPinsOverlay = !showCommPinsOverlay">Expand</button>
</div>
</div>
<div
v-if="showCommPinsOverlay"
class="absolute inset-0 z-20 bg-base-100/55 backdrop-blur-[1px]"
@click="showCommPinsOverlay = false"
>
<aside
class="absolute right-3 top-16 w-[340px] rounded-xl border border-base-300 bg-base-100 shadow-xl"
@click.stop
>
<div class="flex items-center justify-between border-b border-base-300 px-3 py-2">
<p class="text-xs font-semibold uppercase tracking-wide text-base-content/60">Pinned</p>
<button class="btn btn-ghost btn-xs btn-square" @click="showCommPinsOverlay = false">×</button>
</div>
<div class="max-h-[66vh] space-y-1 overflow-y-auto p-2">
<article
v-for="(item, index) in selectedCommPinnedStream"
:key="item.id"
class="rounded-lg border border-base-300 px-2.5 py-2"
:class="selectedPinnedIndex === index ? 'border-primary bg-primary/5' : ''"
@click="selectedPinnedIndex = index"
>
<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="px-1 py-2 text-xs text-base-content/55">
No pinned notes or events.
</p>
</div>
</aside>
</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="border-b border-base-300 pb-2">
<p class="font-medium">{{ selectedWorkspaceContact.name }}</p>
<p class="text-xs text-base-content/60">
{{ selectedWorkspaceContact.company }} · {{ selectedWorkspaceContact.location }}, {{ selectedWorkspaceContact.country }}
</p>
<p class="mt-1 text-xs text-base-content/55">
Last contact · {{ formatStamp(selectedWorkspaceContact.lastContactAt) }}
</p>
</div>
<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>
</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>
</div>
</template>
<style scoped>
.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>