diff --git a/Frontend/app.vue b/Frontend/app.vue index 97171b8..8a01075 100644 --- a/Frontend/app.vue +++ b/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(null); const loginBusy = ref(false); let pilotBackgroundPoll: ReturnType | null = null; +const lifecycleNowMs = ref(Date.now()); +let lifecycleClock: ReturnType | 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("month"); @@ -1615,6 +1673,10 @@ const commEventForm = ref({ durationMinutes: 30, note: "", }); +const eventCloseOpen = ref>({}); +const eventCloseDraft = ref>({}); +const eventCloseSaving = ref>({}); +const eventCloseError = ref>({}); 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") -
-
-

{{ formatDay(entry.event.start) }} · {{ formatTime(entry.event.start) }}

+
+
+

{{ eventPhaseLabel(entry.phase) }}

+

{{ formatDay(entry.event.start) }} · {{ formatTime(entry.event.start) }}

{{ entry.event.note || entry.event.title }}

-
-
+

Archive note: {{ entry.event.archiveNote }}

-
-
-

{{ formatDay(entry.event.start) }} · {{ formatTime(entry.event.start) }}

-

{{ entry.event.note || entry.event.title }}

-
-
+
+ +
-
-
-

{{ formatDay(entry.event.start) }} · {{ formatTime(entry.event.start) }}

-

{{ entry.event.note || entry.event.title }}

+
+