refactor: decompose CrmWorkspaceApp.vue into 15 composables
Split the 6000+ line monolithic component into modular composables: - crm-types.ts: shared types and utility functions - useAuth, useContacts, useContactInboxes, useCalendar, useDeals, useDocuments, useFeed, useTimeline, usePilotChat, useCallAudio, usePins, useChangeReview, useCrmRealtime, useWorkspaceRouting CrmWorkspaceApp.vue is now a thin orchestrator (~2500 lines) that wires composables together with glue code, keeping template and styles intact. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
158
frontend/app/composables/useContacts.ts
Normal file
158
frontend/app/composables/useContacts.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { ref, computed, watch, watchEffect, type ComputedRef } from "vue";
|
||||
import { useQuery } from "@vue/apollo-composable";
|
||||
import {
|
||||
ContactsQueryDocument,
|
||||
CommunicationsQueryDocument,
|
||||
} from "~~/graphql/generated";
|
||||
import type { Contact, CommItem, SortMode } from "~/composables/crm-types";
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user