MelissaConversas: a11y + perf tagsForThread + DRY (channelMeta + KANBAN_COLUMNS shared)

A11y no parent:
- aria-label em botoes icon-only do header (Recarregar dinamico, Buscar
  compact, Close); tooltip vira title que SR ignora
- aria-hidden=true em icones decorativos (header title, search input,
  subheader info-circle, kanban col head, empty state, button icons)
- aria-busy reativo no mw-col__body durante loading
- aria-label dinamico no count do kanban ("3 conversas em Urgente")
- aria-expanded + aria-controls no menu mobile button
- aria-label no input de busca
- role=note no subheader explicativo
- :inert="(drawerOpen && isMobile) || null" no <section class="mw-page">
  — focus trap real: drawer aberto torna conteudo de fundo inerte
  (boolean attr via || null pra Vue 3.4 serializar correto)

A11y no Sidebar:
- aria-hidden=true em todos icones decorativos restantes (filter title
  icons, list/bell/user/user-minus, channel icons, filter-slash, etc)

Perf — tagsForThread cacheado:
- Antes era chamado in-template (2x por card, recriava array a cada
  render). Agora tagsByThreadKey computed Map: lookup O(1) por card,
  recompute so quando threadTagsMap ou tagById muda. EMPTY_TAGS frozen
  evita criar arrays novos pra threads sem tags.

DRY — channelMeta + KANBAN_COLUMNS shared:
- src/utils/channelMeta.js (novo): CHANNEL_OPTIONS frozen + channelIcon
  + channelLabel. Antes channelIcon estava em 3 lugares (parent, Sidebar,
  Card); CHANNEL_OPTIONS em 2 (parent, Sidebar). Agora 1.
- useConversations.js: exporta KANBAN_COLUMNS frozen (metadata canonica:
  key + label + icon + color). Antes parent+Sidebar tinham copias locais
  de 8 linhas cada + composable tinha KANBAN_ORDER separado. Agora
  KANBAN_ORDER deriva de KANBAN_COLUMNS.

Drift eliminado: 3 fontes -> 1 pra channelIcon, 2 -> 1 pra
CHANNEL_OPTIONS, 2 -> 1 pra KANBAN_COLUMNS (KANBAN_ORDER ainda interno
ao composable mas derivado).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-07 16:37:46 -03:00
parent 250e946084
commit cc7841bd1f
5 changed files with 116 additions and 69 deletions
+11 -1
View File
@@ -19,7 +19,17 @@ import { ref, computed, onUnmounted } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
const KANBAN_ORDER = ['urgent', 'awaiting_us', 'awaiting_patient', 'resolved'];
// Metadata canonica das colunas do kanban — fonte unica consumida pelo
// SFC parent (kanban grid central) e pelo MelissaConversasSidebar
// (resumo "Por status"). Antes, parent + sidebar tinham copias locais
// de KANBAN_COLUMNS e o composable separadamente tinha KANBAN_ORDER.
export const KANBAN_COLUMNS = Object.freeze([
Object.freeze({ key: 'urgent', label: 'Urgente', icon: 'pi pi-exclamation-triangle', color: 'red' }),
Object.freeze({ key: 'awaiting_us', label: 'Aguardando resposta', icon: 'pi pi-inbox', color: 'amber' }),
Object.freeze({ key: 'awaiting_patient', label: 'Aguardando paciente', icon: 'pi pi-hourglass', color: 'blue' }),
Object.freeze({ key: 'resolved', label: 'Resolvido', icon: 'pi pi-check', color: 'emerald' })
]);
const KANBAN_ORDER = KANBAN_COLUMNS.map((c) => c.key);
function sanitizeSearch(raw) {
if (typeof raw !== 'string') return '';
+50 -24
View File
@@ -17,7 +17,7 @@
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
import { supabase } from '@/lib/supabase/client';
import Popover from 'primevue/popover';
import { useConversations } from '@/composables/useConversations';
import { useConversations, KANBAN_COLUMNS } from '@/composables/useConversations';
import { useConversationTags } from '@/composables/useConversationTags';
import { useTenantStore } from '@/stores/tenantStore';
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
@@ -56,9 +56,26 @@ const tagById = computed(() => {
for (const t of tagsApi.allTags.value) m[t.id] = t;
return m;
});
// Cache O(1) por thread_key — antes tagsForThread era chamado in-template
// no v-for de cards, recriava array a cada render. Agora computa uma vez
// quando threadTagsMap ou tagById muda (raro), lookup O(1) por card.
const EMPTY_TAGS = Object.freeze([]);
const tagsByThreadKey = computed(() => {
const out = new Map();
const tags = tagById.value;
for (const [key, ids] of threadTagsMap.value) {
const arr = [];
for (const id of ids) {
const t = tags[id];
if (t) arr.push(t);
}
out.set(key, arr);
}
return out;
});
function tagsForThread(threadKey) {
const ids = threadTagsMap.value.get(threadKey) || [];
return ids.map((id) => tagById.value[id]).filter(Boolean);
return tagsByThreadKey.value.get(threadKey) || EMPTY_TAGS;
}
async function reloadThreadTags() {
// Tags pra TODOS os threads (nao so filtered) — antes, mudar de filtro
@@ -108,14 +125,8 @@ async function loadMemberNames() {
// assigneeLabel migrou pra MelissaConversasCard (usa props.memberNameMap).
// Constantes
const KANBAN_COLUMNS = [
{ key: 'urgent', label: 'Urgente', icon: 'pi pi-exclamation-triangle', color: 'red' },
{ key: 'awaiting_us', label: 'Aguardando resposta', icon: 'pi pi-inbox', color: 'amber' },
{ key: 'awaiting_patient', label: 'Aguardando paciente', icon: 'pi pi-hourglass', color: 'blue' },
{ key: 'resolved', label: 'Resolvido', icon: 'pi pi-check', color: 'emerald' }
];
// CHANNEL_OPTIONS migrou pra MelissaConversasSidebar (uso unico la).
// KANBAN_COLUMNS importado de @/composables/useConversations (fonte unica).
// CHANNEL_OPTIONS movido pra @/utils/channelMeta (compartilhado com Sidebar/Card).
// Helpers fmtRelative/channelIcon/truncate/contactLabel migraram pra
// MelissaConversasCard (uso unico la). Se aparecer 3o consumidor,
@@ -212,18 +223,23 @@ watch(() => tenantStore.activeTenantId, async (newTid, oldTid) => {
/>
</Transition>
<section class="mw-page absolute inset-[6px] [bottom:calc(var(--m-dock-h,76px)+6px)] z-40 flex flex-col bg-[var(--m-bg-medium)] backdrop-blur-[32px] backdrop-saturate-[160%] border border-[var(--m-border)] rounded-[18px] shadow-[0_16px_48px_rgba(0,0,0,0.4)] overflow-hidden text-[var(--m-text)] [font-family:'Segoe_UI',system-ui,-apple-system,sans-serif] [animation:mw-page-enter_240ms_cubic-bezier(0.2,0.7,0.3,1)]">
<section
class="mw-page absolute inset-[6px] [bottom:calc(var(--m-dock-h,76px)+6px)] z-40 flex flex-col bg-[var(--m-bg-medium)] backdrop-blur-[32px] backdrop-saturate-[160%] border border-[var(--m-border)] rounded-[18px] shadow-[0_16px_48px_rgba(0,0,0,0.4)] overflow-hidden text-[var(--m-text)] [font-family:'Segoe_UI',system-ui,-apple-system,sans-serif] [animation:mw-page-enter_240ms_cubic-bezier(0.2,0.7,0.3,1)]"
:inert="(drawerOpen && isMobile) || null"
>
<header class="mw-page__head flex items-center justify-between px-[18px] py-[14px] border-b border-[var(--m-border)] flex-shrink-0 gap-2.5">
<button
class="mw-menu-btn mw-menu-btn--mobile-only hidden max-[1023px]:inline-flex h-8 items-center gap-1.5 flex-shrink-0 px-[11px] rounded-[9px] cursor-pointer text-[0.78rem] font-semibold text-white bg-[var(--m-accent)] border border-[var(--m-accent)] [font-family:inherit] transition-[background-color,transform] duration-[140ms] hover:bg-[color-mix(in_srgb,var(--m-accent)_88%,white)] hover:-translate-y-px [&>i]:text-[0.85rem]"
:aria-expanded="drawerOpen"
aria-controls="mw-mobile-drawer-target"
v-tooltip.bottom="'Filtros & atribuição'"
@click="toggleDrawer"
>
<i class="pi pi-bars" />
<i class="pi pi-bars" aria-hidden="true" />
<span>Menu Conversas</span>
</button>
<div class="mw-page__title flex-1 min-w-0 flex items-center gap-2.5 text-[1rem] font-medium flex-wrap [&>i:first-child]:max-[1023px]:hidden">
<i class="pi pi-comments text-emerald-300" />
<i class="pi pi-comments text-emerald-300" aria-hidden="true" />
<span class="overflow-hidden text-ellipsis whitespace-nowrap max-[1023px]:hidden">Conversas</span>
<span class="mw-page__count text-[0.7rem] font-semibold text-[var(--m-accent)] bg-[var(--m-accent-soft)] border border-[color-mix(in_srgb,var(--m-accent)_35%,transparent)] px-2 py-0.5 rounded-full">{{ summary.total }}</span>
<span v-if="summary.unreadTotal > 0" class="mw-page__unread text-[0.7rem] font-semibold text-[rgb(248,113,113)] bg-[rgba(248,113,113,0.12)] border border-[rgba(248,113,113,0.3)] px-2 py-0.5 rounded-full">
@@ -232,36 +248,40 @@ watch(() => tenantStore.activeTenantId, async (newTid, oldTid) => {
</div>
<div class="mw-page__actions flex items-center gap-2 flex-shrink-0">
<div class="mw-search mw-search--xl-only relative w-[240px] max-[1279px]:hidden">
<i class="pi pi-search mw-search__icon absolute top-1/2 left-3 -translate-y-1/2 text-[var(--m-text-muted)] text-[0.78rem]" />
<i class="pi pi-search mw-search__icon absolute top-1/2 left-3 -translate-y-1/2 text-[var(--m-text-muted)] text-[0.78rem]" aria-hidden="true" />
<input
v-model="filters.search"
type="text"
placeholder="Buscar paciente, número ou mensagem"
maxlength="120"
aria-label="Buscar paciente, número ou mensagem"
class="mw-search__input w-full h-8 bg-[var(--m-bg-soft)] border border-[var(--m-border)] text-[var(--m-text)] px-3 pl-8 rounded-[9px] text-[0.78rem] [font-family:inherit] outline-none transition-[border-color,background-color] duration-[140ms] focus:bg-[var(--m-bg-soft-hover)] focus:border-[var(--m-border-strong)] placeholder:text-[var(--m-text-faint)]"
/>
</div>
<button
class="mw-head-btn mw-head-btn--compact-only hidden max-[1279px]:grid w-8 h-8 place-items-center bg-[var(--m-bg-soft)] border border-[var(--m-border)] text-[var(--m-text)] rounded-[9px] cursor-pointer [font-family:inherit] transition-colors duration-[140ms] hover:bg-[var(--m-bg-soft-hover)] [&>i]:text-[0.85rem]"
aria-label="Buscar"
v-tooltip.bottom="'Buscar'"
@click="openActions"
>
<i class="pi pi-search" />
<i class="pi pi-search" aria-hidden="true" />
</button>
<button
class="mw-head-btn w-8 h-8 grid place-items-center bg-[var(--m-bg-soft)] border border-[var(--m-border)] text-[var(--m-text)] rounded-[9px] cursor-pointer [font-family:inherit] transition-colors duration-[140ms] hover:bg-[var(--m-bg-soft-hover)] [&>i]:text-[0.85rem]"
:aria-label="loading ? 'Recarregando' : 'Recarregar lista'"
v-tooltip.bottom="'Recarregar'"
:disabled="loading"
@click="load"
>
<i :class="loading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'" />
<i :class="loading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'" aria-hidden="true" />
</button>
<button
class="mw-close w-8 h-8 grid place-items-center bg-[var(--m-bg-soft)] border border-[var(--m-border)] text-[var(--m-text)] rounded-[9px] cursor-pointer [font-family:inherit] transition-colors duration-[140ms] hover:bg-[var(--m-bg-soft-hover)]"
aria-label="Voltar ao resumo (Esc)"
v-tooltip.bottom="'Voltar ao resumo (Esc)'"
@click="emit('close')"
>
<i class="pi pi-times" />
<i class="pi pi-times" aria-hidden="true" />
</button>
</div>
</header>
@@ -282,8 +302,8 @@ watch(() => tenantStore.activeTenantId, async (newTid, oldTid) => {
<!-- Subheader explicativo (blueprint §9) diferencia de
outras páginas Melissa que mostram listas tabulares. -->
<div class="mw-subheader flex items-start gap-2.5 px-[18px] py-2.5 border-b border-[var(--m-border)] bg-[var(--m-bg-soft)] text-[0.78rem] text-[var(--m-text-muted)] leading-[1.45] flex-shrink-0">
<i class="pi pi-info-circle mw-subheader__icon text-[var(--p-primary-color)] text-[0.92rem] flex-shrink-0 mt-px" />
<div class="mw-subheader flex items-start gap-2.5 px-[18px] py-2.5 border-b border-[var(--m-border)] bg-[var(--m-bg-soft)] text-[0.78rem] text-[var(--m-text-muted)] leading-[1.45] flex-shrink-0" role="note">
<i class="pi pi-info-circle mw-subheader__icon text-[var(--p-primary-color)] text-[0.92rem] flex-shrink-0 mt-px" aria-hidden="true" />
<span class="mw-subheader__text flex-1 min-w-0 [&>strong]:text-[var(--m-text)] [&>strong]:font-semibold">
CRM de mensagens organizado em <strong>kanban por urgência</strong>:
Urgente / Aguardando resposta / Aguardando paciente / Resolvido.
@@ -315,12 +335,18 @@ watch(() => tenantStore.activeTenantId, async (newTid, oldTid) => {
>
<div class="mw-col__head flex items-center justify-between px-3 py-2.5 border-b border-[var(--m-border)] flex-shrink-0">
<div class="mw-col__title inline-flex items-center gap-1.5 text-[0.78rem] font-semibold [&>i]:text-[0.78rem]">
<i :class="col.icon" />
<i :class="col.icon" aria-hidden="true" />
<span>{{ col.label }}</span>
</div>
<span class="mw-col__count text-[0.65rem] font-semibold text-[var(--m-text)] bg-[var(--m-bg-medium)] px-1.5 py-px rounded-full min-w-[22px] text-center">{{ byKanban[col.key].length }}</span>
<span
class="mw-col__count text-[0.65rem] font-semibold text-[var(--m-text)] bg-[var(--m-bg-medium)] px-1.5 py-px rounded-full min-w-[22px] text-center"
:aria-label="`${byKanban[col.key].length} conversas em ${col.label}`"
>{{ byKanban[col.key].length }}</span>
</div>
<div class="mw-col__body flex-1 overflow-y-auto p-2 flex flex-col gap-2 max-[1023px]:flex-none max-[1023px]:overflow-visible max-[1023px]:min-h-[80px]">
<div
class="mw-col__body flex-1 overflow-y-auto p-2 flex flex-col gap-2 max-[1023px]:flex-none max-[1023px]:overflow-visible max-[1023px]:min-h-[80px]"
:aria-busy="carregandoInicial && !byKanban[col.key].length"
>
<!-- Skeleton na primeira carga, na coluna que estiver vazia.
Estrutura inline (nao usa MelissaConversasCard pq o markup
eh simplificado pra placeholder visual). -->
@@ -342,7 +368,7 @@ watch(() => tenantStore.activeTenantId, async (newTid, oldTid) => {
v-else-if="!byKanban[col.key].length"
class="mw-col__empty flex flex-col items-center gap-1.5 px-3 py-6 text-[var(--m-text-faint)] text-[0.72rem] italic [&>i]:text-2xl [&>i]:opacity-40"
>
<i class="pi pi-comments" />
<i class="pi pi-comments" aria-hidden="true" />
<span>Nenhuma conversa</span>
</div>
+2 -8
View File
@@ -28,6 +28,8 @@
* - click — pai chama drawerStore.openForThread
*/
import { channelIcon } from '@/utils/channelMeta';
const props = defineProps({
thread: { type: Object, required: true },
tags: { type: Array, default: () => [] },
@@ -35,14 +37,6 @@ const props = defineProps({
memberNameMap: { type: Object, default: () => ({}) }
});
defineEmits(['click']);
// Helpers internos — duplicados do pai pra manter componente autocontido.
// channelIcon eh trivial. Se aparecer um terceiro consumidor (alem do pai
// e este), vale extrair pra useChannelMeta.
function channelIcon(ch) {
const map = { whatsapp: 'pi-whatsapp', sms: 'pi-comment', email: 'pi-envelope' };
return map[ch] || 'pi-comment';
}
function truncate(s, n = 80) {
if (!s) return '';
const str = String(s).replace(/\s+/g, ' ').trim();
+19 -36
View File
@@ -27,6 +27,8 @@
* - unassignedCount: threads sem atribuicao
*/
import { computed } from 'vue';
import { CHANNEL_OPTIONS, channelIcon } from '@/utils/channelMeta';
import { KANBAN_COLUMNS } from '@/composables/useConversations';
const props = defineProps({
filters: { type: Object, required: true },
@@ -36,25 +38,6 @@ const props = defineProps({
unassignedCount: { type: Number, default: 0 }
});
const KANBAN_COLUMNS = [
{ key: 'urgent', label: 'Urgente', icon: 'pi pi-exclamation-triangle', color: 'red' },
{ key: 'awaiting_us', label: 'Aguardando resposta', icon: 'pi pi-inbox', color: 'amber' },
{ key: 'awaiting_patient', label: 'Aguardando paciente', icon: 'pi pi-hourglass', color: 'blue' },
{ key: 'resolved', label: 'Resolvido', icon: 'pi pi-check', color: 'emerald' }
];
const CHANNEL_OPTIONS = [
{ label: 'Todos', value: null },
{ label: 'WhatsApp', value: 'whatsapp' },
{ label: 'SMS', value: 'sms' },
{ label: 'E-mail', value: 'email' }
];
function channelIcon(ch) {
const map = { whatsapp: 'pi-whatsapp', sms: 'pi-comment', email: 'pi-envelope' };
return map[ch] || 'pi-comment';
}
// Internal — controla render do footer "Limpar filtros"
const hasActiveFilters = computed(() => {
const f = props.filters;
@@ -76,7 +59,7 @@ function clearAllFilters() {
v-if="unlinkedCount > 0"
class="mw-alert flex gap-2.5 mx-3 mt-3 last:mb-3 p-3 rounded-[10px] border border-[rgba(251,191,36,0.3)] bg-[rgba(251,191,36,0.05)] text-[rgb(251,191,36)] flex-shrink-0 shadow-[0_2px_8px_rgba(0,0,0,0.12)] [&>i]:text-[0.85rem] [&>i]:mt-0.5"
>
<i class="pi pi-exclamation-circle" />
<i class="pi pi-exclamation-circle" aria-hidden="true" />
<div>
<div class="mw-alert__title text-[0.78rem] font-semibold">{{ unlinkedCount }} sem paciente vinculado</div>
<div class="mw-alert__hint text-[0.7rem] text-[var(--m-text-muted)] mt-0.5">Números de telefone que não batem com pacientes cadastrados.</div>
@@ -86,7 +69,7 @@ function clearAllFilters() {
<!-- Filtros rápidos -->
<div class="mw-w mw-w--side mx-3 mt-3 first:mt-3 flex-shrink-0 shadow-[0_2px_8px_rgba(0,0,0,0.12)] bg-[var(--m-bg-medium)] border border-[var(--m-border)] rounded-xl p-3">
<div class="mw-w__head flex items-center justify-between mb-2.5">
<span class="mw-w__title inline-flex items-center gap-1.5 text-[0.78rem] font-semibold [&>i]:text-[var(--m-text-muted)] [&>i]:text-[0.78rem]"><i class="pi pi-filter" /> Filtros rápidos</span>
<span class="mw-w__title inline-flex items-center gap-1.5 text-[0.78rem] font-semibold [&>i]:text-[var(--m-text-muted)] [&>i]:text-[0.78rem]"><i class="pi pi-filter" aria-hidden="true" /> Filtros rápidos</span>
<button
v-if="filters.unreadOnly"
class="mw-side__clear-inline w-[18px] h-[18px] grid place-items-center bg-transparent border border-[color-mix(in_srgb,rgb(220,38,38)_30%,var(--m-border))] text-[rgb(220,38,38)] rounded cursor-pointer [font-family:inherit] transition-[background-color,border-color] duration-[140ms] hover:bg-[rgba(220,38,38,0.10)] hover:border-[rgba(220,38,38,0.55)] [&>i]:text-[0.6rem]"
@@ -94,7 +77,7 @@ function clearAllFilters() {
aria-label="Limpar filtro de não lidas"
@click="filters.unreadOnly = false"
>
<i class="pi pi-times" />
<i class="pi pi-times" aria-hidden="true" />
</button>
</div>
<div class="mw-side__list flex flex-col gap-1">
@@ -104,7 +87,7 @@ function clearAllFilters() {
:aria-pressed="!filters.unreadOnly"
@click="filters.unreadOnly = false"
>
<i class="pi pi-list" />
<i class="pi pi-list" aria-hidden="true" />
<span>Todas</span>
<span class="mw-side__count text-[0.65rem] font-semibold text-[var(--m-text-muted)] bg-[var(--m-bg-medium)] px-1.5 py-px rounded-full min-w-[22px] text-center">{{ summary.total }}</span>
</button>
@@ -114,7 +97,7 @@ function clearAllFilters() {
:aria-pressed="filters.unreadOnly"
@click="filters.unreadOnly = !filters.unreadOnly"
>
<i class="pi pi-bell" />
<i class="pi pi-bell" aria-hidden="true" />
<span>Não lidas</span>
<span
class="mw-side__count text-[0.65rem] font-semibold text-[var(--m-text-muted)] bg-[var(--m-bg-medium)] px-1.5 py-px rounded-full min-w-[22px] text-center"
@@ -127,7 +110,7 @@ function clearAllFilters() {
<!-- Atribuição -->
<div class="mw-w mw-w--side mx-3 mt-3 flex-shrink-0 shadow-[0_2px_8px_rgba(0,0,0,0.12)] bg-[var(--m-bg-medium)] border border-[var(--m-border)] rounded-xl p-3">
<div class="mw-w__head flex items-center justify-between mb-2.5">
<span class="mw-w__title inline-flex items-center gap-1.5 text-[0.78rem] font-semibold [&>i]:text-[var(--m-text-muted)] [&>i]:text-[0.78rem]"><i class="pi pi-user" /> Atribuição</span>
<span class="mw-w__title inline-flex items-center gap-1.5 text-[0.78rem] font-semibold [&>i]:text-[var(--m-text-muted)] [&>i]:text-[0.78rem]"><i class="pi pi-user" aria-hidden="true" /> Atribuição</span>
<button
v-if="filters.assigned"
class="mw-side__clear-inline w-[18px] h-[18px] grid place-items-center bg-transparent border border-[color-mix(in_srgb,rgb(220,38,38)_30%,var(--m-border))] text-[rgb(220,38,38)] rounded cursor-pointer [font-family:inherit] transition-[background-color,border-color] duration-[140ms] hover:bg-[rgba(220,38,38,0.10)] hover:border-[rgba(220,38,38,0.55)] [&>i]:text-[0.6rem]"
@@ -135,7 +118,7 @@ function clearAllFilters() {
aria-label="Limpar filtro de atribuição"
@click="filters.assigned = null"
>
<i class="pi pi-times" />
<i class="pi pi-times" aria-hidden="true" />
</button>
</div>
<div class="mw-side__list flex flex-col gap-1">
@@ -145,7 +128,7 @@ function clearAllFilters() {
:aria-pressed="!filters.assigned"
@click="filters.assigned = null"
>
<i class="pi pi-list" />
<i class="pi pi-list" aria-hidden="true" />
<span>Todas</span>
</button>
<button
@@ -154,7 +137,7 @@ function clearAllFilters() {
:aria-pressed="filters.assigned === 'me'"
@click="filters.assigned = 'me'"
>
<i class="pi pi-user" />
<i class="pi pi-user" aria-hidden="true" />
<span>Minhas</span>
<span class="mw-side__count text-[0.65rem] font-semibold text-[var(--m-text-muted)] bg-[var(--m-bg-medium)] px-1.5 py-px rounded-full min-w-[22px] text-center">{{ mineCount }}</span>
</button>
@@ -164,7 +147,7 @@ function clearAllFilters() {
:aria-pressed="filters.assigned === 'unassigned'"
@click="filters.assigned = 'unassigned'"
>
<i class="pi pi-user-minus" />
<i class="pi pi-user-minus" aria-hidden="true" />
<span>Não atribuídas</span>
<span
class="mw-side__count text-[0.65rem] font-semibold text-[var(--m-text-muted)] bg-[var(--m-bg-medium)] px-1.5 py-px rounded-full min-w-[22px] text-center"
@@ -177,7 +160,7 @@ function clearAllFilters() {
<!-- Por status (kanban resumo display-only, sem X) -->
<div class="mw-w mw-w--side mx-3 mt-3 flex-shrink-0 shadow-[0_2px_8px_rgba(0,0,0,0.12)] bg-[var(--m-bg-medium)] border border-[var(--m-border)] rounded-xl p-3">
<div class="mw-w__head flex items-center justify-between mb-2.5">
<span class="mw-w__title inline-flex items-center gap-1.5 text-[0.78rem] font-semibold [&>i]:text-[var(--m-text-muted)] [&>i]:text-[0.78rem]"><i class="pi pi-chart-bar" /> Por status</span>
<span class="mw-w__title inline-flex items-center gap-1.5 text-[0.78rem] font-semibold [&>i]:text-[var(--m-text-muted)] [&>i]:text-[0.78rem]"><i class="pi pi-chart-bar" aria-hidden="true" /> Por status</span>
</div>
<div class="mw-side__list flex flex-col gap-1">
<div
@@ -186,7 +169,7 @@ function clearAllFilters() {
class="mw-side__row flex items-center gap-2.5 px-2.5 py-1.5 rounded-[10px] border border-[var(--m-border)] bg-[var(--m-bg-medium)] text-[0.78rem] [&>span:first-of-type]:flex-1 [&>i]:text-[0.75rem]"
:class="`is-${col.color}`"
>
<i :class="col.icon" />
<i :class="col.icon" aria-hidden="true" />
<span>{{ col.label }}</span>
<span class="mw-side__count text-[0.65rem] font-semibold text-[var(--m-text-muted)] bg-[var(--m-bg-medium)] px-1.5 py-px rounded-full min-w-[22px] text-center">{{ summary[col.key] || 0 }}</span>
</div>
@@ -196,7 +179,7 @@ function clearAllFilters() {
<!-- Canais -->
<div class="mw-w mw-w--side mx-3 mt-3 last:mb-3 flex-shrink-0 shadow-[0_2px_8px_rgba(0,0,0,0.12)] bg-[var(--m-bg-medium)] border border-[var(--m-border)] rounded-xl p-3">
<div class="mw-w__head flex items-center justify-between mb-2.5">
<span class="mw-w__title inline-flex items-center gap-1.5 text-[0.78rem] font-semibold [&>i]:text-[var(--m-text-muted)] [&>i]:text-[0.78rem]"><i class="pi pi-send" /> Canais</span>
<span class="mw-w__title inline-flex items-center gap-1.5 text-[0.78rem] font-semibold [&>i]:text-[var(--m-text-muted)] [&>i]:text-[0.78rem]"><i class="pi pi-send" aria-hidden="true" /> Canais</span>
<button
v-if="filters.channel"
class="mw-side__clear-inline w-[18px] h-[18px] grid place-items-center bg-transparent border border-[color-mix(in_srgb,rgb(220,38,38)_30%,var(--m-border))] text-[rgb(220,38,38)] rounded cursor-pointer [font-family:inherit] transition-[background-color,border-color] duration-[140ms] hover:bg-[rgba(220,38,38,0.10)] hover:border-[rgba(220,38,38,0.55)] [&>i]:text-[0.6rem]"
@@ -204,7 +187,7 @@ function clearAllFilters() {
aria-label="Limpar filtro de canal"
@click="filters.channel = null"
>
<i class="pi pi-times" />
<i class="pi pi-times" aria-hidden="true" />
</button>
</div>
<div class="mw-side__list flex flex-col gap-1">
@@ -216,8 +199,8 @@ function clearAllFilters() {
:aria-pressed="filters.channel === opt.value"
@click="filters.channel = opt.value"
>
<i v-if="opt.value" :class="['pi', channelIcon(opt.value)]" />
<i v-else class="pi pi-list" />
<i v-if="opt.value" :class="['pi', channelIcon(opt.value)]" aria-hidden="true" />
<i v-else class="pi pi-list" aria-hidden="true" />
<span>{{ opt.label }}</span>
</button>
</div>
@@ -234,7 +217,7 @@ function clearAllFilters() {
class="mw-side__clear-all w-full inline-flex items-center justify-center gap-2 px-3 py-2.5 bg-[var(--m-bg-medium)] border border-[var(--m-border)] text-[var(--m-text)] rounded-[10px] cursor-pointer [font-family:inherit] text-[0.78rem] font-semibold transition-[background-color,border-color,color] duration-[140ms] hover:bg-[var(--m-bg-soft-hover)] hover:border-[var(--m-border-strong)] hover:text-[var(--m-text)] [&>i]:text-[0.78rem] [&>i]:text-[var(--m-text-muted)] [&>i]:transition-colors [&>i]:duration-[140ms] hover:[&>i]:text-[var(--m-text)]"
@click="clearAllFilters"
>
<i class="pi pi-filter-slash" />
<i class="pi pi-filter-slash" aria-hidden="true" />
<span>Limpar filtros</span>
</button>
</div>
+34
View File
@@ -0,0 +1,34 @@
/*
* channelMeta — fonte unica de canais de comunicacao (WhatsApp/SMS/E-mail).
* --------------------------------------------------------------------
* Antes, os 3 consumidores (MelissaConversas parent, Sidebar, Card)
* tinham copias locais de CHANNEL_OPTIONS + channelIcon. Drift risk
* pequeno mas existia — adicionar 'telegram' exigia mexer em N lugares.
* Aqui centraliza.
*
* NAO eh um composable Vue (no useXxx) porque sao constantes puras
* e funcoes sem reactivity — arquivo utility eh suficiente.
*/
export const CHANNEL_OPTIONS = Object.freeze([
Object.freeze({ label: 'Todos', value: null }),
Object.freeze({ label: 'WhatsApp', value: 'whatsapp' }),
Object.freeze({ label: 'SMS', value: 'sms' }),
Object.freeze({ label: 'E-mail', value: 'email' })
]);
const ICON_MAP = Object.freeze({
whatsapp: 'pi-whatsapp',
sms: 'pi-comment',
email: 'pi-envelope'
});
export function channelIcon(channel) {
return ICON_MAP[channel] || 'pi-comment';
}
export function channelLabel(channel) {
if (!channel) return 'Todos';
const opt = CHANNEL_OPTIONS.find((o) => o.value === channel);
return opt?.label || channel;
}