Refine CRM chat UX and add DB-backed pin toggle

This commit is contained in:
Ruslan Bakiev
2026-02-19 13:51:18 +07:00
parent 626d4ddd76
commit 23a4deba37
10 changed files with 173 additions and 45 deletions

View File

@@ -13,6 +13,7 @@ import chatConversationsQuery from "./graphql/operations/chat-conversations.grap
import createChatConversationMutation from "./graphql/operations/create-chat-conversation.graphql?raw";
import selectChatConversationMutation from "./graphql/operations/select-chat-conversation.graphql?raw";
import archiveChatConversationMutation from "./graphql/operations/archive-chat-conversation.graphql?raw";
import toggleContactPinMutation from "./graphql/operations/toggle-contact-pin.graphql?raw";
import confirmLatestChangeSetMutation from "./graphql/operations/confirm-latest-change-set.graphql?raw";
import rollbackLatestChangeSetMutation from "./graphql/operations/rollback-latest-change-set.graphql?raw";
import { Chat as AiChat } from "@ai-sdk/vue";
@@ -66,6 +67,7 @@ type CommItem = {
kind: "message" | "call";
direction: "in" | "out";
text: string;
audioUrl?: string;
duration?: string;
transcript?: string[];
};
@@ -283,10 +285,7 @@ let pilotWaveRecordPlugin: any = null;
let pilotWaveMicSession: { onDestroy: () => void; onEnd: () => void } | null = null;
const commCallWaveHosts = new Map<string, HTMLDivElement>();
const commCallWaveSurfers = new Map<string, any>();
const CALL_AUDIO_SAMPLE_URLS = [
"/audio-samples/national-road-9.m4a",
"/audio-samples/meeting-recording-2026-02-11.webm",
];
const FALLBACK_CALL_AUDIO_URL = "/audio-samples/national-road-9.m4a";
const callTranscriptOpen = ref<Record<string, boolean>>({});
const callTranscriptLoading = ref<Record<string, boolean>>({});
const callTranscriptText = ref<Record<string, string>>({});
@@ -330,6 +329,7 @@ const chatSwitching = ref(false);
const chatCreating = ref(false);
const chatArchivingId = ref("");
const chatThreadPickerOpen = ref(false);
const commPinToggling = ref(false);
const selectedChatId = ref("");
const loginPhone = ref("");
const loginPassword = ref("");
@@ -491,7 +491,7 @@ async function gqlFetch<TData>(query: string, variables?: Record<string, unknown
});
if (result.errors?.length) {
throw new Error(result.errors[0].message || "GraphQL request failed");
throw new Error(result.errors[0]?.message || "GraphQL request failed");
}
if (!result.data) {
@@ -739,13 +739,9 @@ function buildCallWavePeaks(item: CommItem, size = 320) {
return out;
}
function getCallAudioUrl(itemId: string) {
if (!CALL_AUDIO_SAMPLE_URLS.length) return "";
let hash = 0;
for (let i = 0; i < itemId.length; i += 1) {
hash = (hash * 33 + itemId.charCodeAt(i)) >>> 0;
}
return CALL_AUDIO_SAMPLE_URLS[hash % CALL_AUDIO_SAMPLE_URLS.length] ?? CALL_AUDIO_SAMPLE_URLS[0];
function getCallAudioUrl(item?: CommItem) {
const direct = String(item?.audioUrl ?? "").trim();
return direct || FALLBACK_CALL_AUDIO_URL;
}
async function ensureCommCallWave(itemId: string) {
@@ -755,7 +751,7 @@ async function ensureCommCallWave(itemId: string) {
const callItem = visibleThreadItems.value.find((item) => item.id === itemId && item.kind === "call");
if (!callItem) return;
const audioUrl = getCallAudioUrl(itemId);
const audioUrl = getCallAudioUrl(callItem);
const { WaveSurfer } = await loadWaveSurferModules();
const durationSeconds =
@@ -1393,7 +1389,7 @@ const groupedContacts = computed(() => {
const map = new Map<string, Contact[]>();
for (const contact of filteredContacts.value) {
const key = contact.name[0].toUpperCase();
const key = (contact.name[0] ?? "#").toUpperCase();
if (!map.has(key)) {
map.set(key, []);
}
@@ -1411,7 +1407,8 @@ watchEffect(() => {
return;
}
if (!filteredContacts.value.some((item) => item.id === selectedContactId.value)) {
selectedContactId.value = filteredContacts.value[0].id;
const first = filteredContacts.value[0];
if (first) selectedContactId.value = first.id;
}
});
@@ -1463,7 +1460,8 @@ watchEffect(() => {
}
if (!filteredDocuments.value.some((item) => item.id === selectedDocumentId.value)) {
selectedDocumentId.value = filteredDocuments.value[0].id;
const first = filteredDocuments.value[0];
if (first) selectedDocumentId.value = first.id;
}
});
@@ -1472,7 +1470,8 @@ const selectedDocument = computed(() => documents.value.find((item) => item.id =
function openPilotInstructions() {
selectedTab.value = "documents";
if (!selectedDocumentId.value && filteredDocuments.value.length) {
selectedDocumentId.value = filteredDocuments.value[0].id;
const first = filteredDocuments.value[0];
if (first) selectedDocumentId.value = first.id;
}
}
@@ -1560,7 +1559,8 @@ watchEffect(() => {
}
if (!commThreads.value.some((thread) => thread.id === selectedCommThreadId.value)) {
selectedCommThreadId.value = commThreads.value[0].id;
const first = commThreads.value[0];
if (first) selectedCommThreadId.value = first.id;
}
});
@@ -1765,6 +1765,51 @@ const latestPinnedLabel = computed(() => {
return `${latestPinnedItem.value.event.title} · ${formatDay(latestPinnedItem.value.event.start)}`;
});
function normalizePinText(value: string) {
return String(value ?? "").replace(/\s+/g, " ").trim();
}
function isPinnedText(contact: string, value: string) {
const contactName = String(contact ?? "").trim();
const text = normalizePinText(value);
if (!contactName || !text) return false;
return commPins.value.some((pin) => pin.contact === contactName && normalizePinText(pin.text) === text);
}
function entryPinText(entry: any): string {
if (!entry) return "";
if (entry.kind === "pin") return normalizePinText(entry.text ?? "");
if (entry.kind === "recommendation") return normalizePinText(entry.card?.text ?? "");
if (entry.kind === "event" || entry.kind === "eventAlert" || entry.kind === "eventLog") {
return normalizePinText(entry.event?.note || entry.event?.title || "");
}
if (entry.kind === "call") return normalizePinText(entry.item?.text || "");
return normalizePinText(entry.item?.text || "");
}
async function togglePinnedText(contact: string, value: string) {
if (commPinToggling.value) return;
const contactName = String(contact ?? "").trim();
const text = normalizePinText(value);
if (!contactName || !text) return;
commPinToggling.value = true;
try {
await gqlFetch<{ toggleContactPin: { ok: boolean; pinned: boolean } }>(toggleContactPinMutation, {
contact: contactName,
text,
});
await refreshCrmData();
} finally {
commPinToggling.value = false;
}
}
async function togglePinForEntry(entry: any) {
const contact = selectedCommThread.value?.contact ?? "";
const text = entryPinText(entry);
await togglePinnedText(contact, text);
}
const selectedWorkspaceContact = computed(() => {
const threadContactId = (selectedCommThread.value?.id ?? "").trim();
if (threadContactId) {
@@ -1880,6 +1925,11 @@ function formatDealStepMeta(step: DealStep) {
return formatDealDeadline(parsed);
}
function formatYearMonthFirst(item: { first?: CalendarEvent }) {
if (!item.first) return "";
return `${formatDay(item.first.start)} · ${item.first.title}`;
}
const selectedWorkspaceDealDueDate = computed(() => {
const deal = selectedWorkspaceDeal.value;
if (!deal) return null;
@@ -1933,7 +1983,7 @@ async function transcribeCallItem(item: CommItem) {
if (callTranscriptLoading.value[itemId]) return;
if (callTranscriptText.value[itemId]) return;
const audioUrl = getCallAudioUrl(itemId);
const audioUrl = getCallAudioUrl(item);
if (!audioUrl) {
callTranscriptError.value[itemId] = "Audio source is missing";
return;
@@ -2803,18 +2853,18 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
<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="font-medium">{{ selectedContact!.name }}</p>
<p class="text-xs text-base-content/60">
{{ selectedContact.company }} · {{ selectedContact.location }}, {{ selectedContact.country }}
{{ selectedContact!.company }} · {{ selectedContact!.location }}, {{ selectedContact!.country }}
</p>
<p class="mt-1 text-xs text-base-content/55">Last contact · {{ formatStamp(selectedContact.lastContactAt) }}</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}`"
:key="`contact-editor-${selectedContact!.id}`"
v-model="selectedContact!.description"
:room="`crm-contact-${selectedContact!.id}`"
placeholder="Describe contact context and next steps..."
/>
</div>
@@ -2846,7 +2896,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
<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>
<button class="btn btn-ghost btn-xs" @click="openCommunicationThread(selectedContact!.name)">Open chat</button>
</div>
<div class="space-y-2">
<button
@@ -3142,7 +3192,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
<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 }}
{{ formatYearMonthFirst(item) }}
</p>
</button>
</div>
@@ -3171,11 +3221,14 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
<svg viewBox="0 0 24 24" class="h-4 w-4 shrink-0 fill-current text-base-content/75">
<path d="M14 3a1 1 0 0 0-1 1v4.59l-1.7 1.7A2 2 0 0 0 10.7 12H8v2h2.7a2 2 0 0 0 .6 1.41L13 17.1V21l2-1.2v-2.7l1.7-1.7A2 2 0 0 0 17.3 14H20v-2h-2.7a2 2 0 0 0-.6-1.41L15 8.9V4a1 1 0 0 0-1-1Z" />
</svg>
<span class="min-w-0 flex-1 truncate text-xs text-base-content/80">{{ latestPinnedLabel }}</span>
<span class="shrink-0 text-[11px] text-base-content/60">{{ selectedCommPinnedStream.length }}</span>
<span class="shrink-0 text-xs text-base-content/75">{{ selectedCommPinnedStream.length }}</span>
</button>
<div v-for="entry in (commPinnedOnly ? selectedCommPinnedStream : threadStreamItems)" :key="entry.id">
<div
v-for="entry in (commPinnedOnly ? selectedCommPinnedStream : threadStreamItems)"
:key="entry.id"
@contextmenu.prevent="togglePinForEntry(entry)"
>
<div v-if="entry.kind === 'pin'" class="flex justify-center">
<article class="w-full max-w-[460px] rounded-xl border border-base-300 bg-base-100 p-3">
<p class="text-[11px] font-semibold uppercase tracking-wide text-base-content/65">Pinned note</p>