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

306 lines
11 KiB
TypeScript

import { ref, computed, watch, type Ref } from "vue";
import { useMutation } from "@vue/apollo-composable";
import {
ConfirmLatestChangeSetMutationDocument,
RollbackLatestChangeSetMutationDocument,
RollbackChangeSetItemsMutationDocument,
ChatMessagesQueryDocument,
ChatConversationsQueryDocument,
ContactsQueryDocument,
CommunicationsQueryDocument,
ContactInboxesQueryDocument,
CalendarQueryDocument,
DealsQueryDocument,
FeedQueryDocument,
PinsQueryDocument,
DocumentsQueryDocument,
} from "~~/graphql/generated";
import type { PilotMessage, PilotChangeItem } from "~/composables/crm-types";
export function useChangeReview(opts: {
pilotMessages: Ref<PilotMessage[]>;
refetchAllCrmQueries: () => Promise<void>;
refetchChatMessages: () => Promise<any>;
refetchChatConversations: () => Promise<any>;
}) {
// ---------------------------------------------------------------------------
// State
// ---------------------------------------------------------------------------
const activeChangeSetId = ref("");
const activeChangeStep = ref(0);
const changeActionBusy = ref(false);
// ---------------------------------------------------------------------------
// All CRM query docs for refetch
// ---------------------------------------------------------------------------
const allCrmQueryDocs = [
{ query: ContactsQueryDocument },
{ query: CommunicationsQueryDocument },
{ query: ContactInboxesQueryDocument },
{ query: CalendarQueryDocument },
{ query: DealsQueryDocument },
{ query: FeedQueryDocument },
{ query: PinsQueryDocument },
{ query: DocumentsQueryDocument },
];
// ---------------------------------------------------------------------------
// Apollo Mutations
// ---------------------------------------------------------------------------
const { mutate: doConfirmLatestChangeSet } = useMutation(ConfirmLatestChangeSetMutationDocument, {
refetchQueries: [{ query: ChatMessagesQueryDocument }],
});
const { mutate: doRollbackLatestChangeSet } = useMutation(RollbackLatestChangeSetMutationDocument, {
refetchQueries: [{ query: ChatMessagesQueryDocument }, { query: ChatConversationsQueryDocument }, ...allCrmQueryDocs],
});
const { mutate: doRollbackChangeSetItems } = useMutation(RollbackChangeSetItemsMutationDocument, {
refetchQueries: [{ query: ChatMessagesQueryDocument }, { query: ChatConversationsQueryDocument }, ...allCrmQueryDocs],
});
// ---------------------------------------------------------------------------
// Computed
// ---------------------------------------------------------------------------
const latestChangeMessage = computed(() => {
return (
[...opts.pilotMessages.value]
.reverse()
.find((m) => m.role === "assistant" && m.changeSetId && m.changeStatus !== "rolled_back") ?? null
);
});
const activeChangeMessage = computed(() => {
const targetId = activeChangeSetId.value.trim();
if (!targetId) return latestChangeMessage.value;
return (
[...opts.pilotMessages.value]
.reverse()
.find((m) => m.role === "assistant" && m.changeSetId === targetId) ?? null
);
});
const activeChangeItems = computed(() => activeChangeMessage.value?.changeItems ?? []);
const activeChangeIndex = computed(() => {
const items = activeChangeItems.value;
if (!items.length) return 0;
return Math.max(0, Math.min(activeChangeStep.value, items.length - 1));
});
const activeChangeItem = computed(() => {
const items = activeChangeItems.value;
if (!items.length) return null;
return items[activeChangeIndex.value] ?? null;
});
const reviewActive = computed(() => Boolean(activeChangeSetId.value.trim() && activeChangeItems.value.length > 0));
const activeReviewCalendarEventId = computed(() => {
const item = activeChangeItem.value;
if (!item || item.entity !== "calendar_event" || !item.entityId) return "";
return item.entityId;
});
const activeReviewContactId = computed(() => {
const item = activeChangeItem.value;
if (!item || item.entity !== "contact_note" || !item.entityId) return "";
return item.entityId;
});
const activeReviewDealId = computed(() => {
const item = activeChangeItem.value;
if (!item || item.entity !== "deal" || !item.entityId) return "";
return item.entityId;
});
const activeReviewMessageId = computed(() => {
const item = activeChangeItem.value;
if (!item || item.entity !== "message" || !item.entityId) return "";
return item.entityId;
});
const activeReviewContactDiff = computed(() => {
const item = activeChangeItem.value;
if (!item || item.entity !== "contact_note" || !item.entityId) return null;
return {
contactId: item.entityId,
before: normalizeChangeText(item.before),
after: normalizeChangeText(item.after),
};
});
// ---------------------------------------------------------------------------
// Text helpers
// ---------------------------------------------------------------------------
function normalizeChangeText(raw: string | null | undefined) {
const text = String(raw ?? "").trim();
if (!text) return "";
try {
const parsed = JSON.parse(text) as Record<string, unknown>;
if (typeof parsed === "object" && parsed) {
const candidate = [parsed.description, parsed.summary, parsed.note, parsed.text]
.find((value) => typeof value === "string");
if (typeof candidate === "string") return candidate.trim();
}
} catch {
// No-op: keep original text when it is not JSON payload.
}
return text;
}
function describeChangeEntity(entity: string) {
if (entity === "contact_note") return "Contact summary";
if (entity === "calendar_event") return "Calendar event";
if (entity === "message") return "Message";
if (entity === "deal") return "Deal";
if (entity === "workspace_document") return "Workspace document";
return entity || "Change";
}
function describeChangeAction(action: string) {
if (action === "created") return "created";
if (action === "updated") return "updated";
if (action === "deleted") return "archived";
return action || "changed";
}
// ---------------------------------------------------------------------------
// Review navigation
// ---------------------------------------------------------------------------
function openChangeReview(changeSetId: string, step = 0) {
const targetId = String(changeSetId ?? "").trim();
if (!targetId) return;
activeChangeSetId.value = targetId;
const items = activeChangeMessage.value?.changeItems ?? [];
activeChangeStep.value = items.length ? Math.max(0, Math.min(step, items.length - 1)) : 0;
}
function goToChangeStep(step: number) {
const items = activeChangeItems.value;
if (!items.length) return;
activeChangeStep.value = Math.max(0, Math.min(step, items.length - 1));
}
function goToPreviousChangeStep() {
goToChangeStep(activeChangeIndex.value - 1);
}
function goToNextChangeStep() {
goToChangeStep(activeChangeIndex.value + 1);
}
function finishReview() {
activeChangeSetId.value = "";
activeChangeStep.value = 0;
}
// ---------------------------------------------------------------------------
// Highlight helpers
// ---------------------------------------------------------------------------
function isReviewHighlightedEvent(eventId: string) {
return Boolean(reviewActive.value && activeReviewCalendarEventId.value && activeReviewCalendarEventId.value === eventId);
}
function isReviewHighlightedContact(contactId: string) {
return Boolean(reviewActive.value && activeReviewContactId.value && activeReviewContactId.value === contactId);
}
function isReviewHighlightedDeal(dealId: string) {
return Boolean(reviewActive.value && activeReviewDealId.value && activeReviewDealId.value === dealId);
}
function isReviewHighlightedMessage(messageId: string) {
return Boolean(reviewActive.value && activeReviewMessageId.value && activeReviewMessageId.value === messageId);
}
// ---------------------------------------------------------------------------
// Change execution
// ---------------------------------------------------------------------------
async function confirmLatestChangeSet() {
if (changeActionBusy.value || !latestChangeMessage.value?.changeSetId) return;
changeActionBusy.value = true;
try {
await doConfirmLatestChangeSet();
} finally {
changeActionBusy.value = false;
}
}
async function rollbackLatestChangeSet() {
if (changeActionBusy.value || !latestChangeMessage.value?.changeSetId) return;
changeActionBusy.value = true;
try {
await doRollbackLatestChangeSet();
activeChangeSetId.value = "";
activeChangeStep.value = 0;
} finally {
changeActionBusy.value = false;
}
}
async function rollbackSelectedChangeItems() {
const targetChangeSetId = activeChangeMessage.value?.changeSetId?.trim() || activeChangeSetId.value.trim();
const itemIds = activeChangeItems.value.filter((item) => !item.rolledBack).map((item) => item.id);
if (changeActionBusy.value || !targetChangeSetId || itemIds.length === 0) return;
changeActionBusy.value = true;
try {
await doRollbackChangeSetItems({ changeSetId: targetChangeSetId, itemIds });
} finally {
changeActionBusy.value = false;
}
}
async function rollbackChangeItemById(itemId: string) {
const item = activeChangeItems.value.find((entry) => entry.id === itemId);
const targetChangeSetId = activeChangeMessage.value?.changeSetId?.trim() || activeChangeSetId.value.trim();
if (!item || item.rolledBack || !targetChangeSetId || changeActionBusy.value) return;
changeActionBusy.value = true;
try {
await doRollbackChangeSetItems({ changeSetId: targetChangeSetId, itemIds: [itemId] });
} finally {
changeActionBusy.value = false;
}
}
// ---------------------------------------------------------------------------
// Watcher: clamp step when change items list changes
// ---------------------------------------------------------------------------
watch(
() => activeChangeMessage.value?.changeSetId,
() => {
if (!activeChangeSetId.value.trim()) return;
const maxIndex = Math.max(0, (activeChangeItems.value.length || 1) - 1);
if (activeChangeStep.value > maxIndex) activeChangeStep.value = maxIndex;
},
);
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
return {
activeChangeSetId,
activeChangeStep,
changeActionBusy,
reviewActive,
activeChangeItems,
activeChangeItem,
activeChangeIndex,
openChangeReview,
goToChangeStep,
goToPreviousChangeStep,
goToNextChangeStep,
finishReview,
isReviewHighlightedEvent,
isReviewHighlightedContact,
isReviewHighlightedDeal,
isReviewHighlightedMessage,
activeReviewCalendarEventId,
activeReviewContactId,
activeReviewDealId,
activeReviewMessageId,
activeReviewContactDiff,
confirmLatestChangeSet,
rollbackLatestChangeSet,
rollbackSelectedChangeItems,
rollbackChangeItemById,
describeChangeEntity,
describeChangeAction,
normalizeChangeText,
};
}