From e2e2901076530314fe5dd52d2bd1ff9129e7fb15 Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Sat, 21 Feb 2026 09:49:44 +0700 Subject: [PATCH] Implement URL-driven live review overlay for change sets --- frontend/app.vue | 670 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 484 insertions(+), 186 deletions(-) diff --git a/frontend/app.vue b/frontend/app.vue index ebcb4ee..b67ca16 100644 --- a/frontend/app.vue +++ b/frontend/app.vue @@ -24,7 +24,7 @@ import { DefaultChatTransport, isTextUIPart, type UIMessage } from "ai"; type TabId = "communications" | "documents"; type CalendarView = "day" | "week" | "month" | "year" | "agenda"; type SortMode = "name" | "lastContact"; -type PeopleLeftMode = "contacts" | "calendar" | "changes"; +type PeopleLeftMode = "contacts" | "calendar"; type PeopleSortMode = "name" | "lastContact" | "company" | "country"; type FeedCard = { @@ -336,6 +336,8 @@ type PilotMessage = { _live?: boolean; }; +type PilotChangeItem = NonNullable[number]; + type ChatConversation = { id: string; title: string; @@ -1232,34 +1234,135 @@ const activeChangeMessage = computed(() => { }); 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; - const idx = Math.max(0, Math.min(activeChangeStep.value, items.length - 1)); - return items[idx] ?? null; + return items[activeChangeIndex.value] ?? null; }); +const reviewActive = computed(() => Boolean(activeChangeSetId.value.trim() && activeChangeItems.value.length > 0)); +const activeChangeStepNumber = computed(() => activeChangeIndex.value + 1); +const activeChangeApproved = computed(() => { + const item = activeChangeItem.value; + if (!item || item.rolledBack) return true; + return changeSelectionByItemId.value[item.id] !== false; +}); +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 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), + }; +}); const selectedRollbackItemIds = computed(() => activeChangeItems.value - .filter((item) => !item.rolledBack && changeSelectionByItemId.value[item.id]) + .filter((item) => !item.rolledBack && changeSelectionByItemId.value[item.id] === false) .map((item) => item.id), ); const selectedRollbackCount = computed(() => selectedRollbackItemIds.value.length); -function toggleAllChangeItems(checked: boolean) { +function setReviewApprovalForAll(approved: boolean) { const next: Record = {}; for (const item of activeChangeItems.value) { - next[item.id] = item.rolledBack ? false : checked; + next[item.id] = item.rolledBack ? true : approved; } changeSelectionByItemId.value = next; } +function normalizeChangeText(raw: string | null | undefined) { + const text = String(raw ?? "").trim(); + if (!text) return ""; + try { + const parsed = JSON.parse(text) as Record; + 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 === "deal") return "Deal"; + 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"; +} + +function isReviewItemApproved(item: PilotChangeItem | null | undefined) { + if (!item || item.rolledBack) return true; + return changeSelectionByItemId.value[item.id] !== false; +} + +function setReviewItemApproval(itemId: string, approved: boolean) { + const target = activeChangeItems.value.find((item) => item.id === itemId); + if (!target || target.rolledBack) return; + changeSelectionByItemId.value = { + ...changeSelectionByItemId.value, + [itemId]: approved, + }; +} + +function onReviewItemApprovalInput(itemId: string, event: Event) { + const input = event.target as HTMLInputElement | null; + setReviewItemApproval(itemId, Boolean(input?.checked)); +} + +function onActiveReviewApprovalInput(event: Event) { + const item = activeChangeItem.value; + if (!item) return; + const input = event.target as HTMLInputElement | null; + setReviewItemApproval(item.id, Boolean(input?.checked)); +} + 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 selectedDateKey.value; + } + if (view === "year") { + return String(calendarCursor.value.getFullYear()); + } + return calendarCursorToken(calendarCursor.value); +} + function parseCalendarCursorToken(token: string | null | undefined) { const text = String(token ?? "").trim(); const m = text.match(/^(\d{4})-(\d{2})$/); @@ -1270,34 +1373,69 @@ function parseCalendarCursorToken(token: string | null | undefined) { 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; +} + function normalizedConversationId() { return (selectedChatId.value || authMe.value?.conversation.id || "pilot").trim(); } function currentUiPath() { if (selectedTab.value !== "communications") { - return `/chat/${encodeURIComponent(normalizedConversationId())}`; - } - - if (peopleLeftMode.value === "changes" && activeChangeSetId.value.trim()) { - const step = Math.max(1, activeChangeStep.value + 1); - return `/changes/${encodeURIComponent(activeChangeSetId.value.trim())}/step/${step}`; + return withReviewQuery(`/chat/${encodeURIComponent(normalizedConversationId())}`); } if (peopleLeftMode.value === "calendar") { if (focusedCalendarEventId.value.trim()) { - return `/calendar/event/${encodeURIComponent(focusedCalendarEventId.value.trim())}`; + return withReviewQuery(`/calendar/event/${encodeURIComponent(focusedCalendarEventId.value.trim())}`); } - return `/calendar/${encodeURIComponent(calendarView.value)}/${encodeURIComponent(calendarCursorToken(calendarCursor.value))}`; + return withReviewQuery(`/calendar/${encodeURIComponent(calendarView.value)}/${encodeURIComponent(calendarRouteToken(calendarView.value))}`); } - return `/chat/${encodeURIComponent(normalizedConversationId())}`; + if (peopleListMode.value === "deals" && selectedDealId.value.trim()) { + return withReviewQuery(`/deal/${encodeURIComponent(selectedDealId.value.trim())}`); + } + + if (selectedContactId.value.trim()) { + return withReviewQuery(`/contact/${encodeURIComponent(selectedContactId.value.trim())}`); + } + + return withReviewQuery(`/chat/${encodeURIComponent(normalizedConversationId())}`); +} + +function withReviewQuery(path: string) { + const reviewSet = activeChangeSetId.value.trim(); + if (!reviewSet) return path; + const params = new URLSearchParams(); + params.set("reviewSet", reviewSet); + params.set("reviewStep", String(Math.max(1, activeChangeStep.value + 1))); + return `${path}?${params.toString()}`; } function syncPathFromUi(push = false) { if (process.server) return; const nextPath = currentUiPath(); - const currentPath = window.location.pathname; + const currentPath = `${window.location.pathname}${window.location.search}`; if (nextPath === currentPath) return; if (push) { window.history.pushState({}, "", nextPath); @@ -1314,7 +1452,7 @@ function ensureChangeSelectionSeeded(message: PilotMessage | null | undefined) { const next: Record = {}; for (const item of message.changeItems) { const prev = changeSelectionByItemId.value[item.id]; - next[item.id] = typeof prev === "boolean" ? prev : !item.rolledBack; + next[item.id] = typeof prev === "boolean" ? prev : true; } changeSelectionByItemId.value = next; } @@ -1322,10 +1460,6 @@ function ensureChangeSelectionSeeded(message: PilotMessage | null | undefined) { function setPeopleLeftMode(mode: PeopleLeftMode, push = false) { selectedTab.value = "communications"; peopleLeftMode.value = mode; - if (mode !== "changes") { - activeChangeSetId.value = ""; - activeChangeStep.value = 0; - } focusedCalendarEventId.value = ""; syncPathFromUi(push); } @@ -1333,17 +1467,28 @@ function setPeopleLeftMode(mode: PeopleLeftMode, push = false) { function openChangeReview(changeSetId: string, step = 0, push = true) { const targetId = String(changeSetId ?? "").trim(); if (!targetId) return; - selectedTab.value = "communications"; - peopleLeftMode.value = "changes"; activeChangeSetId.value = targetId; const items = activeChangeMessage.value?.changeItems ?? []; activeChangeStep.value = items.length ? Math.max(0, Math.min(step, items.length - 1)) : 0; ensureChangeSelectionSeeded(activeChangeMessage.value); - syncPathFromUi(push); + applyReviewStepToUi(push); } -function applyPathToUi(pathname: string) { +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) { + activeChangeSetId.value = reviewSet; + activeChangeStep.value = Number.isFinite(reviewStep) && reviewStep > 0 ? reviewStep - 1 : 0; + } else { + activeChangeSetId.value = ""; + activeChangeStep.value = 0; + changeSelectionByItemId.value = {}; + } + const calendarEventMatch = path.match(/^\/calendar\/event\/([^/]+)\/?$/i); if (calendarEventMatch) { const rawEventId = decodeURIComponent(calendarEventMatch[1] ?? "").trim(); @@ -1354,8 +1499,6 @@ function applyPathToUi(pathname: string) { pickDate(event.start.slice(0, 10)); } focusedCalendarEventId.value = rawEventId; - activeChangeSetId.value = ""; - activeChangeStep.value = 0; return; } @@ -1366,17 +1509,75 @@ function applyPathToUi(pathname: string) { const view = (["day", "week", "month", "year", "agenda"] as CalendarView[]).includes(rawView as CalendarView) ? (rawView as CalendarView) : "month"; - const cursor = parseCalendarCursorToken(rawCursor); + const cursorByMonth = parseCalendarCursorToken(rawCursor); + const cursorByDate = parseCalendarDateToken(rawCursor); + const cursorByYear = parseCalendarYearToken(rawCursor); selectedTab.value = "communications"; peopleLeftMode.value = "calendar"; focusedCalendarEventId.value = ""; calendarView.value = view; - if (cursor) { - calendarCursor.value = cursor; - selectedDateKey.value = dayKey(cursor); + if (view === "day" || view === "week") { + const parsed = cursorByDate; + if (parsed) { + selectedDateKey.value = dayKey(parsed); + calendarCursor.value = new Date(parsed.getFullYear(), parsed.getMonth(), 1); + } + } else if (view === "year") { + if (cursorByYear) { + calendarCursor.value = new Date(cursorByYear, 0, 1); + selectedDateKey.value = dayKey(new Date(cursorByYear, 0, 1)); + } + } else if (cursorByMonth) { + calendarCursor.value = cursorByMonth; + selectedDateKey.value = dayKey(cursorByMonth); } - activeChangeSetId.value = ""; - activeChangeStep.value = 0; + return; + } + + const contactMatch = path.match(/^\/contact\/([^/]+)\/?$/i); + if (contactMatch) { + const rawContactId = decodeURIComponent(contactMatch[1] ?? "").trim(); + selectedTab.value = "communications"; + peopleLeftMode.value = "contacts"; + peopleListMode.value = "contacts"; + if (rawContactId) { + selectedContactId.value = rawContactId; + const linkedThread = commThreads.value.find((thread) => thread.id === rawContactId); + if (linkedThread) selectedCommThreadId.value = linkedThread.id; + } + focusedCalendarEventId.value = ""; + return; + } + + const dealMatch = path.match(/^\/deal\/([^/]+)\/?$/i); + if (dealMatch) { + const rawDealId = decodeURIComponent(dealMatch[1] ?? "").trim(); + selectedTab.value = "communications"; + peopleLeftMode.value = "contacts"; + peopleListMode.value = "deals"; + if (rawDealId) { + selectedDealId.value = rawDealId; + const linkedDeal = deals.value.find((deal) => deal.id === rawDealId); + const linkedContact = linkedDeal + ? contacts.value.find((contact) => contact.name === linkedDeal.contact) + : null; + if (linkedContact) { + selectedContactId.value = linkedContact.id; + selectedCommThreadId.value = linkedContact.id; + } + } + focusedCalendarEventId.value = ""; + return; + } + + const chatMatch = path.match(/^\/chat\/([^/]+)\/?$/i); + if (chatMatch) { + const rawChatId = decodeURIComponent(chatMatch[1] ?? "").trim(); + selectedTab.value = "communications"; + peopleLeftMode.value = "contacts"; + peopleListMode.value = "contacts"; + focusedCalendarEventId.value = ""; + if (rawChatId) selectedChatId.value = rawChatId; return; } @@ -1384,20 +1585,21 @@ function applyPathToUi(pathname: string) { if (changesMatch) { const rawId = decodeURIComponent(changesMatch[1] ?? "").trim(); const rawStep = Number(changesMatch[2] ?? "1"); + if (rawId) { + activeChangeSetId.value = rawId; + activeChangeStep.value = Number.isFinite(rawStep) && rawStep > 0 ? rawStep - 1 : 0; + } selectedTab.value = "communications"; - peopleLeftMode.value = "changes"; + peopleLeftMode.value = "contacts"; + peopleListMode.value = "contacts"; focusedCalendarEventId.value = ""; - activeChangeSetId.value = rawId; - activeChangeStep.value = Number.isFinite(rawStep) && rawStep > 0 ? rawStep - 1 : 0; - ensureChangeSelectionSeeded(activeChangeMessage.value); return; } selectedTab.value = "communications"; peopleLeftMode.value = "contacts"; + peopleListMode.value = "contacts"; focusedCalendarEventId.value = ""; - activeChangeSetId.value = ""; - activeChangeStep.value = 0; } async function confirmLatestChangeSet() { @@ -1417,11 +1619,9 @@ async function rollbackLatestChangeSet() { try { await gqlFetch<{ rollbackLatestChangeSet: { ok: boolean } }>(rollbackLatestChangeSetMutation); await Promise.all([loadPilotMessages(), refreshCrmData(), loadChatConversations()]); - if (peopleLeftMode.value === "changes") { - activeChangeSetId.value = ""; - activeChangeStep.value = 0; - setPeopleLeftMode("contacts"); - } + activeChangeSetId.value = ""; + activeChangeStep.value = 0; + setPeopleLeftMode("contacts"); } finally { changeActionBusy.value = false; } @@ -1449,41 +1649,106 @@ function goToChangeStep(step: number) { const items = activeChangeItems.value; if (!items.length) return; activeChangeStep.value = Math.max(0, Math.min(step, items.length - 1)); - syncPathFromUi(true); + applyReviewStepToUi(true); } -function openChangeItemTarget(item: NonNullable[number]) { +function goToPreviousChangeStep() { + goToChangeStep(activeChangeIndex.value - 1); +} + +function goToNextChangeStep() { + goToChangeStep(activeChangeIndex.value + 1); +} + +function openChangeItemTarget(item: PilotChangeItem) { if (!item) return; + const idx = activeChangeItems.value.findIndex((candidate) => candidate.id === item.id); + if (idx >= 0) { + goToChangeStep(idx); + } +} + +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 applyReviewStepToUi(push = false) { + const item = activeChangeItem.value; + if (!item) { + syncPathFromUi(push); + return; + } + + selectedTab.value = "communications"; if (item.entity === "calendar_event" && item.entityId) { + peopleLeftMode.value = "calendar"; + calendarView.value = "month"; const event = sortedEvents.value.find((x) => x.id === item.entityId); - setPeopleLeftMode("calendar", true); if (event) { pickDate(event.start.slice(0, 10)); } focusedCalendarEventId.value = item.entityId; - syncPathFromUi(true); + syncPathFromUi(push); return; } if (item.entity === "contact_note" && item.entityId) { - const contact = contacts.value.find((x) => x.id === item.entityId); - setPeopleLeftMode("contacts", true); - if (contact) selectedContactId.value = contact.id; - syncPathFromUi(true); + peopleLeftMode.value = "contacts"; + peopleListMode.value = "contacts"; + selectedContactId.value = item.entityId; + const thread = commThreads.value.find((entry) => entry.id === item.entityId); + if (thread) selectedCommThreadId.value = thread.id; + focusedCalendarEventId.value = ""; + syncPathFromUi(push); return; } - setPeopleLeftMode("contacts", true); + if (item.entity === "deal" && item.entityId) { + peopleLeftMode.value = "contacts"; + peopleListMode.value = "deals"; + selectedDealId.value = item.entityId; + const deal = deals.value.find((entry) => entry.id === item.entityId); + if (deal) { + const contact = contacts.value.find((entry) => entry.name === deal.contact); + if (contact) { + selectedContactId.value = contact.id; + selectedCommThreadId.value = contact.id; + } + } + focusedCalendarEventId.value = ""; + syncPathFromUi(push); + return; + } + + peopleLeftMode.value = "contacts"; + focusedCalendarEventId.value = ""; + syncPathFromUi(push); +} + +function finishReview(push = true) { + activeChangeSetId.value = ""; + activeChangeStep.value = 0; + changeSelectionByItemId.value = {}; + syncPathFromUi(push); } watch( () => activeChangeMessage.value?.changeSetId, () => { - if (peopleLeftMode.value !== "changes") return; + if (!activeChangeSetId.value.trim()) return; ensureChangeSelectionSeeded(activeChangeMessage.value); const maxIndex = Math.max(0, (activeChangeItems.value.length || 1) - 1); if (activeChangeStep.value > maxIndex) activeChangeStep.value = maxIndex; + applyReviewStepToUi(false); }, ); @@ -1503,7 +1768,7 @@ onMounted(() => { uiPathSyncLocked.value = true; try { - applyPathToUi(window.location.pathname); + applyPathToUi(window.location.pathname, window.location.search); } finally { uiPathSyncLocked.value = false; } @@ -1511,7 +1776,7 @@ onMounted(() => { popstateHandler = () => { uiPathSyncLocked.value = true; try { - applyPathToUi(window.location.pathname); + applyPathToUi(window.location.pathname, window.location.search); } finally { uiPathSyncLocked.value = false; } @@ -1724,14 +1989,6 @@ function openYearMonth(monthIndex: number) { calendarView.value = "month"; } -watch( - () => [selectedTab.value, peopleLeftMode.value, selectedChatId.value, calendarView.value, calendarCursorToken(calendarCursor.value), activeChangeSetId.value, activeChangeStep.value], - () => { - if (process.server || uiPathSyncLocked.value) return; - syncPathFromUi(false); - }, -); - const contactSearch = ref(""); const selectedCountry = ref("All"); const selectedLocation = ref("All"); @@ -1986,6 +2243,26 @@ const selectedCommThread = computed(() => commThreads.value.find((thread) => thread.id === selectedCommThreadId.value), ); +watch( + () => [ + selectedTab.value, + peopleLeftMode.value, + peopleListMode.value, + selectedChatId.value, + calendarView.value, + calendarRouteToken(calendarView.value), + focusedCalendarEventId.value, + selectedContactId.value, + selectedDealId.value, + activeChangeSetId.value, + activeChangeStep.value, + ], + () => { + if (process.server || uiPathSyncLocked.value) return; + syncPathFromUi(false); + }, +); + const commSendChannel = ref(""); const commPinnedOnly = ref(false); const commDraft = ref(""); @@ -2363,12 +2640,14 @@ const selectedWorkspaceContact = computed(() => { }); const selectedWorkspaceDeal = computed(() => { + const explicit = deals.value.find((deal) => deal.id === selectedDealId.value); + if (explicit) return explicit; + if (selectedWorkspaceContact.value) { const linked = deals.value.find((deal) => deal.contact === selectedWorkspaceContact.value?.name); if (linked) return linked; } - - return deals.value.find((deal) => deal.id === selectedDealId.value) ?? null; + return null; }); function formatDealHeadline(deal: Deal) { @@ -2612,6 +2891,7 @@ function pushPilotNote(text: string) { function openCommunicationThread(contact: string) { setPeopleLeftMode("contacts", true); + peopleListMode.value = "contacts"; selectedDealStepsExpanded.value = false; const linkedContact = contacts.value.find((item) => item.name === contact); if (linkedContact) { @@ -2629,8 +2909,10 @@ function openCommunicationThread(contact: string) { function openDealThread(deal: Deal) { selectedDealId.value = deal.id; + peopleListMode.value = "deals"; selectedDealStepsExpanded.value = false; openCommunicationThread(deal.contact); + peopleListMode.value = "deals"; } function openThreadFromCalendarItem(event: CalendarEvent) { @@ -3166,7 +3448,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected") -
+
@@ -3218,117 +3500,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected") class="min-h-0 flex-1" :class="selectedTab === 'communications' && peopleLeftMode === 'contacts' ? 'px-0 pt-0 pb-0' : 'px-3 pt-3 pb-0 md:px-4 md:pt-4 md:pb-0'" > -
-
-
-
-

Change Review

-

- {{ activeChangeMessage?.changeSummary || "No changes selected" }} - · status: {{ activeChangeMessage.changeStatus }} -

-
-
- - - - - - -
-
-
- -
- - -
-
-
-

{{ activeChangeItem.title }}

-

- Step {{ activeChangeStep + 1 }} / {{ activeChangeItems.length }} · {{ activeChangeItem.entity }} · {{ activeChangeItem.action }} -

-
-
- - - -
-
- -
-
-

Before

-

{{ activeChangeItem.before || "—" }}

-
-
-

After

-

{{ activeChangeItem.after || "—" }}

-
-
-
-
- -
- No change set found for this route. -
-
- -
+
@@ -3380,7 +3552,8 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
+
-
+
+
+
+
+

+ Review {{ activeChangeStepNumber }}/{{ activeChangeItems.length }} +

+

+ {{ activeChangeItem?.title || "Change step" }} +

+
+ +
+ +
+

+ {{ describeChangeEntity(activeChangeItem.entity) }} {{ describeChangeAction(activeChangeItem.action) }} +

+ +
+ +
+
+ + +
+
+ +
+
+ + +
+

+ Rollback marked: {{ selectedRollbackCount }} +

+
+ +
+ + + + +
+
+
+ +