Switch calendar events to isArchived model
This commit is contained in:
265
Frontend/app.vue
265
Frontend/app.vue
@@ -7,6 +7,7 @@ import loginMutation from "./graphql/operations/login.graphql?raw";
|
|||||||
import logoutMutation from "./graphql/operations/logout.graphql?raw";
|
import logoutMutation from "./graphql/operations/logout.graphql?raw";
|
||||||
import logPilotNoteMutation from "./graphql/operations/log-pilot-note.graphql?raw";
|
import logPilotNoteMutation from "./graphql/operations/log-pilot-note.graphql?raw";
|
||||||
import createCalendarEventMutation from "./graphql/operations/create-calendar-event.graphql?raw";
|
import createCalendarEventMutation from "./graphql/operations/create-calendar-event.graphql?raw";
|
||||||
|
import archiveCalendarEventMutation from "./graphql/operations/archive-calendar-event.graphql?raw";
|
||||||
import createCommunicationMutation from "./graphql/operations/create-communication.graphql?raw";
|
import createCommunicationMutation from "./graphql/operations/create-communication.graphql?raw";
|
||||||
import updateCommunicationTranscriptMutation from "./graphql/operations/update-communication-transcript.graphql?raw";
|
import updateCommunicationTranscriptMutation from "./graphql/operations/update-communication-transcript.graphql?raw";
|
||||||
import updateFeedDecisionMutation from "./graphql/operations/update-feed-decision.graphql?raw";
|
import updateFeedDecisionMutation from "./graphql/operations/update-feed-decision.graphql?raw";
|
||||||
@@ -58,8 +59,14 @@ type CalendarEvent = {
|
|||||||
end: string;
|
end: string;
|
||||||
contact: string;
|
contact: string;
|
||||||
note: string;
|
note: string;
|
||||||
|
isArchived: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
archiveNote: string;
|
||||||
|
archivedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type EventLifecyclePhase = "scheduled" | "due_soon" | "awaiting_outcome" | "closed";
|
||||||
|
|
||||||
type CommItem = {
|
type CommItem = {
|
||||||
id: string;
|
id: string;
|
||||||
at: string;
|
at: string;
|
||||||
@@ -175,6 +182,48 @@ function endAfter(startIso: string, minutes: number) {
|
|||||||
return d.toISOString();
|
return d.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isEventFinalStatus(isArchived: boolean) {
|
||||||
|
return Boolean(isArchived);
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventPreDueAt(event: CalendarEvent) {
|
||||||
|
return new Date(new Date(event.start).getTime() - 30 * 60 * 1000).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventDueAt(event: CalendarEvent) {
|
||||||
|
return event.start;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventLifecyclePhase(event: CalendarEvent, nowMs: number): EventLifecyclePhase {
|
||||||
|
if (event.isArchived) return "closed";
|
||||||
|
|
||||||
|
const dueMs = new Date(eventDueAt(event)).getTime();
|
||||||
|
const preDueMs = new Date(eventPreDueAt(event)).getTime();
|
||||||
|
if (nowMs >= dueMs) return "awaiting_outcome";
|
||||||
|
if (nowMs >= preDueMs) return "due_soon";
|
||||||
|
return "scheduled";
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventTimelineAt(event: CalendarEvent, phase: EventLifecyclePhase) {
|
||||||
|
if (phase === "scheduled") return event.createdAt || event.start;
|
||||||
|
if (phase === "due_soon") return eventPreDueAt(event);
|
||||||
|
return eventDueAt(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventPhaseLabel(phase: EventLifecyclePhase) {
|
||||||
|
if (phase === "scheduled") return "Scheduled";
|
||||||
|
if (phase === "due_soon") return "Starts in <30 min";
|
||||||
|
if (phase === "awaiting_outcome") return "Awaiting outcome";
|
||||||
|
return "Closed";
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventPhaseToneClass(phase: EventLifecyclePhase) {
|
||||||
|
if (phase === "awaiting_outcome") return "border-warning/50 bg-warning/10";
|
||||||
|
if (phase === "due_soon") return "border-info/50 bg-info/10";
|
||||||
|
if (phase === "closed") return "border-success/40 bg-success/10";
|
||||||
|
return "border-base-300 bg-base-100";
|
||||||
|
}
|
||||||
|
|
||||||
function toInputDate(date: Date) {
|
function toInputDate(date: Date) {
|
||||||
const y = date.getFullYear();
|
const y = date.getFullYear();
|
||||||
const m = String(date.getMonth() + 1).padStart(2, "0");
|
const m = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
@@ -337,6 +386,8 @@ const loginPassword = ref("");
|
|||||||
const loginError = ref<string | null>(null);
|
const loginError = ref<string | null>(null);
|
||||||
const loginBusy = ref(false);
|
const loginBusy = ref(false);
|
||||||
let pilotBackgroundPoll: ReturnType<typeof setInterval> | null = null;
|
let pilotBackgroundPoll: ReturnType<typeof setInterval> | null = null;
|
||||||
|
const lifecycleNowMs = ref(Date.now());
|
||||||
|
let lifecycleClock: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => authMe.value?.conversation.id,
|
() => authMe.value?.conversation.id,
|
||||||
@@ -1150,6 +1201,9 @@ onMounted(() => {
|
|||||||
typeof navigator !== "undefined" &&
|
typeof navigator !== "undefined" &&
|
||||||
typeof MediaRecorder !== "undefined" &&
|
typeof MediaRecorder !== "undefined" &&
|
||||||
Boolean(navigator.mediaDevices?.getUserMedia);
|
Boolean(navigator.mediaDevices?.getUserMedia);
|
||||||
|
lifecycleClock = setInterval(() => {
|
||||||
|
lifecycleNowMs.value = Date.now();
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
if (!authResolved.value) {
|
if (!authResolved.value) {
|
||||||
void bootstrapSession().finally(() => {
|
void bootstrapSession().finally(() => {
|
||||||
@@ -1177,6 +1231,10 @@ onBeforeUnmount(() => {
|
|||||||
pilotRecorderStream = null;
|
pilotRecorderStream = null;
|
||||||
}
|
}
|
||||||
stopPilotBackgroundPolling();
|
stopPilotBackgroundPolling();
|
||||||
|
if (lifecycleClock) {
|
||||||
|
clearInterval(lifecycleClock);
|
||||||
|
lifecycleClock = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const calendarView = ref<CalendarView>("month");
|
const calendarView = ref<CalendarView>("month");
|
||||||
@@ -1615,6 +1673,10 @@ const commEventForm = ref({
|
|||||||
durationMinutes: 30,
|
durationMinutes: 30,
|
||||||
note: "",
|
note: "",
|
||||||
});
|
});
|
||||||
|
const eventCloseOpen = ref<Record<string, boolean>>({});
|
||||||
|
const eventCloseDraft = ref<Record<string, string>>({});
|
||||||
|
const eventCloseSaving = ref<Record<string, boolean>>({});
|
||||||
|
const eventCloseError = ref<Record<string, string>>({});
|
||||||
|
|
||||||
watch(selectedCommThreadId, () => {
|
watch(selectedCommThreadId, () => {
|
||||||
destroyAllCommCallWaves();
|
destroyAllCommCallWaves();
|
||||||
@@ -1626,6 +1688,10 @@ watch(selectedCommThreadId, () => {
|
|||||||
commDraft.value = "";
|
commDraft.value = "";
|
||||||
commEventModalOpen.value = false;
|
commEventModalOpen.value = false;
|
||||||
commEventError.value = "";
|
commEventError.value = "";
|
||||||
|
eventCloseOpen.value = {};
|
||||||
|
eventCloseDraft.value = {};
|
||||||
|
eventCloseSaving.value = {};
|
||||||
|
eventCloseError.value = {};
|
||||||
const fallback = selectedCommThread.value?.channels.find((channel) => channel !== "Phone") ?? "Telegram";
|
const fallback = selectedCommThread.value?.channels.find((channel) => channel !== "Phone") ?? "Telegram";
|
||||||
commSendChannel.value = fallback;
|
commSendChannel.value = fallback;
|
||||||
});
|
});
|
||||||
@@ -1654,40 +1720,22 @@ const selectedCommPins = computed(() => {
|
|||||||
return commPins.value.filter((item) => item.contact === selectedCommThread.value?.contact);
|
return commPins.value.filter((item) => item.contact === selectedCommThread.value?.contact);
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedCommEvents = computed(() => {
|
const selectedCommLifecycleEvents = computed(() => {
|
||||||
if (!selectedCommThread.value) return [];
|
if (!selectedCommThread.value) return [];
|
||||||
const now = new Date();
|
const nowMs = lifecycleNowMs.value;
|
||||||
|
|
||||||
return sortedEvents.value
|
return sortedEvents.value
|
||||||
.filter((event) => event.contact === selectedCommThread.value?.contact)
|
.filter((event) => event.contact === selectedCommThread.value?.contact)
|
||||||
.filter((event) => new Date(event.end) >= now)
|
.map((event) => {
|
||||||
.slice(0, 6);
|
const phase = eventLifecyclePhase(event, nowMs);
|
||||||
});
|
return {
|
||||||
|
event,
|
||||||
const selectedCommPastEvents = computed(() => {
|
phase,
|
||||||
if (!selectedCommThread.value) return [];
|
timelineAt: eventTimelineAt(event, phase),
|
||||||
const now = new Date();
|
};
|
||||||
|
|
||||||
return sortedEvents.value
|
|
||||||
.filter((event) => event.contact === selectedCommThread.value?.contact)
|
|
||||||
.filter((event) => new Date(event.end) < now)
|
|
||||||
.slice(-6);
|
|
||||||
});
|
|
||||||
|
|
||||||
const selectedCommUrgentEvent = computed(() => {
|
|
||||||
if (!selectedCommThread.value) return null;
|
|
||||||
const now = Date.now();
|
|
||||||
const inFifteenMinutes = now + 15 * 60 * 1000;
|
|
||||||
|
|
||||||
const event = sortedEvents.value
|
|
||||||
.filter((item) => item.contact === selectedCommThread.value?.contact)
|
|
||||||
.filter((item) => {
|
|
||||||
const start = new Date(item.start).getTime();
|
|
||||||
return start >= now && start <= inFifteenMinutes;
|
|
||||||
})
|
})
|
||||||
.sort((a, b) => a.start.localeCompare(b.start))[0];
|
.sort((a, b) => a.timelineAt.localeCompare(b.timelineAt))
|
||||||
|
.slice(-12);
|
||||||
return event ?? null;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const threadStreamItems = computed(() => {
|
const threadStreamItems = computed(() => {
|
||||||
@@ -1702,20 +1750,9 @@ const threadStreamItems = computed(() => {
|
|||||||
| {
|
| {
|
||||||
id: string;
|
id: string;
|
||||||
at: string;
|
at: string;
|
||||||
kind: "eventAlert";
|
kind: "eventLifecycle";
|
||||||
event: CalendarEvent;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
id: string;
|
|
||||||
at: string;
|
|
||||||
kind: "event";
|
|
||||||
event: CalendarEvent;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
id: string;
|
|
||||||
at: string;
|
|
||||||
kind: "eventLog";
|
|
||||||
event: CalendarEvent;
|
event: CalendarEvent;
|
||||||
|
phase: EventLifecyclePhase;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -1725,31 +1762,13 @@ const threadStreamItems = computed(() => {
|
|||||||
}
|
}
|
||||||
> = [];
|
> = [];
|
||||||
|
|
||||||
if (selectedCommUrgentEvent.value) {
|
for (const entry of selectedCommLifecycleEvents.value) {
|
||||||
centeredRows.push({
|
centeredRows.push({
|
||||||
id: `event-alert-${selectedCommUrgentEvent.value.id}`,
|
id: `event-${entry.event.id}`,
|
||||||
at: selectedCommUrgentEvent.value.start,
|
at: entry.timelineAt,
|
||||||
kind: "eventAlert",
|
kind: "eventLifecycle",
|
||||||
event: selectedCommUrgentEvent.value,
|
event: entry.event,
|
||||||
});
|
phase: entry.phase,
|
||||||
}
|
|
||||||
|
|
||||||
for (const event of selectedCommEvents.value) {
|
|
||||||
if (selectedCommUrgentEvent.value?.id === event.id) continue;
|
|
||||||
centeredRows.push({
|
|
||||||
id: `event-${event.id}`,
|
|
||||||
at: event.start,
|
|
||||||
kind: "event",
|
|
||||||
event,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const event of selectedCommPastEvents.value) {
|
|
||||||
centeredRows.push({
|
|
||||||
id: `event-log-${event.id}`,
|
|
||||||
at: event.end,
|
|
||||||
kind: "eventLog",
|
|
||||||
event,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1779,11 +1798,22 @@ const selectedCommPinnedStream = computed(() => {
|
|||||||
text: pin.text,
|
text: pin.text,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const events = selectedCommEvents.value.map((event) => ({
|
const rank = (phase: EventLifecyclePhase) => {
|
||||||
id: `event-${event.id}`,
|
if (phase === "awaiting_outcome") return 0;
|
||||||
kind: "event" as const,
|
if (phase === "due_soon") return 1;
|
||||||
event,
|
if (phase === "scheduled") return 2;
|
||||||
}));
|
return 3;
|
||||||
|
};
|
||||||
|
|
||||||
|
const events = selectedCommLifecycleEvents.value
|
||||||
|
.filter((item) => !isEventFinalStatus(item.event.isArchived))
|
||||||
|
.sort((a, b) => rank(a.phase) - rank(b.phase) || a.event.start.localeCompare(b.event.start))
|
||||||
|
.map((item) => ({
|
||||||
|
id: `event-${item.event.id}`,
|
||||||
|
kind: "eventLifecycle" as const,
|
||||||
|
event: item.event,
|
||||||
|
phase: item.phase,
|
||||||
|
}));
|
||||||
|
|
||||||
return [...pins, ...events];
|
return [...pins, ...events];
|
||||||
});
|
});
|
||||||
@@ -1815,13 +1845,57 @@ function entryPinText(entry: any): string {
|
|||||||
if (!entry) return "";
|
if (!entry) return "";
|
||||||
if (entry.kind === "pin") return normalizePinText(stripPinnedPrefix(entry.text ?? ""));
|
if (entry.kind === "pin") return normalizePinText(stripPinnedPrefix(entry.text ?? ""));
|
||||||
if (entry.kind === "recommendation") return normalizePinText(entry.card?.text ?? "");
|
if (entry.kind === "recommendation") return normalizePinText(entry.card?.text ?? "");
|
||||||
if (entry.kind === "event" || entry.kind === "eventAlert" || entry.kind === "eventLog") {
|
if (entry.kind === "eventLifecycle") {
|
||||||
return normalizePinText(entry.event?.note || entry.event?.title || "");
|
return normalizePinText(entry.event?.note || entry.event?.title || "");
|
||||||
}
|
}
|
||||||
if (entry.kind === "call") return normalizePinText(entry.item?.text || "");
|
if (entry.kind === "call") return normalizePinText(entry.item?.text || "");
|
||||||
return normalizePinText(entry.item?.text || "");
|
return normalizePinText(entry.item?.text || "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canManuallyCloseEvent(entry: { kind: string; event?: CalendarEvent; phase?: EventLifecyclePhase }) {
|
||||||
|
if (entry.kind !== "eventLifecycle" || !entry.event) return false;
|
||||||
|
return !isEventFinalStatus(entry.event.isArchived);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEventCloseOpen(eventId: string) {
|
||||||
|
return Boolean(eventCloseOpen.value[eventId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleEventClose(eventId: string) {
|
||||||
|
const next = !eventCloseOpen.value[eventId];
|
||||||
|
eventCloseOpen.value = { ...eventCloseOpen.value, [eventId]: next };
|
||||||
|
if (next && !eventCloseDraft.value[eventId]) {
|
||||||
|
eventCloseDraft.value = { ...eventCloseDraft.value, [eventId]: "" };
|
||||||
|
}
|
||||||
|
if (!next && eventCloseError.value[eventId]) {
|
||||||
|
eventCloseError.value = { ...eventCloseError.value, [eventId]: "" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function archiveEventManually(event: CalendarEvent) {
|
||||||
|
const eventId = event.id;
|
||||||
|
const archiveNote = String(eventCloseDraft.value[eventId] ?? "").trim();
|
||||||
|
if (eventCloseSaving.value[eventId]) return;
|
||||||
|
|
||||||
|
eventCloseSaving.value = { ...eventCloseSaving.value, [eventId]: true };
|
||||||
|
eventCloseError.value = { ...eventCloseError.value, [eventId]: "" };
|
||||||
|
try {
|
||||||
|
await gqlFetch<{ archiveCalendarEvent: CalendarEvent }>(archiveCalendarEventMutation, {
|
||||||
|
input: {
|
||||||
|
id: eventId,
|
||||||
|
archiveNote: archiveNote || undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await refreshCrmData();
|
||||||
|
eventCloseOpen.value = { ...eventCloseOpen.value, [eventId]: false };
|
||||||
|
eventCloseDraft.value = { ...eventCloseDraft.value, [eventId]: "" };
|
||||||
|
} catch (error: any) {
|
||||||
|
eventCloseError.value = { ...eventCloseError.value, [eventId]: String(error?.message ?? error ?? "Failed to archive event") };
|
||||||
|
} finally {
|
||||||
|
eventCloseSaving.value = { ...eventCloseSaving.value, [eventId]: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function togglePinnedText(contact: string, value: string) {
|
async function togglePinnedText(contact: string, value: string) {
|
||||||
if (commPinToggling.value) return;
|
if (commPinToggling.value) return;
|
||||||
const contactName = String(contact ?? "").trim();
|
const contactName = String(contact ?? "").trim();
|
||||||
@@ -2219,7 +2293,8 @@ async function createCommEvent() {
|
|||||||
end: end.toISOString(),
|
end: end.toISOString(),
|
||||||
contact: selectedCommThread.value.contact,
|
contact: selectedCommThread.value.contact,
|
||||||
note,
|
note,
|
||||||
status: commEventMode.value === "logged" ? "done" : "planned",
|
archived: commEventMode.value === "logged",
|
||||||
|
archiveNote: commEventMode.value === "logged" ? note : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
calendarEvents.value = [res.createCalendarEvent, ...calendarEvents.value];
|
calendarEvents.value = [res.createCalendarEvent, ...calendarEvents.value];
|
||||||
@@ -2286,7 +2361,6 @@ async function executeFeedAction(card: FeedCard) {
|
|||||||
end: end.toISOString(),
|
end: end.toISOString(),
|
||||||
contact: card.contact,
|
contact: card.contact,
|
||||||
note: "Created from feed action.",
|
note: "Created from feed action.",
|
||||||
status: "planned",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
calendarEvents.value = [res.createCalendarEvent, ...calendarEvents.value];
|
calendarEvents.value = [res.createCalendarEvent, ...calendarEvents.value];
|
||||||
@@ -3340,24 +3414,37 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="entry.kind === 'eventAlert'" class="flex justify-center">
|
<div v-else-if="entry.kind === 'eventLifecycle'" class="flex justify-center">
|
||||||
<article class="w-full max-w-[460px] rounded-xl border border-error/45 bg-error/10 p-3 text-center">
|
<article class="w-full max-w-[460px] rounded-xl border p-3 text-center" :class="eventPhaseToneClass(entry.phase)">
|
||||||
<p class="text-xs text-error/85">{{ formatDay(entry.event.start) }} · {{ formatTime(entry.event.start) }}</p>
|
<p class="text-[11px] uppercase tracking-wide text-base-content/65">{{ eventPhaseLabel(entry.phase) }}</p>
|
||||||
|
<p class="mt-1 text-xs text-base-content/65">{{ formatDay(entry.event.start) }} · {{ formatTime(entry.event.start) }}</p>
|
||||||
<p class="mt-1 text-sm text-base-content/90">{{ entry.event.note || entry.event.title }}</p>
|
<p class="mt-1 text-sm text-base-content/90">{{ entry.event.note || entry.event.title }}</p>
|
||||||
</article>
|
<p v-if="entry.event.archiveNote" class="mt-2 text-xs text-base-content/70">Archive note: {{ entry.event.archiveNote }}</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="entry.kind === 'event'" class="flex justify-center">
|
<div v-if="canManuallyCloseEvent(entry)" class="mt-2">
|
||||||
<article class="w-full max-w-[460px] rounded-xl border border-base-300 bg-base-100 p-3 text-center">
|
<button class="btn btn-xs btn-outline" @click="toggleEventClose(entry.event.id)">
|
||||||
<p class="text-xs text-base-content/65">{{ formatDay(entry.event.start) }} · {{ formatTime(entry.event.start) }}</p>
|
{{ isEventCloseOpen(entry.event.id) ? "Cancel" : "Archive event" }}
|
||||||
<p class="mt-1 text-sm text-base-content/90">{{ entry.event.note || entry.event.title }}</p>
|
</button>
|
||||||
</article>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="entry.kind === 'eventLog'" class="flex justify-center">
|
<div v-if="canManuallyCloseEvent(entry) && isEventCloseOpen(entry.event.id)" class="mt-2 space-y-2 text-left">
|
||||||
<article class="w-full max-w-[460px] rounded-xl border border-base-300 bg-base-100 p-3 text-center">
|
<textarea
|
||||||
<p class="text-xs text-base-content/65">{{ formatDay(entry.event.start) }} · {{ formatTime(entry.event.start) }}</p>
|
v-model="eventCloseDraft[entry.event.id]"
|
||||||
<p class="mt-1 text-sm text-base-content/90">{{ entry.event.note || entry.event.title }}</p>
|
class="textarea textarea-bordered w-full text-xs"
|
||||||
|
rows="3"
|
||||||
|
placeholder="Archive note (optional)"
|
||||||
|
/>
|
||||||
|
<p v-if="eventCloseError[entry.event.id]" class="text-xs text-error">{{ eventCloseError[entry.event.id] }}</p>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button
|
||||||
|
class="btn btn-xs"
|
||||||
|
:disabled="eventCloseSaving[entry.event.id]"
|
||||||
|
@click="archiveEventManually(entry.event)"
|
||||||
|
>
|
||||||
|
{{ eventCloseSaving[entry.event.id] ? "Saving..." : "Confirm archive" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
14
Frontend/graphql/operations/archive-calendar-event.graphql
Normal file
14
Frontend/graphql/operations/archive-calendar-event.graphql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
mutation ArchiveCalendarEventMutation($input: ArchiveCalendarEventInput!) {
|
||||||
|
archiveCalendarEvent(input: $input) {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
start
|
||||||
|
end
|
||||||
|
contact
|
||||||
|
note
|
||||||
|
isArchived
|
||||||
|
createdAt
|
||||||
|
archiveNote
|
||||||
|
archivedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,5 +6,9 @@ mutation CreateCalendarEventMutation($input: CreateCalendarEventInput!) {
|
|||||||
end
|
end
|
||||||
contact
|
contact
|
||||||
note
|
note
|
||||||
|
isArchived
|
||||||
|
createdAt
|
||||||
|
archiveNote
|
||||||
|
archivedAt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ query DashboardQuery {
|
|||||||
end
|
end
|
||||||
contact
|
contact
|
||||||
note
|
note
|
||||||
|
isArchived
|
||||||
|
createdAt
|
||||||
|
archiveNote
|
||||||
|
archivedAt
|
||||||
}
|
}
|
||||||
deals {
|
deals {
|
||||||
id
|
id
|
||||||
|
|||||||
@@ -251,7 +251,9 @@ model CalendarEvent {
|
|||||||
startsAt DateTime
|
startsAt DateTime
|
||||||
endsAt DateTime?
|
endsAt DateTime?
|
||||||
note String?
|
note String?
|
||||||
status String?
|
isArchived Boolean @default(false)
|
||||||
|
archiveNote String?
|
||||||
|
archivedAt DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ type PendingChange =
|
|||||||
start: string;
|
start: string;
|
||||||
end: string | null;
|
end: string | null;
|
||||||
note: string | null;
|
note: string | null;
|
||||||
status: string | null;
|
isArchived: boolean;
|
||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -195,7 +195,7 @@ async function buildCrmSnapshot(input: SnapshotOptions) {
|
|||||||
take: 4,
|
take: 4,
|
||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
select: { id: true, title: true, startsAt: true, endsAt: true, status: true },
|
select: { id: true, title: true, startsAt: true, endsAt: true, isArchived: true },
|
||||||
orderBy: { startsAt: "asc" },
|
orderBy: { startsAt: "asc" },
|
||||||
where: { startsAt: { gte: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000) } },
|
where: { startsAt: { gte: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000) } },
|
||||||
take: 4,
|
take: 4,
|
||||||
@@ -276,7 +276,7 @@ async function buildCrmSnapshot(input: SnapshotOptions) {
|
|||||||
title: e.title,
|
title: e.title,
|
||||||
startsAt: iso(e.startsAt),
|
startsAt: iso(e.startsAt),
|
||||||
endsAt: iso(e.endsAt ?? e.startsAt),
|
endsAt: iso(e.endsAt ?? e.startsAt),
|
||||||
status: e.status,
|
isArchived: e.isArchived,
|
||||||
note: e.note,
|
note: e.note,
|
||||||
contact: e.contact?.name ?? null,
|
contact: e.contact?.name ?? null,
|
||||||
})),
|
})),
|
||||||
@@ -327,7 +327,7 @@ async function buildCrmSnapshot(input: SnapshotOptions) {
|
|||||||
title: e.title,
|
title: e.title,
|
||||||
startsAt: iso(e.startsAt),
|
startsAt: iso(e.startsAt),
|
||||||
endsAt: iso(e.endsAt ?? e.startsAt),
|
endsAt: iso(e.endsAt ?? e.startsAt),
|
||||||
status: e.status,
|
isArchived: e.isArchived,
|
||||||
})),
|
})),
|
||||||
deals: c.deals.map((d) => ({
|
deals: c.deals.map((d) => ({
|
||||||
id: d.id,
|
id: d.id,
|
||||||
@@ -507,7 +507,7 @@ export async function runLangGraphCrmAgentFor(input: {
|
|||||||
start: z.string().optional(),
|
start: z.string().optional(),
|
||||||
end: z.string().optional(),
|
end: z.string().optional(),
|
||||||
note: z.string().optional(),
|
note: z.string().optional(),
|
||||||
status: z.string().optional(),
|
archived: z.boolean().optional(),
|
||||||
channel: z.enum(["Telegram", "WhatsApp", "Instagram", "Phone", "Email"]).optional(),
|
channel: z.enum(["Telegram", "WhatsApp", "Instagram", "Phone", "Email"]).optional(),
|
||||||
kind: z.enum(["message", "call"]).optional(),
|
kind: z.enum(["message", "call"]).optional(),
|
||||||
direction: z.enum(["in", "out"]).optional(),
|
direction: z.enum(["in", "out"]).optional(),
|
||||||
@@ -547,7 +547,7 @@ export async function runLangGraphCrmAgentFor(input: {
|
|||||||
startsAt: new Date(change.start),
|
startsAt: new Date(change.start),
|
||||||
endsAt: change.end ? new Date(change.end) : null,
|
endsAt: change.end ? new Date(change.end) : null,
|
||||||
note: change.note,
|
note: change.note,
|
||||||
status: change.status,
|
isArchived: change.isArchived,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
applied.push({ id: change.id, type: change.type, detail: `created event ${created.id}` });
|
applied.push({ id: change.id, type: change.type, detail: `created event ${created.id}` });
|
||||||
@@ -775,7 +775,7 @@ export async function runLangGraphCrmAgentFor(input: {
|
|||||||
start: iso(start),
|
start: iso(start),
|
||||||
end: end && !Number.isNaN(end.getTime()) ? iso(end) : null,
|
end: end && !Number.isNaN(end.getTime()) ? iso(end) : null,
|
||||||
note: (raw.note ?? "").trim() || null,
|
note: (raw.note ?? "").trim() || null,
|
||||||
status: (raw.status ?? "").trim() || null,
|
isArchived: Boolean(raw.archived),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (raw.mode === "apply") {
|
if (raw.mode === "apply") {
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export async function exportDatasetFromPrismaFor(input: { teamId: string; userId
|
|||||||
orderBy: { occurredAt: "asc" },
|
orderBy: { occurredAt: "asc" },
|
||||||
},
|
},
|
||||||
events: {
|
events: {
|
||||||
select: { title: true, startsAt: true, endsAt: true, status: true, note: true },
|
select: { title: true, startsAt: true, endsAt: true, isArchived: true, note: true },
|
||||||
orderBy: { startsAt: "asc" },
|
orderBy: { startsAt: "asc" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -114,7 +114,7 @@ export async function exportDatasetFromPrismaFor(input: { teamId: string; userId
|
|||||||
title: e.title,
|
title: e.title,
|
||||||
startsAt: e.startsAt,
|
startsAt: e.startsAt,
|
||||||
endsAt: e.endsAt,
|
endsAt: e.endsAt,
|
||||||
status: e.status ?? null,
|
isArchived: e.isArchived,
|
||||||
note: e.note ?? null,
|
note: e.note ?? null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -400,6 +400,10 @@ async function getDashboard(auth: AuthContext | null) {
|
|||||||
end: (e.endsAt ?? e.startsAt).toISOString(),
|
end: (e.endsAt ?? e.startsAt).toISOString(),
|
||||||
contact: e.contact?.name ?? "",
|
contact: e.contact?.name ?? "",
|
||||||
note: e.note ?? "",
|
note: e.note ?? "",
|
||||||
|
isArchived: Boolean(e.isArchived),
|
||||||
|
createdAt: e.createdAt.toISOString(),
|
||||||
|
archiveNote: e.archiveNote ?? "",
|
||||||
|
archivedAt: e.archivedAt?.toISOString() ?? "",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const deals = dealsRaw.map((d) => ({
|
const deals = dealsRaw.map((d) => ({
|
||||||
@@ -471,7 +475,8 @@ async function createCalendarEvent(auth: AuthContext | null, input: {
|
|||||||
end?: string;
|
end?: string;
|
||||||
contact?: string;
|
contact?: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
status?: string;
|
archived?: boolean;
|
||||||
|
archiveNote?: string;
|
||||||
}) {
|
}) {
|
||||||
const ctx = requireAuth(auth);
|
const ctx = requireAuth(auth);
|
||||||
|
|
||||||
@@ -488,15 +493,22 @@ async function createCalendarEvent(auth: AuthContext | null, input: {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const created = await prisma.calendarEvent.create({
|
const created = await prisma.calendarEvent.create({
|
||||||
data: {
|
data: (() => {
|
||||||
teamId: ctx.teamId,
|
const archived = Boolean(input?.archived);
|
||||||
contactId: contact?.id ?? null,
|
const note = (input?.note ?? "").trim() || null;
|
||||||
title,
|
const archiveNote = (input?.archiveNote ?? "").trim() || note;
|
||||||
startsAt: start,
|
return {
|
||||||
endsAt: end && !Number.isNaN(end.getTime()) ? end : null,
|
teamId: ctx.teamId,
|
||||||
note: (input?.note ?? "").trim() || null,
|
contactId: contact?.id ?? null,
|
||||||
status: (input?.status ?? "").trim() || null,
|
title,
|
||||||
},
|
startsAt: start,
|
||||||
|
endsAt: end && !Number.isNaN(end.getTime()) ? end : null,
|
||||||
|
note,
|
||||||
|
isArchived: archived,
|
||||||
|
archiveNote: archived ? archiveNote : null,
|
||||||
|
archivedAt: archived ? new Date() : null,
|
||||||
|
};
|
||||||
|
})(),
|
||||||
include: { contact: { select: { name: true } } },
|
include: { contact: { select: { name: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -507,6 +519,46 @@ async function createCalendarEvent(auth: AuthContext | null, input: {
|
|||||||
end: (created.endsAt ?? created.startsAt).toISOString(),
|
end: (created.endsAt ?? created.startsAt).toISOString(),
|
||||||
contact: created.contact?.name ?? "",
|
contact: created.contact?.name ?? "",
|
||||||
note: created.note ?? "",
|
note: created.note ?? "",
|
||||||
|
isArchived: Boolean(created.isArchived),
|
||||||
|
createdAt: created.createdAt.toISOString(),
|
||||||
|
archiveNote: created.archiveNote ?? "",
|
||||||
|
archivedAt: created.archivedAt?.toISOString() ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function archiveCalendarEvent(auth: AuthContext | null, input: { id: string; archiveNote?: string }) {
|
||||||
|
const ctx = requireAuth(auth);
|
||||||
|
const id = String(input?.id ?? "").trim();
|
||||||
|
const archiveNote = String(input?.archiveNote ?? "").trim();
|
||||||
|
if (!id) throw new Error("id is required");
|
||||||
|
|
||||||
|
const existing = await prisma.calendarEvent.findFirst({
|
||||||
|
where: { id, teamId: ctx.teamId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!existing) throw new Error("event not found");
|
||||||
|
|
||||||
|
const updated = await prisma.calendarEvent.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
isArchived: true,
|
||||||
|
archiveNote: archiveNote || null,
|
||||||
|
archivedAt: new Date(),
|
||||||
|
},
|
||||||
|
include: { contact: { select: { name: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: updated.id,
|
||||||
|
title: updated.title,
|
||||||
|
start: updated.startsAt.toISOString(),
|
||||||
|
end: (updated.endsAt ?? updated.startsAt).toISOString(),
|
||||||
|
contact: updated.contact?.name ?? "",
|
||||||
|
note: updated.note ?? "",
|
||||||
|
isArchived: Boolean(updated.isArchived),
|
||||||
|
createdAt: updated.createdAt.toISOString(),
|
||||||
|
archiveNote: updated.archiveNote ?? "",
|
||||||
|
archivedAt: updated.archivedAt?.toISOString() ?? "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -790,6 +842,7 @@ export const crmGraphqlSchema = buildSchema(`
|
|||||||
logPilotNote(text: String!): MutationResult!
|
logPilotNote(text: String!): MutationResult!
|
||||||
toggleContactPin(contact: String!, text: String!): PinToggleResult!
|
toggleContactPin(contact: String!, text: String!): PinToggleResult!
|
||||||
createCalendarEvent(input: CreateCalendarEventInput!): CalendarEvent!
|
createCalendarEvent(input: CreateCalendarEventInput!): CalendarEvent!
|
||||||
|
archiveCalendarEvent(input: ArchiveCalendarEventInput!): CalendarEvent!
|
||||||
createCommunication(input: CreateCommunicationInput!): MutationWithIdResult!
|
createCommunication(input: CreateCommunicationInput!): MutationWithIdResult!
|
||||||
updateCommunicationTranscript(id: ID!, transcript: [String!]!): MutationWithIdResult!
|
updateCommunicationTranscript(id: ID!, transcript: [String!]!): MutationWithIdResult!
|
||||||
updateFeedDecision(id: ID!, decision: String!, decisionNote: String): MutationWithIdResult!
|
updateFeedDecision(id: ID!, decision: String!, decisionNote: String): MutationWithIdResult!
|
||||||
@@ -815,7 +868,13 @@ export const crmGraphqlSchema = buildSchema(`
|
|||||||
end: String
|
end: String
|
||||||
contact: String
|
contact: String
|
||||||
note: String
|
note: String
|
||||||
status: String
|
archived: Boolean
|
||||||
|
archiveNote: String
|
||||||
|
}
|
||||||
|
|
||||||
|
input ArchiveCalendarEventInput {
|
||||||
|
id: ID!
|
||||||
|
archiveNote: String
|
||||||
}
|
}
|
||||||
|
|
||||||
input CreateCommunicationInput {
|
input CreateCommunicationInput {
|
||||||
@@ -933,6 +992,10 @@ export const crmGraphqlSchema = buildSchema(`
|
|||||||
end: String!
|
end: String!
|
||||||
contact: String!
|
contact: String!
|
||||||
note: String!
|
note: String!
|
||||||
|
isArchived: Boolean!
|
||||||
|
createdAt: String!
|
||||||
|
archiveNote: String!
|
||||||
|
archivedAt: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
type Deal {
|
type Deal {
|
||||||
@@ -1030,9 +1093,12 @@ export const crmGraphqlRoot = {
|
|||||||
toggleContactPin: async (args: { contact: string; text: string }, context: GraphQLContext) =>
|
toggleContactPin: async (args: { contact: string; text: string }, context: GraphQLContext) =>
|
||||||
toggleContactPin(context.auth, args.contact, args.text),
|
toggleContactPin(context.auth, args.contact, args.text),
|
||||||
|
|
||||||
createCalendarEvent: async (args: { input: { title: string; start: string; end?: string; contact?: string; note?: string; status?: string } }, context: GraphQLContext) =>
|
createCalendarEvent: async (args: { input: { title: string; start: string; end?: string; contact?: string; note?: string; archived?: boolean; archiveNote?: string } }, context: GraphQLContext) =>
|
||||||
createCalendarEvent(context.auth, args.input),
|
createCalendarEvent(context.auth, args.input),
|
||||||
|
|
||||||
|
archiveCalendarEvent: async (args: { input: { id: string; archiveNote?: string } }, context: GraphQLContext) =>
|
||||||
|
archiveCalendarEvent(context.auth, args.input),
|
||||||
|
|
||||||
createCommunication: async (
|
createCommunication: async (
|
||||||
args: {
|
args: {
|
||||||
input: {
|
input: {
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ type CalendarSnapshotRow = {
|
|||||||
startsAt: string;
|
startsAt: string;
|
||||||
endsAt: string | null;
|
endsAt: string | null;
|
||||||
note: string | null;
|
note: string | null;
|
||||||
status: string | null;
|
isArchived: boolean;
|
||||||
|
archiveNote: string | null;
|
||||||
|
archivedAt: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ContactNoteSnapshotRow = {
|
type ContactNoteSnapshotRow = {
|
||||||
@@ -101,7 +103,9 @@ export async function captureSnapshot(prisma: PrismaClient, teamId: string): Pro
|
|||||||
startsAt: true,
|
startsAt: true,
|
||||||
endsAt: true,
|
endsAt: true,
|
||||||
note: true,
|
note: true,
|
||||||
status: true,
|
isArchived: true,
|
||||||
|
archiveNote: true,
|
||||||
|
archivedAt: true,
|
||||||
},
|
},
|
||||||
take: 4000,
|
take: 4000,
|
||||||
}),
|
}),
|
||||||
@@ -135,7 +139,9 @@ export async function captureSnapshot(prisma: PrismaClient, teamId: string): Pro
|
|||||||
startsAt: row.startsAt.toISOString(),
|
startsAt: row.startsAt.toISOString(),
|
||||||
endsAt: row.endsAt?.toISOString() ?? null,
|
endsAt: row.endsAt?.toISOString() ?? null,
|
||||||
note: row.note ?? null,
|
note: row.note ?? null,
|
||||||
status: row.status ?? null,
|
isArchived: Boolean(row.isArchived),
|
||||||
|
archiveNote: row.archiveNote ?? null,
|
||||||
|
archivedAt: row.archivedAt?.toISOString() ?? null,
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
@@ -203,7 +209,9 @@ export function buildChangeSet(before: SnapshotState, after: SnapshotState): Cha
|
|||||||
prev.startsAt !== row.startsAt ||
|
prev.startsAt !== row.startsAt ||
|
||||||
prev.endsAt !== row.endsAt ||
|
prev.endsAt !== row.endsAt ||
|
||||||
fmt(prev.note) !== fmt(row.note) ||
|
fmt(prev.note) !== fmt(row.note) ||
|
||||||
fmt(prev.status) !== fmt(row.status) ||
|
prev.isArchived !== row.isArchived ||
|
||||||
|
fmt(prev.archiveNote) !== fmt(row.archiveNote) ||
|
||||||
|
fmt(prev.archivedAt) !== fmt(row.archivedAt) ||
|
||||||
prev.contactId !== row.contactId
|
prev.contactId !== row.contactId
|
||||||
) {
|
) {
|
||||||
items.push({
|
items.push({
|
||||||
@@ -336,7 +344,9 @@ export async function rollbackChangeSet(prisma: PrismaClient, teamId: string, ch
|
|||||||
startsAt: new Date(row.startsAt),
|
startsAt: new Date(row.startsAt),
|
||||||
endsAt: row.endsAt ? new Date(row.endsAt) : null,
|
endsAt: row.endsAt ? new Date(row.endsAt) : null,
|
||||||
note: row.note,
|
note: row.note,
|
||||||
status: row.status,
|
isArchived: row.isArchived,
|
||||||
|
archiveNote: row.archiveNote,
|
||||||
|
archivedAt: row.archivedAt ? new Date(row.archivedAt) : null,
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -346,7 +356,9 @@ export async function rollbackChangeSet(prisma: PrismaClient, teamId: string, ch
|
|||||||
startsAt: new Date(row.startsAt),
|
startsAt: new Date(row.startsAt),
|
||||||
endsAt: row.endsAt ? new Date(row.endsAt) : null,
|
endsAt: row.endsAt ? new Date(row.endsAt) : null,
|
||||||
note: row.note,
|
note: row.note,
|
||||||
status: row.status,
|
isArchived: row.isArchived,
|
||||||
|
archiveNote: row.archiveNote,
|
||||||
|
archivedAt: row.archivedAt ? new Date(row.archivedAt) : null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
@@ -412,4 +424,3 @@ export async function rollbackChangeSet(prisma: PrismaClient, teamId: string, ch
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user