Files
clientsflow/frontend/app/composables/useWorkspaceRouting.ts
Ruslan Bakiev a4d8d81de9 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>
2026-02-24 15:05:01 +07:00

405 lines
15 KiB
TypeScript

import { ref, type Ref, type ComputedRef } from "vue";
import type { TabId, CalendarView, PeopleLeftMode, CalendarEvent, PilotChangeItem } from "~/composables/crm-types";
import { safeTrim, dayKey } from "~/composables/crm-types";
export function useWorkspaceRouting(opts: {
selectedTab: Ref<TabId>;
peopleLeftMode: Ref<PeopleLeftMode>;
peopleListMode: Ref<"contacts" | "deals">;
selectedContactId: Ref<string>;
selectedCommThreadId: Ref<string>;
selectedDealId: Ref<string>;
selectedChatId: Ref<string>;
calendarView: Ref<CalendarView>;
calendarCursor: Ref<Date>;
selectedDateKey: Ref<string>;
selectedDocumentId: Ref<string>;
focusedCalendarEventId: Ref<string>;
activeChangeSetId: Ref<string>;
activeChangeStep: Ref<number>;
// computed refs
sortedEvents: ComputedRef<CalendarEvent[]>;
commThreads: ComputedRef<{ id: string; [key: string]: any }[]>;
contacts: Ref<{ id: string; name: string; [key: string]: any }[]>;
deals: Ref<{ id: string; contact: string; [key: string]: any }[]>;
commItems: Ref<{ id: string; contact: string; [key: string]: any }[]>;
activeChangeMessage: ComputedRef<{ changeSetId?: string | null; changeItems?: PilotChangeItem[] | null } | null>;
activeChangeItem: ComputedRef<PilotChangeItem | null>;
activeChangeItems: ComputedRef<PilotChangeItem[]>;
activeChangeIndex: ComputedRef<number>;
authMe: Ref<{ conversation: { id: string } } | null>;
// functions from outside
pickDate: (key: string) => void;
openCommunicationThread: (contact: string) => void;
completeTelegramBusinessConnectFromToken: (token: string) => void;
}) {
const uiPathSyncLocked = ref(false);
let popstateHandler: (() => void) | null = null;
// ---------------------------------------------------------------------------
// Calendar route helpers (internal)
// ---------------------------------------------------------------------------
function calendarCursorToken(date: Date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, "0");
return `${y}-${m}`;
}
function calendarRouteToken(view: CalendarView) {
if (view === "day" || view === "week") {
return opts.selectedDateKey.value;
}
if (view === "year") {
return String(opts.calendarCursor.value.getFullYear());
}
return calendarCursorToken(opts.calendarCursor.value);
}
function parseCalendarCursorToken(token: string | null | undefined) {
const text = String(token ?? "").trim();
const m = text.match(/^(\d{4})-(\d{2})$/);
if (!m) return null;
const year = Number(m[1]);
const month = Number(m[2]);
if (!Number.isFinite(year) || !Number.isFinite(month) || month < 1 || month > 12) return null;
return new Date(year, month - 1, 1);
}
function parseCalendarDateToken(token: string | null | undefined) {
const text = String(token ?? "").trim();
const m = text.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (!m) return null;
const year = Number(m[1]);
const month = Number(m[2]);
const day = Number(m[3]);
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return null;
if (month < 1 || month > 12 || day < 1 || day > 31) return null;
const parsed = new Date(year, month - 1, day);
if (Number.isNaN(parsed.getTime())) return null;
return parsed;
}
function parseCalendarYearToken(token: string | null | undefined) {
const text = String(token ?? "").trim();
const m = text.match(/^(\d{4})$/);
if (!m) return null;
const year = Number(m[1]);
if (!Number.isFinite(year)) return null;
return year;
}
// ---------------------------------------------------------------------------
// Core routing functions
// ---------------------------------------------------------------------------
function normalizedConversationId() {
return safeTrim(opts.selectedChatId.value || opts.authMe.value?.conversation.id || "pilot");
}
function withReviewQuery(path: string) {
const reviewSet = opts.activeChangeSetId.value.trim();
if (!reviewSet) return path;
const params = new URLSearchParams();
params.set("reviewSet", reviewSet);
params.set("reviewStep", String(Math.max(1, opts.activeChangeStep.value + 1)));
return `${path}?${params.toString()}`;
}
function currentUiPath() {
if (opts.selectedTab.value === "documents") {
const docId = opts.selectedDocumentId.value.trim();
if (docId) {
return withReviewQuery(`/documents/${encodeURIComponent(docId)}`);
}
return withReviewQuery("/documents");
}
if (opts.peopleLeftMode.value === "calendar") {
if (opts.focusedCalendarEventId.value.trim()) {
return withReviewQuery(`/calendar/event/${encodeURIComponent(opts.focusedCalendarEventId.value.trim())}`);
}
return withReviewQuery(`/calendar/${encodeURIComponent(opts.calendarView.value)}/${encodeURIComponent(calendarRouteToken(opts.calendarView.value))}`);
}
if (opts.peopleListMode.value === "deals" && opts.selectedDealId.value.trim()) {
return withReviewQuery(`/deal/${encodeURIComponent(opts.selectedDealId.value.trim())}`);
}
if (opts.selectedContactId.value.trim()) {
return withReviewQuery(`/contact/${encodeURIComponent(opts.selectedContactId.value.trim())}`);
}
return withReviewQuery(`/chat/${encodeURIComponent(normalizedConversationId())}`);
}
function syncPathFromUi(push = false) {
if (process.server) return;
const nextPath = currentUiPath();
const currentPath = `${window.location.pathname}${window.location.search}`;
if (nextPath === currentPath) return;
if (push) {
window.history.pushState({}, "", nextPath);
} else {
window.history.replaceState({}, "", nextPath);
}
}
function applyPathToUi(pathname: string, search = "") {
const path = String(pathname || "/").trim() || "/";
const params = new URLSearchParams(String(search || ""));
const reviewSet = (params.get("reviewSet") ?? "").trim();
const reviewStep = Number(params.get("reviewStep") ?? "1");
if (reviewSet) {
opts.activeChangeSetId.value = reviewSet;
opts.activeChangeStep.value = Number.isFinite(reviewStep) && reviewStep > 0 ? reviewStep - 1 : 0;
} else {
opts.activeChangeSetId.value = "";
opts.activeChangeStep.value = 0;
}
const calendarEventMatch = path.match(/^\/calendar\/event\/([^/]+)\/?$/i);
if (calendarEventMatch) {
const rawEventId = decodeURIComponent(calendarEventMatch[1] ?? "").trim();
opts.selectedTab.value = "communications";
opts.peopleLeftMode.value = "calendar";
const event = opts.sortedEvents.value.find((x) => x.id === rawEventId);
if (event) {
opts.pickDate(event.start.slice(0, 10));
}
opts.focusedCalendarEventId.value = rawEventId;
return;
}
const calendarMatch = path.match(/^\/calendar\/([^/]+)\/([^/]+)\/?$/i);
if (calendarMatch) {
const rawView = decodeURIComponent(calendarMatch[1] ?? "").trim();
const rawCursor = decodeURIComponent(calendarMatch[2] ?? "").trim();
const view = (["day", "week", "month", "year", "agenda"] as CalendarView[]).includes(rawView as CalendarView)
? (rawView as CalendarView)
: "month";
const cursorByMonth = parseCalendarCursorToken(rawCursor);
const cursorByDate = parseCalendarDateToken(rawCursor);
const cursorByYear = parseCalendarYearToken(rawCursor);
opts.selectedTab.value = "communications";
opts.peopleLeftMode.value = "calendar";
opts.focusedCalendarEventId.value = "";
opts.calendarView.value = view;
if (view === "day" || view === "week") {
const parsed = cursorByDate;
if (parsed) {
opts.selectedDateKey.value = dayKey(parsed);
opts.calendarCursor.value = new Date(parsed.getFullYear(), parsed.getMonth(), 1);
}
} else if (view === "year") {
if (cursorByYear) {
opts.calendarCursor.value = new Date(cursorByYear, 0, 1);
opts.selectedDateKey.value = dayKey(new Date(cursorByYear, 0, 1));
}
} else if (cursorByMonth) {
opts.calendarCursor.value = cursorByMonth;
opts.selectedDateKey.value = dayKey(cursorByMonth);
}
return;
}
const documentsMatch = path.match(/^\/documents(?:\/([^/]+))?\/?$/i);
if (documentsMatch) {
const rawDocumentId = decodeURIComponent(documentsMatch[1] ?? "").trim();
opts.selectedTab.value = "documents";
opts.focusedCalendarEventId.value = "";
if (rawDocumentId) opts.selectedDocumentId.value = rawDocumentId;
return;
}
const contactMatch = path.match(/^\/contact\/([^/]+)\/?$/i);
if (contactMatch) {
const rawContactId = decodeURIComponent(contactMatch[1] ?? "").trim();
opts.selectedTab.value = "communications";
opts.peopleLeftMode.value = "contacts";
opts.peopleListMode.value = "contacts";
if (rawContactId) {
opts.selectedContactId.value = rawContactId;
const linkedThread = opts.commThreads.value.find((thread) => thread.id === rawContactId);
if (linkedThread) opts.selectedCommThreadId.value = linkedThread.id;
}
opts.focusedCalendarEventId.value = "";
return;
}
const dealMatch = path.match(/^\/deal\/([^/]+)\/?$/i);
if (dealMatch) {
const rawDealId = decodeURIComponent(dealMatch[1] ?? "").trim();
opts.selectedTab.value = "communications";
opts.peopleLeftMode.value = "contacts";
opts.peopleListMode.value = "deals";
if (rawDealId) {
opts.selectedDealId.value = rawDealId;
const linkedDeal = opts.deals.value.find((deal) => deal.id === rawDealId);
const linkedContact = linkedDeal
? opts.contacts.value.find((contact) => contact.name === linkedDeal.contact)
: null;
if (linkedContact) {
opts.selectedContactId.value = linkedContact.id;
opts.selectedCommThreadId.value = linkedContact.id;
}
}
opts.focusedCalendarEventId.value = "";
return;
}
const chatMatch = path.match(/^\/chat\/([^/]+)\/?$/i);
if (chatMatch) {
const rawChatId = decodeURIComponent(chatMatch[1] ?? "").trim();
opts.selectedTab.value = "communications";
opts.peopleLeftMode.value = "contacts";
opts.peopleListMode.value = "contacts";
opts.focusedCalendarEventId.value = "";
if (rawChatId) opts.selectedChatId.value = rawChatId;
return;
}
const changesMatch = path.match(/^\/changes\/([^/]+)(?:\/step\/(\d+))?\/?$/i);
if (changesMatch) {
const rawId = decodeURIComponent(changesMatch[1] ?? "").trim();
const rawStep = Number(changesMatch[2] ?? "1");
if (rawId) {
opts.activeChangeSetId.value = rawId;
opts.activeChangeStep.value = Number.isFinite(rawStep) && rawStep > 0 ? rawStep - 1 : 0;
}
opts.selectedTab.value = "communications";
opts.peopleLeftMode.value = "contacts";
opts.peopleListMode.value = "contacts";
opts.focusedCalendarEventId.value = "";
return;
}
opts.selectedTab.value = "communications";
opts.peopleLeftMode.value = "contacts";
opts.peopleListMode.value = "contacts";
opts.focusedCalendarEventId.value = "";
}
function applyReviewStepToUi(push = false) {
const item = opts.activeChangeItem.value;
if (!item) {
syncPathFromUi(push);
return;
}
opts.selectedTab.value = "communications";
if (item.entity === "calendar_event" && item.entityId) {
opts.peopleLeftMode.value = "calendar";
opts.calendarView.value = "month";
const event = opts.sortedEvents.value.find((x) => x.id === item.entityId);
if (event) {
opts.pickDate(event.start.slice(0, 10));
}
opts.focusedCalendarEventId.value = item.entityId;
syncPathFromUi(push);
return;
}
if (item.entity === "contact_note" && item.entityId) {
opts.peopleLeftMode.value = "contacts";
opts.peopleListMode.value = "contacts";
opts.selectedContactId.value = item.entityId;
const thread = opts.commThreads.value.find((entry) => entry.id === item.entityId);
if (thread) opts.selectedCommThreadId.value = thread.id;
opts.focusedCalendarEventId.value = "";
syncPathFromUi(push);
return;
}
if (item.entity === "deal" && item.entityId) {
opts.peopleLeftMode.value = "contacts";
opts.peopleListMode.value = "deals";
opts.selectedDealId.value = item.entityId;
const deal = opts.deals.value.find((entry) => entry.id === item.entityId);
if (deal) {
const contact = opts.contacts.value.find((entry) => entry.name === deal.contact);
if (contact) {
opts.selectedContactId.value = contact.id;
opts.selectedCommThreadId.value = contact.id;
}
}
opts.focusedCalendarEventId.value = "";
syncPathFromUi(push);
return;
}
if (item.entity === "message" && item.entityId) {
opts.peopleLeftMode.value = "contacts";
opts.peopleListMode.value = "contacts";
const message = opts.commItems.value.find((entry) => entry.id === item.entityId);
if (message?.contact) {
opts.openCommunicationThread(message.contact);
}
opts.focusedCalendarEventId.value = "";
syncPathFromUi(push);
return;
}
if (item.entity === "workspace_document" && item.entityId) {
opts.selectedTab.value = "documents";
opts.selectedDocumentId.value = item.entityId;
opts.focusedCalendarEventId.value = "";
syncPathFromUi(push);
return;
}
opts.peopleLeftMode.value = "contacts";
opts.focusedCalendarEventId.value = "";
syncPathFromUi(push);
}
// ---------------------------------------------------------------------------
// Lifecycle init / cleanup
// ---------------------------------------------------------------------------
function initRouting() {
uiPathSyncLocked.value = true;
try {
const params = new URLSearchParams(window.location.search);
const tgLinkToken = String(params.get("tg_link_token") ?? "").trim();
if (tgLinkToken) {
void opts.completeTelegramBusinessConnectFromToken(tgLinkToken);
params.delete("tg_link_token");
const nextSearch = params.toString();
window.history.replaceState({}, "", `${window.location.pathname}${nextSearch ? `?${nextSearch}` : ""}`);
}
applyPathToUi(window.location.pathname, window.location.search);
} finally {
uiPathSyncLocked.value = false;
}
syncPathFromUi(false);
popstateHandler = () => {
uiPathSyncLocked.value = true;
try {
applyPathToUi(window.location.pathname, window.location.search);
} finally {
uiPathSyncLocked.value = false;
}
};
window.addEventListener("popstate", popstateHandler);
}
function cleanupRouting() {
if (popstateHandler) {
window.removeEventListener("popstate", popstateHandler);
popstateHandler = null;
}
}
return {
uiPathSyncLocked,
currentUiPath,
applyPathToUi,
syncPathFromUi,
applyReviewStepToUi,
withReviewQuery,
initRouting,
cleanupRouting,
};
}