Add chat-side CRM diff panel with keep/rollback flow
This commit is contained in:
151
Frontend/app.vue
151
Frontend/app.vue
@@ -13,6 +13,8 @@ import updateFeedDecisionMutation from "./graphql/operations/update-feed-decisio
|
||||
import chatConversationsQuery from "./graphql/operations/chat-conversations.graphql?raw";
|
||||
import createChatConversationMutation from "./graphql/operations/create-chat-conversation.graphql?raw";
|
||||
import selectChatConversationMutation from "./graphql/operations/select-chat-conversation.graphql?raw";
|
||||
import confirmLatestChangeSetMutation from "./graphql/operations/confirm-latest-change-set.graphql?raw";
|
||||
import rollbackLatestChangeSetMutation from "./graphql/operations/rollback-latest-change-set.graphql?raw";
|
||||
type TabId = "communications" | "documents";
|
||||
type CalendarView = "day" | "week" | "month" | "year" | "agenda";
|
||||
type SortMode = "name" | "lastContact";
|
||||
@@ -190,6 +192,16 @@ type PilotMessage = {
|
||||
output: string;
|
||||
at: string;
|
||||
}> | null;
|
||||
changeSetId?: string | null;
|
||||
changeStatus?: "pending" | "confirmed" | "rolled_back" | null;
|
||||
changeSummary?: string | null;
|
||||
changeItems?: Array<{
|
||||
entity: string;
|
||||
action: string;
|
||||
title: string;
|
||||
before: string;
|
||||
after: string;
|
||||
}> | null;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
@@ -388,7 +400,7 @@ async function sendPilotMessage() {
|
||||
}, 450);
|
||||
try {
|
||||
await gqlFetch<{ sendPilotMessage: { ok: boolean } }>(sendPilotMessageMutation, { text });
|
||||
await Promise.all([loadPilotMessages(), loadChatConversations()]);
|
||||
await Promise.all([loadPilotMessages(), loadChatConversations(), refreshCrmData()]);
|
||||
} catch {
|
||||
pilotInput.value = text;
|
||||
} finally {
|
||||
@@ -397,6 +409,39 @@ async function sendPilotMessage() {
|
||||
}
|
||||
}
|
||||
|
||||
const changePanelOpen = ref(true);
|
||||
const changeActionBusy = ref(false);
|
||||
|
||||
const latestChangeMessage = computed(() => {
|
||||
return (
|
||||
[...pilotMessages.value]
|
||||
.reverse()
|
||||
.find((m) => m.role === "assistant" && m.changeSetId && m.changeStatus !== "rolled_back") ?? null
|
||||
);
|
||||
});
|
||||
|
||||
async function confirmLatestChangeSet() {
|
||||
if (changeActionBusy.value || !latestChangeMessage.value?.changeSetId) return;
|
||||
changeActionBusy.value = true;
|
||||
try {
|
||||
await gqlFetch<{ confirmLatestChangeSet: { ok: boolean } }>(confirmLatestChangeSetMutation);
|
||||
await loadPilotMessages();
|
||||
} finally {
|
||||
changeActionBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function rollbackLatestChangeSet() {
|
||||
if (changeActionBusy.value || !latestChangeMessage.value?.changeSetId) return;
|
||||
changeActionBusy.value = true;
|
||||
try {
|
||||
await gqlFetch<{ rollbackLatestChangeSet: { ok: boolean } }>(rollbackLatestChangeSetMutation);
|
||||
await Promise.all([loadPilotMessages(), refreshCrmData()]);
|
||||
} finally {
|
||||
changeActionBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadMe()
|
||||
.then(() => Promise.all([loadPilotMessages(), loadChatConversations(), refreshCrmData()]))
|
||||
@@ -820,10 +865,13 @@ const selectedCommThread = computed(() =>
|
||||
|
||||
const selectedCommChannel = ref<"All" | CommItem["channel"]>("All");
|
||||
const rightSidebarMode = ref<"summary" | "pinned">("summary");
|
||||
const commDraft = ref("");
|
||||
const commSending = ref(false);
|
||||
|
||||
watch(selectedCommThreadId, () => {
|
||||
selectedCommChannel.value = "All";
|
||||
rightSidebarMode.value = "summary";
|
||||
commDraft.value = "";
|
||||
});
|
||||
|
||||
const commChannelTabs = computed(() => {
|
||||
@@ -1064,6 +1112,35 @@ function openMessageFromContact(channel: CommItem["channel"]) {
|
||||
selectedCommChannel.value = channel;
|
||||
}
|
||||
|
||||
async function sendCommMessage() {
|
||||
const text = commDraft.value.trim();
|
||||
if (!text || commSending.value || !selectedCommThread.value) return;
|
||||
|
||||
commSending.value = true;
|
||||
try {
|
||||
const fallback =
|
||||
selectedCommThread.value.channels.find((channel) => channel !== "Phone") ??
|
||||
"Telegram";
|
||||
const channel = selectedCommChannel.value === "All" ? fallback : selectedCommChannel.value;
|
||||
|
||||
await gqlFetch<{ createCommunication: { ok: boolean; id: string } }>(createCommunicationMutation, {
|
||||
input: {
|
||||
contact: selectedCommThread.value.contact,
|
||||
channel,
|
||||
kind: "message",
|
||||
direction: "out",
|
||||
text,
|
||||
},
|
||||
});
|
||||
|
||||
commDraft.value = "";
|
||||
await refreshCrmData();
|
||||
openCommunicationThread(selectedCommThread.value.contact);
|
||||
} finally {
|
||||
commSending.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function executeFeedAction(card: FeedCard) {
|
||||
const key = card.proposal.key;
|
||||
if (key === "create_followup") {
|
||||
@@ -1328,6 +1405,52 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="latestChangeMessage && latestChangeMessage.changeItems && latestChangeMessage.changeItems.length"
|
||||
class="mb-2 rounded-xl border border-amber-300/40 bg-amber-500/10 p-2"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-amber-100">Detected changes</p>
|
||||
<p class="text-[11px] text-amber-100/80">
|
||||
{{ latestChangeMessage.changeSummary || `Changed: ${latestChangeMessage.changeItems.length}` }}
|
||||
· status: {{ latestChangeMessage.changeStatus || "pending" }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button class="btn btn-xs btn-ghost" @click="changePanelOpen = !changePanelOpen">
|
||||
{{ changePanelOpen ? "Hide" : "Show" }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-xs"
|
||||
:disabled="changeActionBusy || latestChangeMessage.changeStatus === 'confirmed'"
|
||||
@click="confirmLatestChangeSet"
|
||||
>
|
||||
Keep
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-xs btn-outline"
|
||||
:disabled="changeActionBusy || latestChangeMessage.changeStatus === 'rolled_back'"
|
||||
@click="rollbackLatestChangeSet"
|
||||
>
|
||||
Rollback
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="changePanelOpen" class="mt-2 max-h-44 space-y-2 overflow-y-auto pr-1">
|
||||
<div
|
||||
v-for="(item, idx) in latestChangeMessage.changeItems"
|
||||
:key="`change-item-${idx}`"
|
||||
class="rounded-lg border border-amber-200/30 bg-[#1e2230] p-2"
|
||||
>
|
||||
<p class="text-[11px] font-semibold text-white/90">{{ item.title }}</p>
|
||||
<p class="text-[11px] text-white/60">{{ item.entity }} · {{ item.action }}</p>
|
||||
<p v-if="item.before" class="mt-1 text-[11px] text-red-300/80">- {{ item.before }}</p>
|
||||
<p v-if="item.after" class="text-[11px] text-emerald-300/80">+ {{ item.after }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pilot-input-wrap">
|
||||
<input
|
||||
v-model="pilotInput"
|
||||
@@ -1679,8 +1802,8 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
<button class="btn btn-sm join-item btn-ghost" @click="peopleLeftMode = 'calendar'">Calendar</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid min-h-0 flex-1 gap-0 md:grid-cols-[220px_minmax(0,1fr)_320px]">
|
||||
<aside class="min-h-0 border-r border-base-300 flex flex-col">
|
||||
<div class="grid h-full min-h-0 flex-1 gap-0 md:grid-cols-[220px_minmax(0,1fr)_320px]">
|
||||
<aside class="h-full min-h-0 border-r border-base-300 flex flex-col">
|
||||
<div class="sticky top-0 z-20 border-b border-base-300 bg-base-100 p-2">
|
||||
<div class="mb-2 flex items-center gap-1">
|
||||
<button
|
||||
@@ -1801,7 +1924,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<article class="min-h-0 border-r border-base-300 flex flex-col">
|
||||
<article class="h-full min-h-0 border-r border-base-300 flex flex-col">
|
||||
<div v-if="false" class="p-3">
|
||||
<div class="grid grid-cols-[auto_1fr_auto] items-center gap-2">
|
||||
<div class="flex items-center gap-1">
|
||||
@@ -1980,7 +2103,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 space-y-2 overflow-y-auto pr-1">
|
||||
<div class="min-h-0 flex-1 space-y-2 overflow-y-auto pr-1 pb-3">
|
||||
<div v-for="entry in threadStreamItems" :key="entry.id">
|
||||
<div v-if="entry.kind === 'call'" class="flex justify-center">
|
||||
<div class="call-wave-card w-full max-w-[460px] rounded-2xl border border-base-300 px-4 py-3 text-center">
|
||||
@@ -2076,6 +2199,22 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sticky bottom-0 z-10 mt-3 border-t border-base-300 bg-base-100/95 pt-2 backdrop-blur">
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="commDraft"
|
||||
type="text"
|
||||
class="input input-bordered input-sm w-full"
|
||||
placeholder="Type a message..."
|
||||
:disabled="commSending"
|
||||
@keyup.enter="sendCommMessage"
|
||||
>
|
||||
<button class="btn btn-sm btn-primary" :disabled="commSending || !commDraft.trim()" @click="sendCommMessage">
|
||||
{{ commSending ? "..." : "Send" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex h-full items-center justify-center text-sm text-base-content/60">
|
||||
@@ -2083,7 +2222,7 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<aside class="min-h-0">
|
||||
<aside class="h-full min-h-0">
|
||||
<div v-if="selectedWorkspaceContact" class="flex h-full min-h-0 flex-col p-3">
|
||||
<div class="mb-3 flex items-start justify-between gap-2 border-b border-base-300 pb-2">
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user