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>
405 lines
15 KiB
TypeScript
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,
|
|
};
|
|
}
|