Implement URL-driven live review overlay for change sets
This commit is contained in:
670
frontend/app.vue
670
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<PilotMessage["changeItems"]>[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<string, boolean> = {};
|
||||
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<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 === "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<string, boolean> = {};
|
||||
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<PilotMessage["changeItems"]>[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<CommItem["channel"] | "">("");
|
||||
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")
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="min-h-0 bg-base-100">
|
||||
<main class="relative min-h-0 bg-base-100">
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<div class="workspace-topbar border-b border-base-300 px-3 py-2 md:px-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
@@ -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'"
|
||||
>
|
||||
<section v-if="selectedTab === 'communications' && peopleLeftMode === 'changes'" class="flex h-full min-h-0 flex-col gap-3">
|
||||
<div class="rounded-xl border border-amber-300/35 bg-amber-500/10 p-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-amber-100">Change Review</p>
|
||||
<p class="text-xs text-amber-100/80">
|
||||
{{ activeChangeMessage?.changeSummary || "No changes selected" }}
|
||||
<span v-if="activeChangeMessage?.changeStatus"> · status: {{ activeChangeMessage.changeStatus }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
class="btn btn-xs btn-outline"
|
||||
:disabled="!activeChangeItems.length"
|
||||
@click="toggleAllChangeItems(true)"
|
||||
>
|
||||
Select all
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-xs btn-outline"
|
||||
:disabled="!activeChangeItems.length"
|
||||
@click="toggleAllChangeItems(false)"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-xs"
|
||||
:disabled="changeActionBusy || selectedRollbackCount === 0"
|
||||
@click="rollbackSelectedChangeItems"
|
||||
>
|
||||
Rollback selected ({{ selectedRollbackCount }})
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-xs btn-ghost"
|
||||
:disabled="changeActionBusy || !latestChangeMessage?.changeSetId || activeChangeMessage?.changeSetId !== latestChangeMessage?.changeSetId"
|
||||
@click="confirmLatestChangeSet"
|
||||
>
|
||||
Keep as is
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-xs btn-outline"
|
||||
:disabled="changeActionBusy || !latestChangeMessage?.changeSetId || activeChangeMessage?.changeSetId !== latestChangeMessage?.changeSetId"
|
||||
@click="rollbackLatestChangeSet"
|
||||
>
|
||||
Rollback all
|
||||
</button>
|
||||
<button class="btn btn-xs btn-ghost" @click="setPeopleLeftMode('contacts', true)">Back to contacts</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeChangeItems.length" class="grid min-h-0 flex-1 gap-3 md:grid-cols-[360px_1fr]">
|
||||
<aside class="min-h-0 overflow-y-auto rounded-xl border border-base-300 bg-base-100 p-2">
|
||||
<button
|
||||
v-for="(item, idx) in activeChangeItems"
|
||||
:key="`change-review-item-${item.id}`"
|
||||
class="mb-2 block w-full rounded-lg border px-2 py-2 text-left transition hover:bg-base-200/50"
|
||||
:class="idx === activeChangeStep ? 'border-primary bg-primary/5' : 'border-base-300'"
|
||||
@click="goToChangeStep(idx)"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-xs mt-0.5"
|
||||
v-model="changeSelectionByItemId[item.id]"
|
||||
:disabled="item.rolledBack"
|
||||
@click.stop
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-xs font-semibold">{{ idx + 1 }}. {{ item.title }}</p>
|
||||
<p class="text-[11px] text-base-content/60">{{ item.entity }} · {{ item.action }}</p>
|
||||
<p v-if="item.rolledBack" class="mt-1 text-[10px] uppercase tracking-wide text-red-400">Rolled back</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<article v-if="activeChangeItem" class="min-h-0 overflow-y-auto rounded-xl border border-base-300 bg-base-100 p-3">
|
||||
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<p class="text-sm font-semibold">{{ activeChangeItem.title }}</p>
|
||||
<p class="text-xs text-base-content/60">
|
||||
Step {{ activeChangeStep + 1 }} / {{ activeChangeItems.length }} · {{ activeChangeItem.entity }} · {{ activeChangeItem.action }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="btn btn-xs btn-ghost" :disabled="activeChangeStep <= 0" @click="goToChangeStep(activeChangeStep - 1)">Prev</button>
|
||||
<button class="btn btn-xs btn-ghost" :disabled="activeChangeStep >= activeChangeItems.length - 1" @click="goToChangeStep(activeChangeStep + 1)">Next</button>
|
||||
<button class="btn btn-xs btn-outline" @click="openChangeItemTarget(activeChangeItem)">Open target</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div class="rounded-lg border border-red-300/40 bg-red-500/5 p-2">
|
||||
<p class="mb-1 text-xs font-semibold text-red-300">Before</p>
|
||||
<p class="whitespace-pre-wrap text-xs text-base-content/80">{{ activeChangeItem.before || "—" }}</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-emerald-300/40 bg-emerald-500/5 p-2">
|
||||
<p class="mb-1 text-xs font-semibold text-emerald-300">After</p>
|
||||
<p class="whitespace-pre-wrap text-xs text-base-content/80">{{ activeChangeItem.after || "—" }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div v-else class="rounded-xl border border-base-300 bg-base-100 p-4 text-sm text-base-content/65">
|
||||
No change set found for this route.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="selectedTab === 'communications' && peopleLeftMode === 'calendar'" class="flex h-full min-h-0 flex-col gap-3">
|
||||
<section v-if="selectedTab === 'communications' && peopleLeftMode === 'calendar'" class="flex h-full min-h-0 flex-col gap-3">
|
||||
<div class="grid grid-cols-[auto_1fr_auto] items-center gap-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<button class="btn btn-xs" @click="setToday">Today</button>
|
||||
@@ -3380,7 +3552,8 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
<button
|
||||
v-for="event in cell.events.slice(0, 2)"
|
||||
:key="event.id"
|
||||
class="block w-full truncate text-left text-[10px] text-base-content/70 hover:underline"
|
||||
class="block w-full truncate rounded px-1 text-left text-[10px] text-base-content/70 transition hover:underline"
|
||||
:class="isReviewHighlightedEvent(event.id) ? 'bg-success/20 text-success-content ring-1 ring-success/40' : ''"
|
||||
@click.stop="openThreadFromCalendarItem(event)"
|
||||
>
|
||||
{{ formatTime(event.start) }} {{ event.title }}
|
||||
@@ -3402,7 +3575,8 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
<button
|
||||
v-for="event in day.events"
|
||||
:key="event.id"
|
||||
class="block w-full rounded bg-base-200 px-2 py-1 text-left text-xs hover:bg-base-300/80"
|
||||
class="block w-full rounded px-2 py-1 text-left text-xs"
|
||||
:class="isReviewHighlightedEvent(event.id) ? 'bg-success/20 ring-1 ring-success/45' : 'bg-base-200 hover:bg-base-300/80'"
|
||||
@click.stop="openThreadFromCalendarItem(event)"
|
||||
>
|
||||
{{ formatTime(event.start) }} - {{ event.title }} ({{ event.contact }})
|
||||
@@ -3417,6 +3591,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
v-for="event in selectedDayEvents"
|
||||
:key="event.id"
|
||||
class="block w-full rounded-xl border border-base-300 p-3 text-left transition hover:bg-base-200/60"
|
||||
:class="isReviewHighlightedEvent(event.id) ? 'border-success/60 bg-success/10' : ''"
|
||||
@click="openThreadFromCalendarItem(event)"
|
||||
>
|
||||
<p class="font-medium">{{ event.title }}</p>
|
||||
@@ -3451,6 +3626,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
v-for="event in sortedEvents"
|
||||
:key="event.id"
|
||||
class="block w-full rounded-xl border border-base-300 p-3 text-left transition hover:bg-base-200/60"
|
||||
:class="isReviewHighlightedEvent(event.id) ? 'border-success/60 bg-success/10' : ''"
|
||||
@click="openThreadFromCalendarItem(event)"
|
||||
>
|
||||
<p class="font-medium">{{ event.title }}</p>
|
||||
@@ -3578,6 +3754,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
v-for="event in selectedContactEvents"
|
||||
:key="`contact-event-${event.id}`"
|
||||
class="w-full rounded-lg border border-base-300 px-3 py-2 text-left transition hover:bg-base-200/50"
|
||||
:class="isReviewHighlightedEvent(event.id) ? 'border-success/60 bg-success/10' : ''"
|
||||
@click="openEventFromContact(event)"
|
||||
>
|
||||
<p class="text-sm font-medium">{{ event.title }}</p>
|
||||
@@ -3711,7 +3888,10 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
v-for="thread in peopleContactList"
|
||||
:key="thread.id"
|
||||
class="w-full border-b border-base-300 px-3 py-2 text-left transition hover:bg-base-200/40"
|
||||
:class="selectedCommThreadId === thread.id ? 'bg-primary/10' : ''"
|
||||
:class="[
|
||||
selectedCommThreadId === thread.id ? 'bg-primary/10' : '',
|
||||
isReviewHighlightedContact(thread.id) ? 'bg-primary/10 ring-1 ring-primary/45' : '',
|
||||
]"
|
||||
@click="openCommunicationThread(thread.contact)"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
@@ -3750,7 +3930,10 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
v-for="deal in peopleDealList"
|
||||
:key="deal.id"
|
||||
class="w-full border-b border-base-300 px-3 py-2 text-left transition hover:bg-base-200/40"
|
||||
:class="selectedDealId === deal.id ? 'bg-primary/10' : ''"
|
||||
:class="[
|
||||
selectedDealId === deal.id ? 'bg-primary/10' : '',
|
||||
isReviewHighlightedDeal(deal.id) ? 'bg-primary/10 ring-1 ring-primary/45' : '',
|
||||
]"
|
||||
@click="openDealThread(deal)"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
@@ -3990,7 +4173,10 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
</div>
|
||||
|
||||
<div v-else-if="entry.kind === 'eventLifecycle'" class="flex justify-center">
|
||||
<article class="w-full max-w-[460px] rounded-xl border p-3 text-center" :class="eventPhaseToneClass(entry.phase)">
|
||||
<article
|
||||
class="w-full max-w-[460px] rounded-xl border p-3 text-center"
|
||||
:class="[eventPhaseToneClass(entry.phase), isReviewHighlightedEvent(entry.event.id) ? 'ring-2 ring-success/45' : '']"
|
||||
>
|
||||
<p class="text-xs text-base-content/70">
|
||||
{{ eventRelativeLabel(entry.event, lifecycleNowMs) }} · {{ formatDay(entry.event.start) }} {{ formatTime(entry.event.start) }}
|
||||
</p>
|
||||
@@ -4238,7 +4424,11 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
<aside class="h-full min-h-0">
|
||||
<div class="flex h-full min-h-0 flex-col p-3">
|
||||
<div class="min-h-0 flex-1 space-y-3 overflow-y-auto pr-1">
|
||||
<div v-if="selectedWorkspaceDeal" class="rounded-xl border border-base-300 bg-base-200/30 p-2.5">
|
||||
<div
|
||||
v-if="selectedWorkspaceDeal"
|
||||
class="rounded-xl border border-base-300 bg-base-200/30 p-2.5"
|
||||
:class="isReviewHighlightedDeal(selectedWorkspaceDeal.id) ? 'border-primary/60 bg-primary/10' : ''"
|
||||
>
|
||||
<p class="text-sm font-medium">
|
||||
{{ formatDealHeadline(selectedWorkspaceDeal) }}
|
||||
</p>
|
||||
@@ -4276,6 +4466,16 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
|
||||
<div>
|
||||
<p class="mb-2 text-xs font-semibold uppercase tracking-wide text-base-content/60">Summary</p>
|
||||
<div
|
||||
v-if="activeReviewContactDiff && selectedWorkspaceContact && activeReviewContactDiff.contactId === selectedWorkspaceContact.id"
|
||||
class="mb-2 rounded-xl border border-primary/35 bg-primary/5 p-2"
|
||||
>
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wide text-primary/80">Review diff</p>
|
||||
<p class="mt-1 text-[11px] text-base-content/65">Before</p>
|
||||
<pre class="mt-1 whitespace-pre-wrap rounded-lg border border-base-300/70 bg-base-100 px-2 py-1.5 text-[11px] leading-relaxed text-base-content/65 line-through">{{ activeReviewContactDiff.before || "Empty" }}</pre>
|
||||
<p class="mt-2 text-[11px] text-base-content/65">After</p>
|
||||
<pre class="mt-1 whitespace-pre-wrap rounded-lg border border-success/40 bg-success/10 px-2 py-1.5 text-[11px] leading-relaxed text-base-content">{{ activeReviewContactDiff.after || "Empty" }}</pre>
|
||||
</div>
|
||||
<ContactCollaborativeEditor
|
||||
v-if="selectedWorkspaceContact"
|
||||
:key="`contact-summary-${selectedWorkspaceContact.id}`"
|
||||
@@ -4357,9 +4557,107 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
<div
|
||||
v-if="reviewActive && selectedTab === 'communications'"
|
||||
class="pointer-events-none fixed inset-x-2 bottom-2 z-40 md:inset-auto md:right-4 md:bottom-4 md:w-[390px]"
|
||||
>
|
||||
<section class="pointer-events-auto rounded-2xl border border-base-300 bg-base-100/95 p-3 shadow-2xl backdrop-blur">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<p class="text-[11px] font-semibold uppercase tracking-wide text-base-content/60">
|
||||
Review {{ activeChangeStepNumber }}/{{ activeChangeItems.length }}
|
||||
</p>
|
||||
<p class="truncate text-sm font-semibold text-base-content">
|
||||
{{ activeChangeItem?.title || "Change step" }}
|
||||
</p>
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-xs" @click="finishReview(true)">Close</button>
|
||||
</div>
|
||||
|
||||
<div v-if="activeChangeItem" class="mt-2 rounded-xl border border-base-300 bg-base-200/35 p-2">
|
||||
<p class="text-xs text-base-content/80">
|
||||
{{ describeChangeEntity(activeChangeItem.entity) }} {{ describeChangeAction(activeChangeItem.action) }}
|
||||
</p>
|
||||
<label class="mt-1 inline-flex items-center gap-2 text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-xs"
|
||||
:checked="activeChangeApproved"
|
||||
:disabled="activeChangeItem.rolledBack"
|
||||
@change="onActiveReviewApprovalInput"
|
||||
>
|
||||
<span>{{ activeChangeItem.rolledBack ? "Already rolled back" : "Approve this step" }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 max-h-40 space-y-1 overflow-y-auto pr-1">
|
||||
<div
|
||||
v-for="(item, index) in activeChangeItems"
|
||||
:key="`review-step-${item.id}`"
|
||||
class="flex items-center gap-2 rounded-lg border px-2 py-1"
|
||||
:class="index === activeChangeIndex ? 'border-primary/45 bg-primary/10' : 'border-base-300 bg-base-100'"
|
||||
>
|
||||
<button
|
||||
class="min-w-0 flex-1 text-left"
|
||||
@click="openChangeItemTarget(item)"
|
||||
>
|
||||
<p class="truncate text-xs font-medium text-base-content">
|
||||
{{ index + 1 }}. {{ item.title }}
|
||||
</p>
|
||||
<p class="truncate text-[11px] text-base-content/65">
|
||||
{{ describeChangeEntity(item.entity) }}
|
||||
</p>
|
||||
</button>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-xs"
|
||||
:checked="isReviewItemApproved(item)"
|
||||
:disabled="item.rolledBack"
|
||||
@change="onReviewItemApprovalInput(item.id, $event)"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex items-center justify-between gap-2">
|
||||
<div class="join">
|
||||
<button
|
||||
class="btn btn-xs join-item"
|
||||
:disabled="activeChangeIndex <= 0"
|
||||
@click="goToPreviousChangeStep"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-xs join-item"
|
||||
:disabled="activeChangeIndex >= activeChangeItems.length - 1"
|
||||
@click="goToNextChangeStep"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-[11px] text-base-content/70">
|
||||
Rollback marked: {{ selectedRollbackCount }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<button class="btn btn-xs btn-outline" @click="setReviewApprovalForAll(true)">Approve all</button>
|
||||
<button class="btn btn-xs btn-outline" @click="setReviewApprovalForAll(false)">Mark all rollback</button>
|
||||
<button
|
||||
class="btn btn-xs btn-warning"
|
||||
:disabled="changeActionBusy || selectedRollbackCount === 0"
|
||||
@click="rollbackSelectedChangeItems"
|
||||
>
|
||||
{{ changeActionBusy ? "Applying..." : "Rollback selected" }}
|
||||
</button>
|
||||
<button class="btn btn-xs btn-primary ml-auto" @click="finishReview(true)">Done</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user