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>
223 lines
7.1 KiB
TypeScript
223 lines
7.1 KiB
TypeScript
import { ref, computed, watch, type ComputedRef, type Ref } from "vue";
|
|
import { useQuery, useMutation } from "@vue/apollo-composable";
|
|
import { PinsQueryDocument, ToggleContactPinMutationDocument } from "~~/graphql/generated";
|
|
import type { CommPin, CalendarEvent, CommItem, ClientTimelineItem } from "~/composables/crm-types";
|
|
import {
|
|
formatDay,
|
|
isEventFinalStatus,
|
|
eventLifecyclePhase,
|
|
} from "~/composables/crm-types";
|
|
import type { EventLifecyclePhase } from "~/composables/crm-types";
|
|
|
|
export function usePins(opts: {
|
|
apolloAuthReady: ComputedRef<boolean>;
|
|
selectedCommThread: ComputedRef<{ id: string; contact: string; items: CommItem[] } | undefined>;
|
|
selectedCommLifecycleEvents: ComputedRef<Array<{ event: CalendarEvent; phase: EventLifecyclePhase; timelineAt: string }>>;
|
|
visibleThreadItems: ComputedRef<CommItem[]>;
|
|
}) {
|
|
const { result: pinsResult, refetch: refetchPins } = useQuery(
|
|
PinsQueryDocument,
|
|
null,
|
|
{ enabled: opts.apolloAuthReady },
|
|
);
|
|
|
|
const { mutate: doToggleContactPin } = useMutation(ToggleContactPinMutationDocument, {
|
|
refetchQueries: [{ query: PinsQueryDocument }],
|
|
});
|
|
|
|
const commPins = ref<CommPin[]>([]);
|
|
const commPinToggling = ref(false);
|
|
const commPinContextMenu = ref<{
|
|
open: boolean;
|
|
x: number;
|
|
y: number;
|
|
entry: any | null;
|
|
}>({
|
|
open: false,
|
|
x: 0,
|
|
y: 0,
|
|
entry: null,
|
|
});
|
|
|
|
watch(() => pinsResult.value?.pins, (v) => {
|
|
if (v) commPins.value = v as CommPin[];
|
|
}, { immediate: true });
|
|
|
|
const selectedCommPins = computed(() => {
|
|
if (!opts.selectedCommThread.value) return [];
|
|
return commPins.value.filter((item) => item.contact === opts.selectedCommThread.value?.contact);
|
|
});
|
|
|
|
function normalizePinText(value: string) {
|
|
return String(value ?? "").replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
function stripPinnedPrefix(value: string) {
|
|
return String(value ?? "").replace(/^\s*(закреплено|pinned)\s*:\s*/i, "").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(stripPinnedPrefix(entry.text ?? ""));
|
|
if (entry.kind === "recommendation") return normalizePinText(entry.card?.text ?? "");
|
|
if (entry.kind === "eventLifecycle") {
|
|
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 doToggleContactPin({ contact: contactName, text });
|
|
} finally {
|
|
commPinToggling.value = false;
|
|
}
|
|
}
|
|
|
|
async function togglePinForEntry(entry: any) {
|
|
const contact = opts.selectedCommThread.value?.contact ?? "";
|
|
const text = entryPinText(entry);
|
|
await togglePinnedText(contact, text);
|
|
}
|
|
|
|
function isPinnedEntry(entry: any) {
|
|
const contact = opts.selectedCommThread.value?.contact ?? "";
|
|
const text = entryPinText(entry);
|
|
return isPinnedText(contact, text);
|
|
}
|
|
|
|
function closeCommPinContextMenu() {
|
|
commPinContextMenu.value = {
|
|
open: false,
|
|
x: 0,
|
|
y: 0,
|
|
entry: null,
|
|
};
|
|
}
|
|
|
|
function openCommPinContextMenu(event: MouseEvent, entry: any) {
|
|
const text = entryPinText(entry);
|
|
if (!text) return;
|
|
const menuWidth = 136;
|
|
const menuHeight = 46;
|
|
const padding = 8;
|
|
const maxX = Math.max(padding, window.innerWidth - menuWidth - padding);
|
|
const maxY = Math.max(padding, window.innerHeight - menuHeight - padding);
|
|
const x = Math.min(maxX, Math.max(padding, event.clientX));
|
|
const y = Math.min(maxY, Math.max(padding, event.clientY));
|
|
commPinContextMenu.value = {
|
|
open: true,
|
|
x,
|
|
y,
|
|
entry,
|
|
};
|
|
}
|
|
|
|
const commPinContextActionLabel = computed(() => {
|
|
const entry = commPinContextMenu.value.entry;
|
|
if (!entry) return "Pin";
|
|
return isPinnedEntry(entry) ? "Unpin" : "Pin";
|
|
});
|
|
|
|
async function applyCommPinContextAction() {
|
|
const entry = commPinContextMenu.value.entry;
|
|
if (!entry) return;
|
|
closeCommPinContextMenu();
|
|
await togglePinForEntry(entry);
|
|
}
|
|
|
|
function onWindowPointerDownForCommPinMenu(event: PointerEvent) {
|
|
if (!commPinContextMenu.value.open) return;
|
|
const target = event.target as HTMLElement | null;
|
|
if (target?.closest(".comm-pin-context-menu")) return;
|
|
closeCommPinContextMenu();
|
|
}
|
|
|
|
function onWindowKeyDownForCommPinMenu(event: KeyboardEvent) {
|
|
if (!commPinContextMenu.value.open) return;
|
|
if (event.key === "Escape") {
|
|
closeCommPinContextMenu();
|
|
}
|
|
}
|
|
|
|
const selectedCommPinnedStream = computed(() => {
|
|
const pins = selectedCommPins.value.map((pin) => {
|
|
const normalizedText = normalizePinText(stripPinnedPrefix(pin.text));
|
|
const sourceItem =
|
|
[...opts.visibleThreadItems.value]
|
|
.filter((item) => normalizePinText(item.text) === normalizedText)
|
|
.sort((a, b) => b.at.localeCompare(a.at))[0] ?? null;
|
|
return {
|
|
id: `pin-${pin.id}`,
|
|
kind: "pin" as const,
|
|
text: pin.text,
|
|
sourceItem,
|
|
};
|
|
});
|
|
|
|
const rank = (phase: EventLifecyclePhase) => {
|
|
if (phase === "awaiting_outcome") return 0;
|
|
if (phase === "due_soon") return 1;
|
|
if (phase === "scheduled") return 2;
|
|
return 3;
|
|
};
|
|
|
|
const events = opts.selectedCommLifecycleEvents.value
|
|
.filter((item) => !isEventFinalStatus(item.event.isArchived))
|
|
.sort((a, b) => rank(a.phase) - rank(b.phase) || a.event.start.localeCompare(b.event.start))
|
|
.map((item) => ({
|
|
id: `event-${item.event.id}`,
|
|
kind: "eventLifecycle" as const,
|
|
event: item.event,
|
|
phase: item.phase,
|
|
}));
|
|
|
|
return [...pins, ...events];
|
|
});
|
|
|
|
const latestPinnedItem = computed(() => selectedCommPinnedStream.value[0] ?? null);
|
|
|
|
const latestPinnedLabel = computed(() => {
|
|
if (!latestPinnedItem.value) return "No pinned items yet";
|
|
if (latestPinnedItem.value.kind === "pin") return stripPinnedPrefix(latestPinnedItem.value.text);
|
|
return `${latestPinnedItem.value.event.title} · ${formatDay(latestPinnedItem.value.event.start)}`;
|
|
});
|
|
|
|
return {
|
|
commPins,
|
|
commPinToggling,
|
|
commPinContextMenu,
|
|
selectedCommPins,
|
|
selectedCommPinnedStream,
|
|
togglePinnedText,
|
|
togglePinForEntry,
|
|
isPinnedText,
|
|
isPinnedEntry,
|
|
entryPinText,
|
|
normalizePinText,
|
|
stripPinnedPrefix,
|
|
latestPinnedItem,
|
|
latestPinnedLabel,
|
|
closeCommPinContextMenu,
|
|
openCommPinContextMenu,
|
|
commPinContextActionLabel,
|
|
applyCommPinContextAction,
|
|
onWindowPointerDownForCommPinMenu,
|
|
onWindowKeyDownForCommPinMenu,
|
|
refetchPins,
|
|
};
|
|
}
|