2504 lines
100 KiB
Vue
2504 lines
100 KiB
Vue
<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>
|