Files
clientsflow/frontend/app/composables/useContacts.ts
Ruslan Bakiev d892d0c604 refactor: distribute types from crm-types.ts to owning composables
Each composable now owns its types and exports them. Other composables
import types from the owning composable. Deleted centralized crm-types.ts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 15:21:30 +07:00

187 lines
5.2 KiB
TypeScript

import { ref, computed, watch, watchEffect, type ComputedRef } from "vue";
import { useQuery } from "@vue/apollo-composable";
import {
ContactsQueryDocument,
CommunicationsQueryDocument,
} from "~~/graphql/generated";
export type Contact = {
id: string;
name: string;
avatar: string;
channels: string[];
lastContactAt: string;
description: string;
};
export type CommItem = {
id: string;
at: string;
contact: string;
contactInboxId: string;
sourceExternalId: string;
sourceTitle: string;
channel: "Telegram" | "WhatsApp" | "Instagram" | "Phone" | "Email";
kind: "message" | "call";
direction: "in" | "out";
text: string;
audioUrl?: string;
duration?: string;
waveform?: number[];
transcript?: string[];
deliveryStatus?: "PENDING" | "SENT" | "DELIVERED" | "READ" | "FAILED" | string | null;
};
export type SortMode = "name" | "lastContact";
export function useContacts(opts: { apolloAuthReady: ComputedRef<boolean> }) {
const { result: contactsResult, refetch: refetchContacts } = useQuery(
ContactsQueryDocument,
null,
{ enabled: opts.apolloAuthReady },
);
const { result: communicationsResult, refetch: refetchCommunications } = useQuery(
CommunicationsQueryDocument,
null,
{ enabled: opts.apolloAuthReady },
);
const contacts = ref<Contact[]>([]);
const commItems = ref<CommItem[]>([]);
watch(
[() => contactsResult.value?.contacts, () => communicationsResult.value?.communications],
([rawContacts, rawComms]) => {
if (!rawContacts) return;
const contactsList = [...rawContacts] as Contact[];
const commsList = (rawComms ?? []) as CommItem[];
const byName = new Map<string, Set<string>>();
for (const item of commsList) {
if (!byName.has(item.contact)) byName.set(item.contact, new Set());
byName.get(item.contact)?.add(item.channel);
}
contacts.value = contactsList.map((c) => ({
...c,
channels: Array.from(byName.get(c.name) ?? c.channels ?? []),
}));
commItems.value = commsList;
},
{ immediate: true },
);
const contactSearch = ref("");
const selectedChannel = ref("All");
const sortMode = ref<SortMode>("name");
const channels = computed(() => ["All", ...new Set(contacts.value.flatMap((c) => c.channels))].sort());
function resetContactFilters() {
contactSearch.value = "";
selectedChannel.value = "All";
sortMode.value = "name";
}
const filteredContacts = computed(() => {
const query = contactSearch.value.trim().toLowerCase();
const data = contacts.value.filter((contact) => {
if (selectedChannel.value !== "All" && !contact.channels.includes(selectedChannel.value)) return false;
if (query) {
const haystack = [contact.name, 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)) {
const first = filteredContacts.value[0];
if (first) selectedContactId.value = first.id;
}
});
const selectedContact = computed(() => contacts.value.find((item) => item.id === selectedContactId.value));
const brokenAvatarByContactId = ref<Record<string, boolean>>({});
function contactInitials(name: string) {
const words = String(name ?? "")
.trim()
.split(/\s+/)
.filter(Boolean);
if (!words.length) return "?";
return words
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? "")
.join("");
}
function avatarSrcForThread(thread: { id: string; avatar: string }) {
if (brokenAvatarByContactId.value[thread.id]) return "";
return String(thread.avatar ?? "").trim();
}
function markAvatarBroken(contactId: string) {
if (!contactId) return;
brokenAvatarByContactId.value = {
...brokenAvatarByContactId.value,
[contactId]: true,
};
}
return {
contacts,
commItems,
contactSearch,
selectedChannel,
sortMode,
selectedContactId,
selectedContact,
filteredContacts,
groupedContacts,
channels,
resetContactFilters,
brokenAvatarByContactId,
avatarSrcForThread,
markAvatarBroken,
contactInitials,
refetchContacts,
refetchCommunications,
};
}