MelissaConversas: refator + extracoes + migracao Tailwind
useConversations: debounce 300ms no realtime load (sem isso, clinica
ativa fazia SELECT 500 por mensagem); expose currentUserId no return
(antes SFC + composable faziam 2 round-trips a auth.getUser); cleanup
do timer no unsubscribeRealtime.
MelissaConversas: bug fixes de loading
- reloadThreadTags lê de threads.value (universal, nao filtered) — antes
tags piscavam a cada flick de filtro
- watch(threads) com debounce 200ms substitui watch(filteredThreads.length)
— antes recarregava todas as tags em cada char digitado
- Promise.all no mount sem race com currentUserId (reloadThreadTags
removido daqui — vem via watch automatic)
- watch drawer.isOpen: await load() antes (antes load+reload em paralelo
liam threads velhas)
- watch tenantStore com token monotonico (race A→B→A)
- supabase.auth.getUser local removido (usa currentUserId do composable)
Extracoes:
- MelissaConversasSidebar.vue: aside col-1 (alerta unlinked + 4 grupos
de filtros + footer "Limpar filtros" com Vue Transition). filters
passado como prop e mutado direto. KANBAN_COLUMNS/CHANNEL_OPTIONS/
channelIcon/hasActiveFilters/clearAllFilters movidos pra dentro.
Tailwind nas bases; state modifiers .is-active/.is-warn/.is-danger/
.is-{red,amber,blue,emerald} ficam scoped (cores fixas por status).
- MelissaConversasCard.vue: card do kanban (head/msg/tags/foot).
channelIcon/truncate/contactLabel/fmtRelative/assigneeLabel movidos.
aria-label, aria-pressed, aria-hidden em icones decorativos.
Tailwind no template; .is-mine do assignee fica scoped.
Tailwind no resto do parent: containers (.mw-page + animation), header
(.mw-page__head/title/count/unread/actions), search (.mw-search* +
--xl-only via max-[1279px]:hidden), close/head-btn/menu-btn (incluindo
--compact-only e --mobile-only via hidden + max-[XXX]:grid/inline-flex),
subheader, body/main/kanban/col/col__head/title/count/body/empty,
mobile drawer + backdrop. 2 media queries inteiras eliminadas
(@media max-width 1279/1023). State modifiers de kanban color
(.mw-col.is-{color}) ficam scoped — 12 regras com cores fixas RGB
seriam ruidosas inline. Cross-teleport :deep(.mw-side*) preservado.
MelissaConversas: 1293 -> 465 linhas (-828, -64%)
script: 198 -> 195 (logica essencial preservada)
template: 278 -> 143 (49% reducao via componentizacao)
style: 761 -> 99 (87% reducao — so keyframes, kanban color states,
scrollbars, cross-teleport :deep, Vue Transitions)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -45,6 +45,21 @@ export function useConversations() {
|
||||
|
||||
let realtimeChannel = null;
|
||||
|
||||
// Debounce do refetch disparado por realtime — sem isso, clínica ativa
|
||||
// recebendo 10 mensagens/min faz 10 SELECT 500 na lista de threads por
|
||||
// minuto. 300ms agrupa rajadas (digita-rápido, mensagens em sequência)
|
||||
// sem fazer o user esperar visualmente. A nova mensagem em si vai pro
|
||||
// threadMessages direto (acima); load() é só pra atualizar contadores
|
||||
// e preview do thread na lista — pode esperar.
|
||||
let _loadDebounceTimer = null;
|
||||
function _scheduleLoad() {
|
||||
if (_loadDebounceTimer) clearTimeout(_loadDebounceTimer);
|
||||
_loadDebounceTimer = setTimeout(() => {
|
||||
_loadDebounceTimer = null;
|
||||
load();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const tenantId = tenantStore.activeTenantId;
|
||||
if (!tenantId) {
|
||||
@@ -90,8 +105,8 @@ export function useConversations() {
|
||||
filter: `tenant_id=eq.${tenantId}`
|
||||
},
|
||||
(payload) => {
|
||||
// refetch da lista (view agrega tudo)
|
||||
load();
|
||||
// refetch da lista (view agrega tudo) — debounced
|
||||
_scheduleLoad();
|
||||
// se o drawer esta aberto numa thread desta msg, appenda
|
||||
const newMsg = payload.new;
|
||||
if (currentThread.value && messageBelongsToThread(newMsg, currentThread.value)) {
|
||||
@@ -109,7 +124,7 @@ export function useConversations() {
|
||||
filter: `tenant_id=eq.${tenantId}`
|
||||
},
|
||||
(payload) => {
|
||||
load();
|
||||
_scheduleLoad();
|
||||
const updated = payload.new;
|
||||
if (currentThread.value && messageBelongsToThread(updated, currentThread.value)) {
|
||||
const idx = threadMessages.value.findIndex((m) => m.id === updated.id);
|
||||
@@ -125,6 +140,12 @@ export function useConversations() {
|
||||
supabase.removeChannel(realtimeChannel);
|
||||
realtimeChannel = null;
|
||||
}
|
||||
// Cancela refetch agendado — se desmontar entre o trigger e o
|
||||
// debounce dispara, callback rodaria em ref morta.
|
||||
if (_loadDebounceTimer) {
|
||||
clearTimeout(_loadDebounceTimer);
|
||||
_loadDebounceTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => unsubscribeRealtime());
|
||||
@@ -263,6 +284,10 @@ export function useConversations() {
|
||||
threadLoading,
|
||||
loadThreadMessages,
|
||||
markThreadRead,
|
||||
setKanbanStatus
|
||||
setKanbanStatus,
|
||||
// Exposto pra evitar que cada consumidor chame supabase.auth.getUser()
|
||||
// por conta propria — antes, MelissaConversas + composable faziam 2
|
||||
// round-trips ao auth no mesmo mount.
|
||||
currentUserId
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,130 @@
|
||||
<script setup>
|
||||
/*
|
||||
* MelissaConversasCard — card de thread no kanban Melissa.
|
||||
* --------------------------------------------------------
|
||||
* Renderiza uma conversa dentro de uma coluna do kanban (Urgente /
|
||||
* Aguardando resposta / Aguardando paciente / Resolvido). Mostra:
|
||||
* - Head: icone do canal + nome (paciente ou numero) + badge de
|
||||
* unread count (vermelho)
|
||||
* - Msg: preview da ultima mensagem (truncada, com seta se outbound)
|
||||
* - Tags: chips coloridas (cor + icone vem do registro de tag)
|
||||
* - Foot: relative time + badge de assignee ("Eu" ou nome curto) ou
|
||||
* "nao vinculado" quando nao ha paciente
|
||||
*
|
||||
* Componente puramente visual — pai decide o que fazer no @click
|
||||
* (tipicamente drawerStore.openForThread).
|
||||
*
|
||||
* Props:
|
||||
* - thread: { thread_key, channel, patient_name, contact_number,
|
||||
* unread_count, last_message_body, last_message_direction,
|
||||
* last_message_at, assigned_to, patient_id, ... }
|
||||
* - tags: Array<{ id, name, color, icon? }> — tags ja resolvidas
|
||||
* pelo pai (via tagsForThread). [] se nao ha tags.
|
||||
* - currentUserId: string|null — pra determinar "Eu" vs nome
|
||||
* - memberNameMap: Record<userId, fullName> — pra labels de outros
|
||||
* assignees (rotulo "Marina S.")
|
||||
*
|
||||
* Emit:
|
||||
* - click — pai chama drawerStore.openForThread
|
||||
*/
|
||||
|
||||
const props = defineProps({
|
||||
thread: { type: Object, required: true },
|
||||
tags: { type: Array, default: () => [] },
|
||||
currentUserId: { type: String, default: null },
|
||||
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();
|
||||
return str.length > n ? str.slice(0, n - 1) + '…' : str;
|
||||
}
|
||||
function contactLabel(thread) {
|
||||
return thread.patient_name || thread.contact_number || 'Desconhecido';
|
||||
}
|
||||
function fmtRelative(iso) {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
const now = new Date();
|
||||
const diff = Math.floor((now - d) / 1000);
|
||||
if (diff < 60) return 'agora';
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
|
||||
if (diff < 604800) return `${Math.floor(diff / 86400)}d`;
|
||||
return d.toLocaleDateString('pt-BR');
|
||||
}
|
||||
function assigneeLabel(userId) {
|
||||
if (!userId) return '';
|
||||
const full = props.memberNameMap[userId];
|
||||
if (!full) return 'Atribuída';
|
||||
const parts = full.trim().split(/\s+/);
|
||||
if (parts.length === 1) return parts[0].slice(0, 14);
|
||||
return `${parts[0]} ${parts[parts.length - 1][0]}.`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="mw-card flex flex-col gap-1.5 px-3 py-2.5 bg-[var(--m-bg-soft)] border border-[var(--m-border)] rounded-[10px] cursor-pointer text-left [font-family:inherit] text-[var(--m-text)] transition-[background-color,border-color,transform] duration-[140ms] hover:bg-[var(--m-bg-soft-hover)] hover:border-[color-mix(in_srgb,var(--m-accent)_40%,var(--m-border))] hover:-translate-y-px"
|
||||
:aria-label="`Abrir conversa de ${contactLabel(thread)}`"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<div class="mw-card__head flex items-center gap-1.5">
|
||||
<i :class="['pi', channelIcon(thread.channel), 'mw-card__channel', 'text-[var(--m-text-muted)]', 'text-[0.7rem]']" />
|
||||
<span class="mw-card__name flex-1 min-w-0 text-[0.82rem] font-semibold overflow-hidden text-ellipsis whitespace-nowrap">{{ contactLabel(thread) }}</span>
|
||||
<span
|
||||
v-if="thread.unread_count > 0"
|
||||
class="mw-card__unread text-[0.62rem] font-bold text-white bg-[rgb(248,113,113)] px-1.5 py-px rounded-full min-w-[18px] text-center"
|
||||
:aria-label="`${thread.unread_count} mensagens não lidas`"
|
||||
>{{ thread.unread_count }}</span>
|
||||
</div>
|
||||
<div class="mw-card__msg text-[0.74rem] text-[var(--m-text-muted)] overflow-hidden text-ellipsis whitespace-nowrap">
|
||||
<i v-if="thread.last_message_direction === 'outbound'" class="pi pi-arrow-right text-[0.55rem] mr-1 opacity-60" aria-hidden="true" />
|
||||
{{ truncate(thread.last_message_body, 70) }}
|
||||
</div>
|
||||
<div v-if="tags.length" class="mw-card__tags flex items-center gap-1 flex-wrap">
|
||||
<span
|
||||
v-for="tag in tags"
|
||||
:key="tag.id"
|
||||
class="mw-card__tag inline-flex items-center gap-[3px] text-[0.6rem] font-semibold px-[7px] py-px rounded-full border leading-[1.4] [&>i]:text-[0.55rem]"
|
||||
:style="{ background: tag.color + '20', color: tag.color, borderColor: tag.color + '40' }"
|
||||
>
|
||||
<i v-if="tag.icon" :class="tag.icon" aria-hidden="true" />
|
||||
{{ tag.name }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mw-card__foot flex items-center justify-between gap-1.5 text-[0.65rem] text-[var(--m-text-faint)]">
|
||||
<span class="mw-card__time font-medium">{{ fmtRelative(thread.last_message_at) }}</span>
|
||||
<span
|
||||
v-if="thread.assigned_to"
|
||||
class="mw-card__assignee inline-flex items-center gap-[3px] px-1.5 py-px rounded-full border border-[var(--m-border)] bg-[var(--m-bg-soft)] text-[var(--m-text)] text-[0.6rem] font-semibold [&>i]:text-[0.55rem]"
|
||||
:class="{ 'is-mine': thread.assigned_to === currentUserId }"
|
||||
v-tooltip.top="thread.assigned_to === currentUserId ? 'Atribuída a mim' : 'Atribuída a ' + (memberNameMap[thread.assigned_to] || '')"
|
||||
>
|
||||
<i class="pi pi-user" aria-hidden="true" />
|
||||
{{ thread.assigned_to === currentUserId ? 'Eu' : assigneeLabel(thread.assigned_to) }}
|
||||
</span>
|
||||
<span v-else-if="!thread.patient_name" class="mw-card__unlinked italic">não vinculado</span>
|
||||
</div>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* State modifier do assignee — usa vars accent (Melissa theme).
|
||||
Aceita Tailwind utility tambem mas a override de border-color via
|
||||
color-mix fica mais limpa em CSS. */
|
||||
.mw-card__assignee.is-mine {
|
||||
background: var(--m-accent-soft);
|
||||
border-color: color-mix(in srgb, var(--m-accent) 35%, var(--m-border));
|
||||
color: var(--m-accent);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,307 @@
|
||||
<script setup>
|
||||
/*
|
||||
* MelissaConversasSidebar — aside col-1 do CRM de conversas Melissa.
|
||||
* --------------------------------------------------------------
|
||||
* Concentra:
|
||||
* - Alerta unlinked (numeros sem paciente vinculado)
|
||||
* - Filtros rapidos (Todas / Nao lidas)
|
||||
* - Atribuicao (Todas / Minhas / Nao atribuidas)
|
||||
* - Por status (kanban resumo — display-only)
|
||||
* - Canais (Todos / WhatsApp / SMS / E-mail)
|
||||
* - Footer fixo "Limpar filtros" (aparece quando algum filtro ativo)
|
||||
*
|
||||
* Constantes KANBAN_COLUMNS e CHANNEL_OPTIONS vivem aqui (uso unico no
|
||||
* MelissaConversas; se outras paginas precisarem do mesmo conceito,
|
||||
* sobem pra um composable shared como useChannelMeta/useKanbanMeta).
|
||||
*
|
||||
* Pattern de mutacao de prop: `filters` eh um Ref do composable
|
||||
* useConversations. Component muta props.filters.search etc direto —
|
||||
* aceitavel em Vue 3 (refs nao sao readonly por padrao). Alternativa
|
||||
* com emit('update:filters', ...) seria verboso pra cada filter.
|
||||
*
|
||||
* Props:
|
||||
* - filters: { search, unreadOnly, assigned, channel } — mutavel
|
||||
* - summary: { total, unreadTotal, urgent, awaiting_us, awaiting_patient, resolved }
|
||||
* - unlinkedCount: numero de threads sem paciente vinculado
|
||||
* - mineCount: threads atribuidas ao user atual
|
||||
* - unassignedCount: threads sem atribuicao
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
filters: { type: Object, required: true },
|
||||
summary: { type: Object, required: true },
|
||||
unlinkedCount: { type: Number, default: 0 },
|
||||
mineCount: { type: Number, default: 0 },
|
||||
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;
|
||||
return !!(f.search || f.unreadOnly || f.assigned || f.channel);
|
||||
});
|
||||
function clearAllFilters() {
|
||||
props.filters.search = '';
|
||||
props.filters.unreadOnly = false;
|
||||
props.filters.assigned = null;
|
||||
props.filters.channel = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="mw-side w-[280px] flex-shrink-0 flex flex-col bg-[var(--m-bg-soft)] border-r border-[var(--m-border)] overflow-hidden">
|
||||
<div class="mw-side__scroll flex-1 min-h-0 overflow-y-auto flex flex-col">
|
||||
<!-- Alerta unlinked -->
|
||||
<div
|
||||
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" />
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<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]"
|
||||
v-tooltip.top="'Limpar filtro de não lidas'"
|
||||
aria-label="Limpar filtro de não lidas"
|
||||
@click="filters.unreadOnly = false"
|
||||
>
|
||||
<i class="pi pi-times" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="mw-side__list flex flex-col gap-1">
|
||||
<button
|
||||
class="mw-side__item w-full flex items-center gap-2.5 px-2.5 py-2 bg-transparent border border-transparent text-[var(--m-text)] rounded-[10px] cursor-pointer [font-family:inherit] text-[0.82rem] text-left transition-[background-color,border-color] duration-[140ms] hover:bg-[var(--m-bg-soft-hover)] [&>i]:text-[var(--m-text-muted)] [&>i]:text-[0.75rem] [&>i]:w-3.5 [&>i]:text-center [&>span:first-of-type]:flex-1"
|
||||
:class="{ 'is-active': !filters.unreadOnly }"
|
||||
:aria-pressed="!filters.unreadOnly"
|
||||
@click="filters.unreadOnly = false"
|
||||
>
|
||||
<i class="pi pi-list" />
|
||||
<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>
|
||||
<button
|
||||
class="mw-side__item w-full flex items-center gap-2.5 px-2.5 py-2 bg-transparent border border-transparent text-[var(--m-text)] rounded-[10px] cursor-pointer [font-family:inherit] text-[0.82rem] text-left transition-[background-color,border-color] duration-[140ms] hover:bg-[var(--m-bg-soft-hover)] [&>i]:text-[var(--m-text-muted)] [&>i]:text-[0.75rem] [&>i]:w-3.5 [&>i]:text-center [&>span:first-of-type]:flex-1"
|
||||
:class="{ 'is-active': filters.unreadOnly, 'is-warn': summary.unreadTotal > 0 }"
|
||||
:aria-pressed="filters.unreadOnly"
|
||||
@click="filters.unreadOnly = !filters.unreadOnly"
|
||||
>
|
||||
<i class="pi pi-bell" />
|
||||
<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"
|
||||
:class="{ 'is-danger': summary.unreadTotal > 0 }"
|
||||
>{{ summary.unreadTotal }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<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]"
|
||||
v-tooltip.top="'Limpar filtro de atribuição'"
|
||||
aria-label="Limpar filtro de atribuição"
|
||||
@click="filters.assigned = null"
|
||||
>
|
||||
<i class="pi pi-times" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="mw-side__list flex flex-col gap-1">
|
||||
<button
|
||||
class="mw-side__item w-full flex items-center gap-2.5 px-2.5 py-2 bg-transparent border border-transparent text-[var(--m-text)] rounded-[10px] cursor-pointer [font-family:inherit] text-[0.82rem] text-left transition-[background-color,border-color] duration-[140ms] hover:bg-[var(--m-bg-soft-hover)] [&>i]:text-[var(--m-text-muted)] [&>i]:text-[0.75rem] [&>i]:w-3.5 [&>i]:text-center [&>span:first-of-type]:flex-1"
|
||||
:class="{ 'is-active': !filters.assigned }"
|
||||
:aria-pressed="!filters.assigned"
|
||||
@click="filters.assigned = null"
|
||||
>
|
||||
<i class="pi pi-list" />
|
||||
<span>Todas</span>
|
||||
</button>
|
||||
<button
|
||||
class="mw-side__item w-full flex items-center gap-2.5 px-2.5 py-2 bg-transparent border border-transparent text-[var(--m-text)] rounded-[10px] cursor-pointer [font-family:inherit] text-[0.82rem] text-left transition-[background-color,border-color] duration-[140ms] hover:bg-[var(--m-bg-soft-hover)] [&>i]:text-[var(--m-text-muted)] [&>i]:text-[0.75rem] [&>i]:w-3.5 [&>i]:text-center [&>span:first-of-type]:flex-1"
|
||||
:class="{ 'is-active': filters.assigned === 'me' }"
|
||||
:aria-pressed="filters.assigned === 'me'"
|
||||
@click="filters.assigned = 'me'"
|
||||
>
|
||||
<i class="pi pi-user" />
|
||||
<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>
|
||||
<button
|
||||
class="mw-side__item w-full flex items-center gap-2.5 px-2.5 py-2 bg-transparent border border-transparent text-[var(--m-text)] rounded-[10px] cursor-pointer [font-family:inherit] text-[0.82rem] text-left transition-[background-color,border-color] duration-[140ms] hover:bg-[var(--m-bg-soft-hover)] [&>i]:text-[var(--m-text-muted)] [&>i]:text-[0.75rem] [&>i]:w-3.5 [&>i]:text-center [&>span:first-of-type]:flex-1"
|
||||
:class="{ 'is-active': filters.assigned === 'unassigned' }"
|
||||
:aria-pressed="filters.assigned === 'unassigned'"
|
||||
@click="filters.assigned = 'unassigned'"
|
||||
>
|
||||
<i class="pi pi-user-minus" />
|
||||
<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"
|
||||
:class="{ 'is-warn': unassignedCount > 0 }"
|
||||
>{{ unassignedCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
</div>
|
||||
<div class="mw-side__list flex flex-col gap-1">
|
||||
<div
|
||||
v-for="col in KANBAN_COLUMNS"
|
||||
:key="col.key"
|
||||
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" />
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
<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]"
|
||||
v-tooltip.top="'Limpar filtro de canal'"
|
||||
aria-label="Limpar filtro de canal"
|
||||
@click="filters.channel = null"
|
||||
>
|
||||
<i class="pi pi-times" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="mw-side__list flex flex-col gap-1">
|
||||
<button
|
||||
v-for="opt in CHANNEL_OPTIONS"
|
||||
:key="String(opt.value)"
|
||||
class="mw-side__item w-full flex items-center gap-2.5 px-2.5 py-2 bg-transparent border border-transparent text-[var(--m-text)] rounded-[10px] cursor-pointer [font-family:inherit] text-[0.82rem] text-left transition-[background-color,border-color] duration-[140ms] hover:bg-[var(--m-bg-soft-hover)] [&>i]:text-[var(--m-text-muted)] [&>i]:text-[0.75rem] [&>i]:w-3.5 [&>i]:text-center [&>span:first-of-type]:flex-1"
|
||||
:class="{ 'is-active': filters.channel === opt.value }"
|
||||
: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" />
|
||||
<span>{{ opt.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer fixo: "Limpar filtros" global -->
|
||||
<Transition name="mw-clear">
|
||||
<div
|
||||
v-if="hasActiveFilters"
|
||||
class="mw-side__footer flex-shrink-0 p-3 bg-[var(--m-bg-soft)] border-t border-[var(--m-border)]"
|
||||
>
|
||||
<button
|
||||
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" />
|
||||
<span>Limpar filtros</span>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Scrollbar (sem utility Tailwind) e state modifiers de cor (kanban
|
||||
colors fixas + .is-active/.is-warn/.is-danger com vars accent) ficam
|
||||
em scoped — sao dificeis de expressar em utilities sem repetir N
|
||||
classes em cada filho. */
|
||||
|
||||
.mw-side__scroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--m-border-strong) transparent;
|
||||
}
|
||||
.mw-side__scroll::-webkit-scrollbar { width: 5px; }
|
||||
.mw-side__scroll::-webkit-scrollbar-thumb {
|
||||
background: var(--m-border-strong);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* State modifier: filtro ativo (item selecionado) */
|
||||
.mw-side__item.is-active {
|
||||
background: var(--m-accent-soft);
|
||||
border-color: color-mix(in srgb, var(--m-accent) 35%, var(--m-border));
|
||||
}
|
||||
/* State modifier: warn — quando ha threads nao lidas no item "Nao lidas" */
|
||||
.mw-side__item.is-warn { background: rgba(248, 113, 113, 0.05); }
|
||||
|
||||
/* Counts variantes */
|
||||
.mw-side__count.is-danger {
|
||||
background: rgba(248, 113, 113, 0.15);
|
||||
color: rgb(248, 113, 113);
|
||||
}
|
||||
.mw-side__count.is-warn {
|
||||
background: rgba(251, 191, 36, 0.15);
|
||||
color: rgb(251, 191, 36);
|
||||
}
|
||||
|
||||
/* Kanban row colors — uma cor por status (red/amber/blue/emerald) */
|
||||
.mw-side__row.is-red { border-color: rgba(248, 113, 113, 0.3); background: rgba(248, 113, 113, 0.05); }
|
||||
.mw-side__row.is-red > i { color: rgb(248, 113, 113); }
|
||||
.mw-side__row.is-amber { border-color: rgba(251, 191, 36, 0.3); background: rgba(251, 191, 36, 0.05); }
|
||||
.mw-side__row.is-amber > i { color: rgb(251, 191, 36); }
|
||||
.mw-side__row.is-blue { border-color: rgba(96, 165, 250, 0.3); background: rgba(96, 165, 250, 0.05); }
|
||||
.mw-side__row.is-blue > i { color: rgb(96, 165, 250); }
|
||||
.mw-side__row.is-emerald { border-color: rgba(74, 222, 128, 0.3); background: rgba(74, 222, 128, 0.05); }
|
||||
.mw-side__row.is-emerald > i { color: rgb(74, 222, 128); }
|
||||
|
||||
/* Transition do footer "Limpar filtros" */
|
||||
.mw-clear-enter-active,
|
||||
.mw-clear-leave-active {
|
||||
transition: opacity 220ms ease, transform 220ms ease, max-height 240ms ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mw-clear-enter-from,
|
||||
.mw-clear-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
max-height: 0;
|
||||
}
|
||||
.mw-clear-enter-to,
|
||||
.mw-clear-leave-from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
max-height: 80px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user