calendar: mask scene before level swap after zoom-in fill
This commit is contained in:
@@ -17,6 +17,7 @@ import createChatConversationMutation from "~~/graphql/operations/create-chat-co
|
|||||||
import selectChatConversationMutation from "~~/graphql/operations/select-chat-conversation.graphql?raw";
|
import selectChatConversationMutation from "~~/graphql/operations/select-chat-conversation.graphql?raw";
|
||||||
import archiveChatConversationMutation from "~~/graphql/operations/archive-chat-conversation.graphql?raw";
|
import archiveChatConversationMutation from "~~/graphql/operations/archive-chat-conversation.graphql?raw";
|
||||||
import toggleContactPinMutation from "~~/graphql/operations/toggle-contact-pin.graphql?raw";
|
import toggleContactPinMutation from "~~/graphql/operations/toggle-contact-pin.graphql?raw";
|
||||||
|
import setContactInboxHiddenMutation from "~~/graphql/operations/set-contact-inbox-hidden.graphql?raw";
|
||||||
import confirmLatestChangeSetMutation from "~~/graphql/operations/confirm-latest-change-set.graphql?raw";
|
import confirmLatestChangeSetMutation from "~~/graphql/operations/confirm-latest-change-set.graphql?raw";
|
||||||
import rollbackLatestChangeSetMutation from "~~/graphql/operations/rollback-latest-change-set.graphql?raw";
|
import rollbackLatestChangeSetMutation from "~~/graphql/operations/rollback-latest-change-set.graphql?raw";
|
||||||
import rollbackChangeSetItemsMutation from "~~/graphql/operations/rollback-change-set-items.graphql?raw";
|
import rollbackChangeSetItemsMutation from "~~/graphql/operations/rollback-change-set-items.graphql?raw";
|
||||||
@@ -79,6 +80,9 @@ type CommItem = {
|
|||||||
id: string;
|
id: string;
|
||||||
at: string;
|
at: string;
|
||||||
contact: string;
|
contact: string;
|
||||||
|
contactInboxId: string;
|
||||||
|
sourceExternalId: string;
|
||||||
|
sourceTitle: string;
|
||||||
channel: "Telegram" | "WhatsApp" | "Instagram" | "Phone" | "Email";
|
channel: "Telegram" | "WhatsApp" | "Instagram" | "Phone" | "Email";
|
||||||
kind: "message" | "call";
|
kind: "message" | "call";
|
||||||
direction: "in" | "out";
|
direction: "in" | "out";
|
||||||
@@ -89,6 +93,18 @@ type CommItem = {
|
|||||||
deliveryStatus?: "PENDING" | "SENT" | "DELIVERED" | "READ" | "FAILED" | string | null;
|
deliveryStatus?: "PENDING" | "SENT" | "DELIVERED" | "READ" | "FAILED" | string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ContactInbox = {
|
||||||
|
id: string;
|
||||||
|
contactId: string;
|
||||||
|
contactName: string;
|
||||||
|
channel: CommItem["channel"];
|
||||||
|
sourceExternalId: string;
|
||||||
|
title: string;
|
||||||
|
isHidden: boolean;
|
||||||
|
lastMessageAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
type CommPin = {
|
type CommPin = {
|
||||||
id: string;
|
id: string;
|
||||||
contact: string;
|
contact: string;
|
||||||
@@ -302,6 +318,7 @@ const contacts = ref<Contact[]>([]);
|
|||||||
const calendarEvents = ref<CalendarEvent[]>([]);
|
const calendarEvents = ref<CalendarEvent[]>([]);
|
||||||
|
|
||||||
const commItems = ref<CommItem[]>([]);
|
const commItems = ref<CommItem[]>([]);
|
||||||
|
const contactInboxes = ref<ContactInbox[]>([]);
|
||||||
|
|
||||||
const commPins = ref<CommPin[]>([]);
|
const commPins = ref<CommPin[]>([]);
|
||||||
|
|
||||||
@@ -952,6 +969,7 @@ async function refreshCrmData() {
|
|||||||
dashboard: {
|
dashboard: {
|
||||||
contacts: Contact[];
|
contacts: Contact[];
|
||||||
communications: CommItem[];
|
communications: CommItem[];
|
||||||
|
contactInboxes: ContactInbox[];
|
||||||
calendar: CalendarEvent[];
|
calendar: CalendarEvent[];
|
||||||
deals: Deal[];
|
deals: Deal[];
|
||||||
feed: FeedCard[];
|
feed: FeedCard[];
|
||||||
@@ -962,6 +980,7 @@ async function refreshCrmData() {
|
|||||||
|
|
||||||
contacts.value = data.dashboard.contacts ?? [];
|
contacts.value = data.dashboard.contacts ?? [];
|
||||||
commItems.value = data.dashboard.communications ?? [];
|
commItems.value = data.dashboard.communications ?? [];
|
||||||
|
contactInboxes.value = data.dashboard.contactInboxes ?? [];
|
||||||
calendarEvents.value = data.dashboard.calendar ?? [];
|
calendarEvents.value = data.dashboard.calendar ?? [];
|
||||||
deals.value = data.dashboard.deals ?? [];
|
deals.value = data.dashboard.deals ?? [];
|
||||||
feedCards.value = data.dashboard.feed ?? [];
|
feedCards.value = data.dashboard.feed ?? [];
|
||||||
@@ -2696,11 +2715,16 @@ async function animateCalendarZoomIn(sourceElement: HTMLElement | null, ghost: C
|
|||||||
durationMs: CALENDAR_ZOOM_DURATION_MS,
|
durationMs: CALENDAR_ZOOM_DURATION_MS,
|
||||||
};
|
};
|
||||||
await waitCalendarCameraTransition();
|
await waitCalendarCameraTransition();
|
||||||
|
// Freeze the filled block frame, then swap level while scene is masked.
|
||||||
|
// This keeps the "zoom into block -> reveal next grid inside" sequence.
|
||||||
|
calendarSceneMasked.value = true;
|
||||||
|
await nextAnimationFrame();
|
||||||
apply();
|
apply();
|
||||||
await nextTick();
|
await nextTick();
|
||||||
} finally {
|
} finally {
|
||||||
await resetCalendarCamera();
|
await resetCalendarCamera();
|
||||||
calendarZoomGhost.value = null;
|
calendarZoomGhost.value = null;
|
||||||
|
calendarSceneMasked.value = false;
|
||||||
calendarZoomBusy.value = false;
|
calendarZoomBusy.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3324,6 +3348,7 @@ const commThreads = computed(() => {
|
|||||||
items,
|
items,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
.filter((thread) => thread.items.length > 0)
|
||||||
.sort((a, b) => b.lastAt.localeCompare(a.lastAt));
|
.sort((a, b) => b.lastAt.localeCompare(a.lastAt));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3426,6 +3451,7 @@ const commDocumentForm = ref<{
|
|||||||
}>({
|
}>({
|
||||||
title: "",
|
title: "",
|
||||||
});
|
});
|
||||||
|
const inboxToggleLoadingById = ref<Record<string, boolean>>({});
|
||||||
const eventCloseOpen = ref<Record<string, boolean>>({});
|
const eventCloseOpen = ref<Record<string, boolean>>({});
|
||||||
const eventCloseDraft = ref<Record<string, string>>({});
|
const eventCloseDraft = ref<Record<string, string>>({});
|
||||||
const eventCloseSaving = ref<Record<string, boolean>>({});
|
const eventCloseSaving = ref<Record<string, boolean>>({});
|
||||||
@@ -3453,6 +3479,7 @@ watch(selectedCommThreadId, () => {
|
|||||||
commPinContextMenu.value = { open: false, x: 0, y: 0, entry: null };
|
commPinContextMenu.value = { open: false, x: 0, y: 0, entry: null };
|
||||||
commEventError.value = "";
|
commEventError.value = "";
|
||||||
commDocumentForm.value = { title: "" };
|
commDocumentForm.value = { title: "" };
|
||||||
|
inboxToggleLoadingById.value = {};
|
||||||
eventCloseOpen.value = {};
|
eventCloseOpen.value = {};
|
||||||
eventCloseDraft.value = {};
|
eventCloseDraft.value = {};
|
||||||
eventCloseSaving.value = {};
|
eventCloseSaving.value = {};
|
||||||
@@ -3488,6 +3515,26 @@ const selectedCommPins = computed(() => {
|
|||||||
return commPins.value.filter((item) => item.contact === selectedCommThread.value?.contact);
|
return commPins.value.filter((item) => item.contact === selectedCommThread.value?.contact);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const selectedThreadInboxes = computed(() => {
|
||||||
|
if (!selectedCommThread.value) return [];
|
||||||
|
const all = contactInboxes.value.filter((inbox) => inbox.contactId === selectedCommThread.value?.id);
|
||||||
|
return all.sort((a, b) => {
|
||||||
|
const aTime = a.lastMessageAt || a.updatedAt;
|
||||||
|
const bTime = b.lastMessageAt || b.updatedAt;
|
||||||
|
return bTime.localeCompare(aTime);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const hiddenContactInboxes = computed(() =>
|
||||||
|
contactInboxes.value
|
||||||
|
.filter((inbox) => inbox.isHidden)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aTime = a.lastMessageAt || a.updatedAt;
|
||||||
|
const bTime = b.lastMessageAt || b.updatedAt;
|
||||||
|
return bTime.localeCompare(aTime);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const selectedCommLifecycleEvents = computed(() => {
|
const selectedCommLifecycleEvents = computed(() => {
|
||||||
if (!selectedCommThread.value) return [];
|
if (!selectedCommThread.value) return [];
|
||||||
const nowMs = lifecycleNowMs.value;
|
const nowMs = lifecycleNowMs.value;
|
||||||
@@ -4215,6 +4262,34 @@ function channelIcon(channel: "All" | CommItem["channel"]) {
|
|||||||
return "phone";
|
return "phone";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatInboxLabel(inbox: ContactInbox) {
|
||||||
|
const title = String(inbox.title ?? "").trim();
|
||||||
|
if (title) return `${inbox.channel} · ${title}`;
|
||||||
|
const source = String(inbox.sourceExternalId ?? "").trim();
|
||||||
|
if (!source) return inbox.channel;
|
||||||
|
const tail = source.length > 18 ? source.slice(-18) : source;
|
||||||
|
return `${inbox.channel} · ${tail}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInboxToggleLoading(inboxId: string) {
|
||||||
|
return Boolean(inboxToggleLoadingById.value[inboxId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setInboxHidden(inboxId: string, hidden: boolean) {
|
||||||
|
const id = String(inboxId ?? "").trim();
|
||||||
|
if (!id || isInboxToggleLoading(id)) return;
|
||||||
|
inboxToggleLoadingById.value = { ...inboxToggleLoadingById.value, [id]: true };
|
||||||
|
try {
|
||||||
|
await gqlFetch<{ setContactInboxHidden: { ok: boolean } }>(setContactInboxHiddenMutation, {
|
||||||
|
inboxId: id,
|
||||||
|
hidden,
|
||||||
|
});
|
||||||
|
await refreshCrmData();
|
||||||
|
} finally {
|
||||||
|
inboxToggleLoadingById.value = { ...inboxToggleLoadingById.value, [id]: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function messageDeliveryUiState(item: CommItem): "none" | "sending" | "sent" | "delivered" | "failed" {
|
function messageDeliveryUiState(item: CommItem): "none" | "sending" | "sent" | "delivered" | "failed" {
|
||||||
if (item.kind !== "message" || item.direction !== "out") return "none";
|
if (item.kind !== "message" || item.direction !== "out") return "none";
|
||||||
const rawStatus = String(item.deliveryStatus ?? "").toUpperCase();
|
const rawStatus = String(item.deliveryStatus ?? "").toUpperCase();
|
||||||
@@ -5486,6 +5561,31 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="min-h-0 flex-1 overflow-y-auto p-0">
|
<div class="min-h-0 flex-1 overflow-y-auto p-0">
|
||||||
|
<div
|
||||||
|
v-if="peopleListMode === 'contacts' && hiddenContactInboxes.length"
|
||||||
|
class="border-b border-base-300 px-3 py-2"
|
||||||
|
>
|
||||||
|
<p class="mb-1 text-[10px] font-semibold uppercase tracking-wide text-base-content/55">Hidden sources</p>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div
|
||||||
|
v-for="inbox in hiddenContactInboxes.slice(0, 30)"
|
||||||
|
:key="`hidden-inbox-${inbox.id}`"
|
||||||
|
class="flex items-center justify-between gap-2 rounded-lg border border-base-300 bg-base-100 px-2 py-1"
|
||||||
|
>
|
||||||
|
<p class="min-w-0 flex-1 truncate text-[10px] text-base-content/75">
|
||||||
|
{{ inbox.contactName }} · {{ formatInboxLabel(inbox) }}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-xs h-5 min-h-5 px-1"
|
||||||
|
:disabled="isInboxToggleLoading(inbox.id)"
|
||||||
|
@click="setInboxHidden(inbox.id, false)"
|
||||||
|
>
|
||||||
|
{{ isInboxToggleLoading(inbox.id) ? "..." : "Show" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
v-if="peopleListMode === 'contacts'"
|
v-if="peopleListMode === 'contacts'"
|
||||||
v-for="thread in peopleContactList"
|
v-for="thread in peopleContactList"
|
||||||
@@ -5718,6 +5818,30 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
|
|||||||
<span class="shrink-0 text-xs text-base-content/75">{{ selectedCommPinnedStream.length }}</span>
|
<span class="shrink-0 text-xs text-base-content/75">{{ selectedCommPinnedStream.length }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div v-if="selectedThreadInboxes.length" class="mb-2 flex flex-wrap items-center gap-1.5">
|
||||||
|
<div
|
||||||
|
v-for="inbox in selectedThreadInboxes"
|
||||||
|
:key="`inbox-chip-${inbox.id}`"
|
||||||
|
class="inline-flex items-center gap-1 rounded-full border border-base-300 bg-base-100 px-2 py-1 text-[10px]"
|
||||||
|
:class="inbox.isHidden ? 'opacity-60' : ''"
|
||||||
|
>
|
||||||
|
<span class="truncate max-w-[180px]">{{ formatInboxLabel(inbox) }}</span>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-xs h-5 min-h-5 px-1"
|
||||||
|
:disabled="isInboxToggleLoading(inbox.id)"
|
||||||
|
@click="setInboxHidden(inbox.id, !inbox.isHidden)"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
isInboxToggleLoading(inbox.id)
|
||||||
|
? "..."
|
||||||
|
: inbox.isHidden
|
||||||
|
? "Show"
|
||||||
|
: "Hide"
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-for="entry in (commPinnedOnly ? selectedCommPinnedStream : threadStreamItems)"
|
v-for="entry in (commPinnedOnly ? selectedCommPinnedStream : threadStreamItems)"
|
||||||
:key="entry.id"
|
:key="entry.id"
|
||||||
|
|||||||
Reference in New Issue
Block a user