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>
187 lines
5.2 KiB
TypeScript
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,
|
|
};
|
|
}
|