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:
222
frontend/app/composables/usePins.ts
Normal file
222
frontend/app/composables/usePins.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user