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 logPilotNoteMutation from "./graphql/operations/log-pilot-note.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 updateCommunicationTranscriptMutation from "./graphql/operations/update-communication-transcript.graphql?raw";
|
||||
import updateFeedDecisionMutation from "./graphql/operations/update-feed-decision.graphql?raw";
|
||||
@@ -58,8 +59,14 @@ type CalendarEvent = {
|
||||
end: string;
|
||||
contact: string;
|
||||
note: string;
|
||||
isArchived: boolean;
|
||||
createdAt: string;
|
||||
archiveNote: string;
|
||||
archivedAt: string;
|
||||
};
|
||||
|
||||
type EventLifecyclePhase = "scheduled" | "due_soon" | "awaiting_outcome" | "closed";
|
||||
|
||||
type CommItem = {
|
||||
id: string;
|
||||
at: string;
|
||||
@@ -175,6 +182,48 @@ function endAfter(startIso: string, minutes: number) {
|
||||
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) {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, "0");
|
||||
@@ -337,6 +386,8 @@ const loginPassword = ref("");
|
||||
const loginError = ref<string | null>(null);
|
||||
const loginBusy = ref(false);
|
||||
let pilotBackgroundPoll: ReturnType<typeof setInterval> | null = null;
|
||||
const lifecycleNowMs = ref(Date.now());
|
||||
let lifecycleClock: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
watch(
|
||||
() => authMe.value?.conversation.id,
|
||||
@@ -1150,6 +1201,9 @@ onMounted(() => {
|
||||
typeof navigator !== "undefined" &&
|
||||
typeof MediaRecorder !== "undefined" &&
|
||||
Boolean(navigator.mediaDevices?.getUserMedia);
|
||||
lifecycleClock = setInterval(() => {
|
||||
lifecycleNowMs.value = Date.now();
|
||||
}, 15000);
|
||||
|
||||
if (!authResolved.value) {
|
||||
void bootstrapSession().finally(() => {
|
||||
@@ -1177,6 +1231,10 @@ onBeforeUnmount(() => {
|
||||
pilotRecorderStream = null;
|
||||
}
|
||||
stopPilotBackgroundPolling();
|
||||
if (lifecycleClock) {
|
||||
clearInterval(lifecycleClock);
|
||||
lifecycleClock = null;
|
||||
}
|
||||
});
|
||||
|
||||
const calendarView = ref<CalendarView>("month");
|
||||
@@ -1615,6 +1673,10 @@ const commEventForm = ref({
|
||||
durationMinutes: 30,
|
||||
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, () => {
|
||||
destroyAllCommCallWaves();
|
||||
@@ -1626,6 +1688,10 @@ watch(selectedCommThreadId, () => {
|
||||
commDraft.value = "";
|
||||
commEventModalOpen.value = false;
|
||||
commEventError.value = "";
|
||||
eventCloseOpen.value = {};
|
||||
eventCloseDraft.value = {};
|
||||
eventCloseSaving.value = {};
|
||||
eventCloseError.value = {};
|
||||
const fallback = selectedCommThread.value?.channels.find((channel) => channel !== "Phone") ?? "Telegram";
|
||||
commSendChannel.value = fallback;
|
||||
});
|
||||
@@ -1654,40 +1720,22 @@ const selectedCommPins = computed(() => {
|
||||
return commPins.value.filter((item) => item.contact === selectedCommThread.value?.contact);
|
||||
});
|
||||
|
||||
const selectedCommEvents = computed(() => {
|
||||
const selectedCommLifecycleEvents = computed(() => {
|
||||
if (!selectedCommThread.value) return [];
|
||||
const now = new Date();
|
||||
const nowMs = lifecycleNowMs.value;
|
||||
|
||||
return sortedEvents.value
|
||||
.filter((event) => event.contact === selectedCommThread.value?.contact)
|
||||
.filter((event) => new Date(event.end) >= now)
|
||||
.slice(0, 6);
|
||||
});
|
||||
|
||||
const selectedCommPastEvents = computed(() => {
|
||||
if (!selectedCommThread.value) return [];
|
||||
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;
|
||||
.map((event) => {
|
||||
const phase = eventLifecyclePhase(event, nowMs);
|
||||
return {
|
||||
event,
|
||||
phase,
|
||||
timelineAt: eventTimelineAt(event, phase),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.start.localeCompare(b.start))[0];
|
||||
|
||||
return event ?? null;
|
||||
.sort((a, b) => a.timelineAt.localeCompare(b.timelineAt))
|
||||
.slice(-12);
|
||||
});
|
||||
|
||||
const threadStreamItems = computed(() => {
|
||||
@@ -1702,20 +1750,9 @@ const threadStreamItems = computed(() => {
|
||||
| {
|
||||
id: string;
|
||||
at: string;
|
||||
kind: "eventAlert";
|
||||
event: CalendarEvent;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
at: string;
|
||||
kind: "event";
|
||||
event: CalendarEvent;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
at: string;
|
||||
kind: "eventLog";
|
||||
kind: "eventLifecycle";
|
||||
event: CalendarEvent;
|
||||
phase: EventLifecyclePhase;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
@@ -1725,31 +1762,13 @@ const threadStreamItems = computed(() => {
|
||||
}
|
||||
> = [];
|
||||
|
||||
if (selectedCommUrgentEvent.value) {
|
||||
for (const entry of selectedCommLifecycleEvents.value) {
|
||||
centeredRows.push({
|
||||
id: `event-alert-${selectedCommUrgentEvent.value.id}`,
|
||||
at: selectedCommUrgentEvent.value.start,
|
||||
kind: "eventAlert",
|
||||
event: selectedCommUrgentEvent.value,
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
id: `event-${entry.event.id}`,
|
||||
at: entry.timelineAt,
|
||||
kind: "eventLifecycle",
|
||||
event: entry.event,
|
||||
phase: entry.phase,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1779,11 +1798,22 @@ const selectedCommPinnedStream = computed(() => {
|
||||
text: pin.text,
|
||||
}));
|
||||
|
||||
const events = selectedCommEvents.value.map((event) => ({
|
||||
id: `event-${event.id}`,
|
||||
kind: "event" as const,
|
||||
event,
|
||||
}));
|
||||
const rank = (phase: EventLifecyclePhase) => {
|
||||
if (phase === "awaiting_outcome") return 0;
|
||||
if (phase === "due_soon") return 1;
|
||||
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];
|
||||
});
|
||||
@@ -1815,13 +1845,57 @@ function entryPinText(entry: any): string {
|
||||
if (!entry) return "";
|
||||
if (entry.kind === "pin") return normalizePinText(stripPinnedPrefix(entry.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 || "");
|
||||
}
|
||||
if (entry.kind === "call") 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) {
|
||||
if (commPinToggling.value) return;
|
||||
const contactName = String(contact ?? "").trim();
|
||||
@@ -2219,7 +2293,8 @@ async function createCommEvent() {
|
||||
end: end.toISOString(),
|
||||
contact: selectedCommThread.value.contact,
|
||||
note,
|
||||
status: commEventMode.value === "logged" ? "done" : "planned",
|
||||
archived: commEventMode.value === "logged",
|
||||
archiveNote: commEventMode.value === "logged" ? note : undefined,
|
||||
},
|
||||
});
|
||||
calendarEvents.value = [res.createCalendarEvent, ...calendarEvents.value];
|
||||
@@ -2286,7 +2361,6 @@ async function executeFeedAction(card: FeedCard) {
|
||||
end: end.toISOString(),
|
||||
contact: card.contact,
|
||||
note: "Created from feed action.",
|
||||
status: "planned",
|
||||
},
|
||||
});
|
||||
calendarEvents.value = [res.createCalendarEvent, ...calendarEvents.value];
|
||||
@@ -3340,24 +3414,37 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="entry.kind === 'eventAlert'" class="flex justify-center">
|
||||
<article class="w-full max-w-[460px] rounded-xl border border-error/45 bg-error/10 p-3 text-center">
|
||||
<p class="text-xs text-error/85">{{ formatDay(entry.event.start) }} · {{ formatTime(entry.event.start) }}</p>
|
||||
<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)">
|
||||
<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>
|
||||
</article>
|
||||
</div>
|
||||
<p v-if="entry.event.archiveNote" class="mt-2 text-xs text-base-content/70">Archive note: {{ entry.event.archiveNote }}</p>
|
||||
|
||||
<div v-else-if="entry.kind === 'event'" class="flex justify-center">
|
||||
<article class="w-full max-w-[460px] rounded-xl border border-base-300 bg-base-100 p-3 text-center">
|
||||
<p class="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>
|
||||
</article>
|
||||
</div>
|
||||
<div v-if="canManuallyCloseEvent(entry)" class="mt-2">
|
||||
<button class="btn btn-xs btn-outline" @click="toggleEventClose(entry.event.id)">
|
||||
{{ isEventCloseOpen(entry.event.id) ? "Cancel" : "Archive event" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="entry.kind === 'eventLog'" class="flex justify-center">
|
||||
<article class="w-full max-w-[460px] rounded-xl border border-base-300 bg-base-100 p-3 text-center">
|
||||
<p class="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>
|
||||
<div v-if="canManuallyCloseEvent(entry) && isEventCloseOpen(entry.event.id)" class="mt-2 space-y-2 text-left">
|
||||
<textarea
|
||||
v-model="eventCloseDraft[entry.event.id]"
|
||||
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>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user