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 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"
|
||||
|
||||
Reference in New Issue
Block a user