Refine CRM chat UX and add DB-backed pin toggle
This commit is contained in:
113
Frontend/app.vue
113
Frontend/app.vue
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user