Files
clientsflow/frontend/app/composables/usePins.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

226 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 { CommItem } from "~/composables/useContacts";
import type { CalendarEvent, EventLifecyclePhase } from "~/composables/useCalendar";
import { formatDay, isEventFinalStatus } from "~/composables/useCalendar";
export type CommPin = {
id: string;
contact: string;
text: string;
};
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,
};
}