calendar: mask scene before level swap after zoom-in fill

This commit is contained in:
Ruslan Bakiev
2026-02-23 10:37:26 +07:00
parent 23d8035571
commit 6bc154a1e6

View File

@@ -17,6 +17,7 @@ import createChatConversationMutation from "~~/graphql/operations/create-chat-co
import selectChatConversationMutation from "~~/graphql/operations/select-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 setContactInboxHiddenMutation from "~~/graphql/operations/set-contact-inbox-hidden.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 rollbackChangeSetItemsMutation from "~~/graphql/operations/rollback-change-set-items.graphql?raw";
@@ -79,6 +80,9 @@ type CommItem = {
id: string;
at: string;
contact: string;
contactInboxId: string;
sourceExternalId: string;
sourceTitle: string;
channel: "Telegram" | "WhatsApp" | "Instagram" | "Phone" | "Email";
kind: "message" | "call";
direction: "in" | "out";
@@ -89,6 +93,18 @@ type CommItem = {
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 = {
id: string;
contact: string;
@@ -302,6 +318,7 @@ const contacts = ref<Contact[]>([]);
const calendarEvents = ref<CalendarEvent[]>([]);
const commItems = ref<CommItem[]>([]);
const contactInboxes = ref<ContactInbox[]>([]);
const commPins = ref<CommPin[]>([]);
@@ -952,6 +969,7 @@ async function refreshCrmData() {
dashboard: {
contacts: Contact[];
communications: CommItem[];
contactInboxes: ContactInbox[];
calendar: CalendarEvent[];
deals: Deal[];
feed: FeedCard[];
@@ -962,6 +980,7 @@ async function refreshCrmData() {
contacts.value = data.dashboard.contacts ?? [];
commItems.value = data.dashboard.communications ?? [];
contactInboxes.value = data.dashboard.contactInboxes ?? [];
calendarEvents.value = data.dashboard.calendar ?? [];
deals.value = data.dashboard.deals ?? [];
feedCards.value = data.dashboard.feed ?? [];
@@ -2696,11 +2715,16 @@ async function animateCalendarZoomIn(sourceElement: HTMLElement | null, ghost: C
durationMs: CALENDAR_ZOOM_DURATION_MS,
};
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();
await nextTick();
} finally {
await resetCalendarCamera();
calendarZoomGhost.value = null;
calendarSceneMasked.value = false;
calendarZoomBusy.value = false;
}
}
@@ -3324,6 +3348,7 @@ const commThreads = computed(() => {
items,
};
})
.filter((thread) => thread.items.length > 0)
.sort((a, b) => b.lastAt.localeCompare(a.lastAt));
});
@@ -3426,6 +3451,7 @@ const commDocumentForm = ref<{
}>({
title: "",
});
const inboxToggleLoadingById = ref<Record<string, boolean>>({});
const eventCloseOpen = ref<Record<string, boolean>>({});
const eventCloseDraft = ref<Record<string, string>>({});
const eventCloseSaving = ref<Record<string, boolean>>({});
@@ -3453,6 +3479,7 @@ watch(selectedCommThreadId, () => {
commPinContextMenu.value = { open: false, x: 0, y: 0, entry: null };
commEventError.value = "";
commDocumentForm.value = { title: "" };
inboxToggleLoadingById.value = {};
eventCloseOpen.value = {};
eventCloseDraft.value = {};
eventCloseSaving.value = {};
@@ -3488,6 +3515,26 @@ const selectedCommPins = computed(() => {
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(() => {
if (!selectedCommThread.value) return [];
const nowMs = lifecycleNowMs.value;
@@ -4215,6 +4262,34 @@ function channelIcon(channel: "All" | CommItem["channel"]) {
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" {
if (item.kind !== "message" || item.direction !== "out") return "none";
const rawStatus = String(item.deliveryStatus ?? "").toUpperCase();
@@ -5486,6 +5561,31 @@ async function decideFeedCard(card: FeedCard, decision: "accepted" | "rejected")
</div>
<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
v-if="peopleListMode === 'contacts'"
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>
</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
v-for="entry in (commPinnedOnly ? selectedCommPinnedStream : threadStreamItems)"
:key="entry.id"