Files
clientsflow/frontend/app/components/workspace/communications/CrmCommunicationsListSidebar.vue
Ruslan Bakiev 5492e0d05c feat: unread message tracking with blue dot indicator
Add ContactThreadRead model to track when users last viewed each contact thread.
Contacts with messages newer than the last read time show a blue dot in the sidebar.
Opening a thread automatically marks it as read via markThreadRead mutation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 20:25:32 +07:00

186 lines
8.4 KiB
Vue

<script setup lang="ts">
type PeopleListMode = "contacts" | "deals";
defineProps<{
peopleListMode: PeopleListMode;
peopleSearch: string;
peopleSortOptions: Array<{ value: string; label: string }>;
peopleSortMode: string;
peopleVisibilityOptions: Array<{ value: string; label: string }>;
peopleVisibilityMode: string;
peopleContactList: any[];
selectedCommThreadId: string;
isReviewHighlightedContact: (contactId: string) => boolean;
openCommunicationThread: (contactName: string) => void;
avatarSrcForThread: (thread: any) => string;
markAvatarBroken: (threadId: string) => void;
contactInitials: (contactName: string) => string;
formatThreadTime: (iso: string) => string;
threadChannelLabel: (thread: any) => string;
peopleDealList: any[];
selectedDealId: string;
isReviewHighlightedDeal: (dealId: string) => boolean;
openDealThread: (deal: any) => void;
getDealCurrentStepLabel: (deal: any) => string;
onPeopleListModeChange: (mode: PeopleListMode) => void;
onPeopleSearchInput: (value: string) => void;
onPeopleSortModeChange: (mode: string) => void;
onPeopleVisibilityModeChange: (mode: string) => void;
}>();
function onSearchInput(event: Event) {
const target = event.target as HTMLInputElement | null;
onPeopleSearchInput(target?.value ?? "");
}
</script>
<template>
<aside class="h-full min-h-0 border-r border-base-300 flex flex-col md:row-span-2">
<div class="sticky top-0 z-20 h-12 border-b border-base-300 bg-base-100 px-2">
<div class="flex h-full items-center gap-1">
<div class="join rounded-lg border border-base-300 overflow-hidden">
<button
class="btn btn-ghost btn-sm join-item rounded-none"
:class="peopleListMode === 'contacts' ? 'bg-base-200/80 text-base-content' : 'text-base-content/65 hover:text-base-content'"
title="Contacts"
@click="onPeopleListModeChange('contacts')"
>
<svg viewBox="0 0 24 24" class="h-4 w-4 fill-current">
<path d="M12 12a5 5 0 1 0-5-5 5 5 0 0 0 5 5m0 2c-4.42 0-8 2.24-8 5v1h16v-1c0-2.76-3.58-5-8-5" />
</svg>
</button>
<button
class="btn btn-ghost btn-sm join-item rounded-none border-l border-base-300/70"
:class="peopleListMode === 'deals' ? 'bg-base-200/80 text-base-content' : 'text-base-content/65 hover:text-base-content'"
title="Deals"
@click="onPeopleListModeChange('deals')"
>
<svg viewBox="0 0 24 24" class="h-4 w-4 fill-current">
<path d="M10 3h4a2 2 0 0 1 2 2v2h3a2 2 0 0 1 2 2v3H3V9a2 2 0 0 1 2-2h3V5a2 2 0 0 1 2-2m0 4h4V5h-4zm11 7v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-5h7v2h4v-2z" />
</svg>
</button>
</div>
<input
:value="peopleSearch"
type="text"
class="input input-bordered input-sm w-full"
:placeholder="peopleListMode === 'contacts' ? 'Search contacts' : 'Search deals'"
@input="onSearchInput"
/>
<div class="dropdown dropdown-end">
<button
tabindex="0"
class="btn btn-ghost btn-sm btn-square"
:title="peopleListMode === 'contacts' ? 'Sort contacts' : 'Sort deals'"
>
<svg viewBox="0 0 24 24" class="h-4 w-4 fill-current">
<path d="M3 5h18v2H3zm3 6h12v2H6zm4 6h4v2h-4z" />
</svg>
</button>
<div tabindex="0" class="dropdown-content z-20 mt-2 w-52 rounded-xl border border-base-300 bg-base-100 p-2 shadow-lg">
<template v-if="peopleListMode === 'contacts'">
<p class="px-2 pb-1 text-[11px] font-semibold uppercase tracking-wide text-base-content/55">Sort contacts</p>
<button
v-for="option in peopleSortOptions"
:key="`people-sort-${option.value}`"
class="btn btn-ghost btn-sm w-full justify-between"
@click="onPeopleSortModeChange(option.value)"
>
<span>{{ option.label }}</span>
<span v-if="peopleSortMode === option.value"></span>
</button>
<div class="my-1 h-px bg-base-300/70" />
<p class="px-2 pb-1 text-[11px] font-semibold uppercase tracking-wide text-base-content/55">Filter contacts</p>
<button
v-for="option in peopleVisibilityOptions"
:key="`people-visibility-${option.value}`"
class="btn btn-ghost btn-sm w-full justify-between"
@click="onPeopleVisibilityModeChange(option.value)"
>
<span>{{ option.label }}</span>
<span v-if="peopleVisibilityMode === option.value"></span>
</button>
</template>
<p v-else class="px-2 py-1 text-xs text-base-content/60">Deals are sorted by title.</p>
</div>
</div>
</div>
</div>
<div class="min-h-0 flex-1 overflow-y-auto p-0">
<div
v-if="peopleListMode === 'contacts'"
v-for="thread in peopleContactList"
:key="thread.id"
class="w-full border-b border-base-300 px-3 py-2 text-left transition hover:bg-base-200/40"
:class="[
selectedCommThreadId === thread.id ? 'bg-primary/10' : '',
isReviewHighlightedContact(thread.id) ? 'bg-primary/10 ring-1 ring-primary/45' : '',
]"
@click="openCommunicationThread(thread.contact)"
role="button"
tabindex="0"
@keydown.enter.prevent="openCommunicationThread(thread.contact)"
@keydown.space.prevent="openCommunicationThread(thread.contact)"
>
<div class="flex items-start gap-2">
<div class="avatar shrink-0">
<div class="h-8 w-8 rounded-full ring-1 ring-base-300/70">
<img
v-if="avatarSrcForThread(thread)"
:src="avatarSrcForThread(thread)"
:alt="thread.contact"
@error="markAvatarBroken(thread.id)"
/>
<span v-else class="flex h-full w-full items-center justify-center text-[10px] font-semibold text-base-content/65">
{{ contactInitials(thread.contact) }}
</span>
</div>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-2">
<div class="flex min-w-0 flex-1 items-center gap-1">
<span v-if="thread.hasUnread" class="h-2 w-2 shrink-0 rounded-full bg-primary" />
<p class="min-w-0 flex-1 truncate text-xs" :class="thread.hasUnread ? 'font-bold' : 'font-semibold'">{{ thread.contact }}</p>
</div>
<span class="shrink-0 text-[10px]" :class="thread.hasUnread ? 'font-semibold text-primary' : 'text-base-content/55'">{{ formatThreadTime(thread.lastAt) }}</span>
</div>
<p class="mt-0.5 min-w-0 truncate text-[11px]" :class="thread.hasUnread ? 'font-semibold text-base-content' : 'text-base-content/75'">
{{ thread.lastText || threadChannelLabel(thread) }}
</p>
</div>
</div>
</div>
<button
v-if="peopleListMode === 'deals'"
v-for="deal in peopleDealList"
:key="deal.id"
class="w-full border-b border-base-300 px-3 py-2 text-left transition hover:bg-base-200/40"
:class="[
selectedDealId === deal.id ? 'bg-primary/10' : '',
isReviewHighlightedDeal(deal.id) ? 'bg-primary/10 ring-1 ring-primary/45' : '',
]"
@click="openDealThread(deal)"
>
<div class="flex items-start justify-between gap-2">
<p class="truncate text-xs font-semibold">{{ deal.title }}</p>
<span class="shrink-0 text-[10px] text-base-content/55">{{ deal.amount }}</span>
</div>
<p class="mt-0.5 truncate text-[11px] text-base-content/75">{{ deal.stage }}</p>
<p class="mt-0.5 truncate text-[11px] text-base-content/60">{{ getDealCurrentStepLabel(deal) }}</p>
</button>
<p v-if="peopleListMode === 'contacts' && peopleContactList.length === 0" class="px-1 py-2 text-xs text-base-content/55">
{{ peopleVisibilityMode === 'hidden' ? 'No hidden contacts found.' : 'No contacts found.' }}
</p>
<p v-if="peopleListMode === 'deals' && peopleDealList.length === 0" class="px-1 py-2 text-xs text-base-content/55">
No deals found.
</p>
</div>
</aside>
</template>