Files
agenciapsilmno/src/layout/melissa/MelissaConversas.vue
T
Leonardo 02af119dc6 Melissa drawers: footer colado no bottom (pattern AppMenu)
Refator do mobile drawer em todas as Melissa Pages com sidebar:
scroll move pra dentro de .xx-side__scroll (flex: 1 + min-height: 0)
e o __footer vira flex-shrink: 0 last child de flex column. Espelha
o pattern do AppMenu/layout-sidebar Rail. Substitui o sticky/margin:auto
que falhava quando o conteudo era pequeno (deixava espaco vazio sob
o "Limpar filtros").

Pages: Compromissos, Conversas, Documentos, FinanceiroLancamentos,
Grupos, Medicos, Notificacoes, Pacientes, Recorrencias, Relatorios, Tags.

Pacientes (caso especial): mp-quick fixo no topo (max-height: 50%)
+ mp-side flex: 1 com scroll/footer interno.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 11:30:52 -03:00

1293 lines
44 KiB
Vue

<script setup>
/*
* MelissaConversas — CRM de WhatsApp dentro de Melissa.
* Segue o blueprint melissa-page-blueprint.md.
*
* Layout 2-col:
* - COL 1 — Aside (~280px): filtros rápidos (todas/não lidas) +
* atribuição (minhas/não atribuídas) + canais + kanban resumo
* + alerta de não vinculados
* - COL 2 — Main: kanban grid 4-col (Urgente / Aguardando resposta /
* Aguardando paciente / Resolvido) com cards
*
* Click num card abre o ConversationDrawer (componente existente,
* já mountado no MelissaLayout). Sem rotas externas — fica tudo
* dentro do Melissa.
*/
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 { useConversationTags } from '@/composables/useConversationTags';
import { useTenantStore } from '@/stores/tenantStore';
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
// Badge/InputText/IconField/InputIcon: auto-import via PrimeVueResolver
const emit = defineEmits(['close']);
const tenantStore = useTenantStore();
const drawerStore = useConversationDrawerStore();
// ── Breakpoints + drawer (blueprint §2/§3) ─────────────────
const drawerOpen = ref(false);
const isMobile = ref(false);
const isCompact = ref(false);
let _mqMobile = null;
let _mqCompact = null;
function _onMqMobileChange(e) {
isMobile.value = e.matches;
if (!e.matches) drawerOpen.value = false;
}
function _onMqCompactChange(e) {
isCompact.value = e.matches;
}
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
function fecharDrawer() { drawerOpen.value = false; }
// ── Composables (espelhado da CRMConversasPage) ─────────────
const { threads, filteredThreads, byKanban, summary, filters, loading, load, subscribeRealtime, unsubscribeRealtime } = useConversations();
const tagsApi = useConversationTags();
const threadTagsMap = ref(new Map());
const tagById = computed(() => {
const m = {};
for (const t of tagsApi.allTags.value) m[t.id] = t;
return m;
});
function tagsForThread(threadKey) {
const ids = threadTagsMap.value.get(threadKey) || [];
return ids.map((id) => tagById.value[id]).filter(Boolean);
}
async function reloadThreadTags() {
const keys = filteredThreads.value.map((t) => t.thread_key);
threadTagsMap.value = await tagsApi.loadForThreads(keys);
}
// Atribuição
const currentUserId = ref(null);
supabase.auth.getUser().then(({ data }) => { currentUserId.value = data?.user?.id ?? null; });
const mineCount = computed(() => {
const uid = currentUserId.value;
if (!uid) return 0;
return threads.value.filter((t) => t.assigned_to === uid).length;
});
const unassignedCount = computed(() => threads.value.filter((t) => !t.assigned_to).length);
const memberNameMap = ref({});
async function loadMemberNames() {
const tenantId = tenantStore.activeTenantId;
if (!tenantId) return;
const { data } = await supabase
.from('v_tenant_members_with_profiles')
.select('user_id, full_name, email')
.eq('tenant_id', tenantId)
.eq('status', 'active');
const map = {};
for (const m of (data || [])) {
map[m.user_id] = m.full_name || m.email || '';
}
memberNameMap.value = map;
}
function assigneeLabel(userId) {
if (!userId) return '';
const full = memberNameMap.value[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]}.`;
}
// 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' }
];
const CHANNEL_OPTIONS = [
{ label: 'Todos', value: null },
{ label: 'WhatsApp', value: 'whatsapp' },
{ label: 'SMS', value: 'sms' },
{ label: 'E-mail', value: 'email' }
];
// Helpers
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 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 onCardClick(thread) {
drawerStore.openForThread(thread);
if (isMobile.value) fecharDrawer();
}
const carregandoInicial = computed(
() => loading.value && filteredThreads.value.length === 0
);
const unlinkedCount = computed(() => filteredThreads.value.filter((t) => !t.patient_id).length);
// ── "Limpar filtros" global (footer fixo da sidebar) ─────────────
// `filters` é um ref({...}) (vide useConversations.js). No script
// preciso acessar via .value; no template o auto-unwrap cuida.
const hasActiveFilters = computed(() =>
!!(filters.value.search || filters.value.unreadOnly || filters.value.assigned || filters.value.channel)
);
function clearAllFilters() {
filters.value.search = '';
filters.value.unreadOnly = false;
filters.value.assigned = null;
filters.value.channel = null;
}
// Popover de Ações (compact)
const actionsPopRef = ref(null);
function openActions(e) { actionsPopRef.value?.toggle(e); }
// Lifecycle
onMounted(async () => {
if (typeof window !== 'undefined' && window.matchMedia) {
_mqMobile = window.matchMedia('(max-width: 1023px)');
isMobile.value = _mqMobile.matches;
_mqMobile.addEventListener('change', _onMqMobileChange);
_mqCompact = window.matchMedia('(max-width: 1279px)');
isCompact.value = _mqCompact.matches;
_mqCompact.addEventListener('change', _onMqCompactChange);
}
await load();
subscribeRealtime();
await Promise.all([tagsApi.loadAllTags(), reloadThreadTags(), loadMemberNames()]);
});
onBeforeUnmount(() => {
if (_mqMobile) _mqMobile.removeEventListener('change', _onMqMobileChange);
if (_mqCompact) _mqCompact.removeEventListener('change', _onMqCompactChange);
unsubscribeRealtime();
});
// Recarrega tags quando drawer fecha
watch(() => drawerStore.isOpen, (isOpen) => {
if (!isOpen) { reloadThreadTags(); load(); }
});
watch(() => filteredThreads.value.length, () => { reloadThreadTags(); });
watch(() => tenantStore.activeTenantId, async () => {
unsubscribeRealtime();
await load();
subscribeRealtime();
});
</script>
<template>
<!-- Drawer host (blueprint §2) -->
<aside
class="mw-mobile-drawer"
:class="{ 'is-open': drawerOpen }"
v-show="isMobile"
aria-label="Filtros & atribuição"
>
<div id="mw-mobile-drawer-target" class="mw-mobile-drawer__scroll" />
</aside>
<Transition name="mw-drawer-fade">
<div
v-if="isMobile && drawerOpen"
class="mw-mobile-drawer__backdrop"
@click="fecharDrawer"
/>
</Transition>
<section class="mw-page">
<header class="mw-page__head">
<button
class="mw-menu-btn mw-menu-btn--mobile-only"
v-tooltip.bottom="'Filtros & atribuição'"
@click="toggleDrawer"
>
<i class="pi pi-bars" />
<span>Menu Conversas</span>
</button>
<div class="mw-page__title">
<i class="pi pi-comments text-emerald-300" />
<span>Conversas</span>
<span class="mw-page__count">{{ summary.total }}</span>
<span v-if="summary.unreadTotal > 0" class="mw-page__unread">
{{ summary.unreadTotal }} não lida{{ summary.unreadTotal !== 1 ? 's' : '' }}
</span>
</div>
<div class="mw-page__actions">
<div class="mw-search mw-search--xl-only">
<i class="pi pi-search mw-search__icon" />
<input
v-model="filters.search"
type="text"
placeholder="Buscar paciente, número ou mensagem"
maxlength="120"
class="mw-search__input"
/>
</div>
<button
class="mw-head-btn mw-head-btn--compact-only"
v-tooltip.bottom="'Buscar'"
@click="openActions"
>
<i class="pi pi-search" />
</button>
<button
class="mw-head-btn"
v-tooltip.bottom="'Recarregar'"
:disabled="loading"
@click="load"
>
<i :class="loading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'" />
</button>
<button class="mw-close" v-tooltip.bottom="'Voltar ao resumo (Esc)'" @click="emit('close')">
<i class="pi pi-times" />
</button>
</div>
</header>
<Popover ref="actionsPopRef" class="mw-actions-pop">
<div class="mw-actions">
<div class="mw-actions__group">
<div class="mw-actions__label">Buscar</div>
<InputText
v-model="filters.search"
placeholder="Paciente, número ou mensagem…"
maxlength="120"
class="w-full"
/>
</div>
</div>
</Popover>
<!-- Subheader explicativo (blueprint §9) diferencia de
outras páginas Melissa que mostram listas tabulares. -->
<div class="mw-subheader">
<i class="pi pi-info-circle mw-subheader__icon" />
<span class="mw-subheader__text">
CRM de mensagens organizado em <strong>kanban por urgência</strong>:
Urgente / Aguardando resposta / Aguardando paciente / Resolvido.
Click num card abre a conversa no painel lateral.
</span>
</div>
<div class="mw-body">
<!-- COL 1: Filtros + atribuição + canais + status -->
<Teleport to="#mw-mobile-drawer-target" :disabled="!isMobile">
<aside class="mw-side">
<div class="mw-side__scroll">
<!-- Alerta unlinked no topo pra ficar bem visível
(números de telefone sem paciente vinculado). -->
<div v-if="unlinkedCount > 0" class="mw-alert">
<i class="pi pi-exclamation-circle" />
<div>
<div class="mw-alert__title">{{ unlinkedCount }} sem paciente vinculado</div>
<div class="mw-alert__hint">Números de telefone que não batem com pacientes cadastrados.</div>
</div>
</div>
<!-- Filtros rápidos -->
<div class="mw-w mw-w--side">
<div class="mw-w__head">
<span class="mw-w__title"><i class="pi pi-filter" /> Filtros rápidos</span>
<button
v-if="filters.unreadOnly"
class="mw-side__clear-inline"
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">
<button
class="mw-side__item"
:class="{ 'is-active': !filters.unreadOnly }"
@click="filters.unreadOnly = false"
>
<i class="pi pi-list" />
<span>Todas</span>
<span class="mw-side__count">{{ summary.total }}</span>
</button>
<button
class="mw-side__item"
:class="{ 'is-active': filters.unreadOnly, 'is-warn': summary.unreadTotal > 0 }"
@click="filters.unreadOnly = !filters.unreadOnly"
>
<i class="pi pi-bell" />
<span>Não lidas</span>
<span class="mw-side__count" :class="{ 'is-danger': summary.unreadTotal > 0 }">{{ summary.unreadTotal }}</span>
</button>
</div>
</div>
<!-- Atribuição -->
<div class="mw-w mw-w--side">
<div class="mw-w__head">
<span class="mw-w__title"><i class="pi pi-user" /> Atribuição</span>
<button
v-if="filters.assigned"
class="mw-side__clear-inline"
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">
<button
class="mw-side__item"
:class="{ 'is-active': !filters.assigned }"
@click="filters.assigned = null"
>
<i class="pi pi-list" />
<span>Todas</span>
</button>
<button
class="mw-side__item"
:class="{ 'is-active': filters.assigned === 'me' }"
@click="filters.assigned = 'me'"
>
<i class="pi pi-user" />
<span>Minhas</span>
<span class="mw-side__count">{{ mineCount }}</span>
</button>
<button
class="mw-side__item"
:class="{ 'is-active': filters.assigned === 'unassigned' }"
@click="filters.assigned = 'unassigned'"
>
<i class="pi pi-user-minus" />
<span>Não atribuídas</span>
<span class="mw-side__count" :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">
<div class="mw-w__head">
<span class="mw-w__title"><i class="pi pi-chart-bar" /> Por status</span>
</div>
<div class="mw-side__list">
<div
v-for="col in KANBAN_COLUMNS"
:key="col.key"
class="mw-side__row"
:class="`is-${col.color}`"
>
<i :class="col.icon" />
<span>{{ col.label }}</span>
<span class="mw-side__count">{{ summary[col.key] || 0 }}</span>
</div>
</div>
</div>
<!-- Canais -->
<div class="mw-w mw-w--side">
<div class="mw-w__head">
<span class="mw-w__title"><i class="pi pi-send" /> Canais</span>
<button
v-if="filters.channel"
class="mw-side__clear-inline"
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">
<button
v-for="opt in CHANNEL_OPTIONS"
:key="String(opt.value)"
class="mw-side__item"
:class="{ 'is-active': 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 (zera busca,
unread, atribuição e canal de uma vez). -->
<Transition name="mw-clear">
<div v-if="hasActiveFilters" class="mw-side__footer">
<button class="mw-side__clear-all" @click="clearAllFilters">
<i class="pi pi-filter-slash" />
<span>Limpar filtros</span>
</button>
</div>
</Transition>
</aside>
</Teleport>
<!-- COL 2: Kanban grid -->
<div class="mw-main">
<!-- Kanban -->
<div class="mw-kanban">
<div
v-for="col in KANBAN_COLUMNS"
:key="col.key"
class="mw-col"
:class="`is-${col.color}`"
>
<div class="mw-col__head">
<div class="mw-col__title">
<i :class="col.icon" />
<span>{{ col.label }}</span>
</div>
<span class="mw-col__count">{{ byKanban[col.key].length }}</span>
</div>
<div class="mw-col__body">
<!-- Skeleton na primeira carga, na coluna que estiver vazia -->
<template v-if="carregandoInicial && !byKanban[col.key].length">
<div v-for="i in 2" :key="`csk-${col.key}-${i}`" class="mw-card mw-card--skeleton" aria-busy="true">
<div style="display:flex; flex-direction:column; gap:6px; flex:1;">
<span class="melissa-skeleton melissa-skeleton--title" :style="{ width: `${50 + (i * 13) % 30}%` }" />
<span class="melissa-skeleton melissa-skeleton--text" style="width: 80%;" />
</div>
</div>
</template>
<div v-else-if="!byKanban[col.key].length" class="mw-col__empty">
<i class="pi pi-comments" />
<span>Nenhuma conversa</span>
</div>
<button
v-for="t in byKanban[col.key]"
v-else
:key="t.thread_key"
class="mw-card"
@click="onCardClick(t)"
>
<div class="mw-card__head">
<i :class="['pi', channelIcon(t.channel), 'mw-card__channel']" />
<span class="mw-card__name">{{ contactLabel(t) }}</span>
<span v-if="t.unread_count > 0" class="mw-card__unread">{{ t.unread_count }}</span>
</div>
<div class="mw-card__msg">
<i v-if="t.last_message_direction === 'outbound'" class="pi pi-arrow-right text-[0.55rem] mr-1 opacity-60" />
{{ truncate(t.last_message_body, 70) }}
</div>
<div v-if="tagsForThread(t.thread_key).length" class="mw-card__tags">
<span
v-for="tag in tagsForThread(t.thread_key)"
:key="tag.id"
class="mw-card__tag"
:style="{ background: tag.color + '20', color: tag.color, borderColor: tag.color + '40' }"
>
<i v-if="tag.icon" :class="tag.icon" />
{{ tag.name }}
</span>
</div>
<div class="mw-card__foot">
<span class="mw-card__time">{{ fmtRelative(t.last_message_at) }}</span>
<span
v-if="t.assigned_to"
class="mw-card__assignee"
:class="{ 'is-mine': t.assigned_to === currentUserId }"
v-tooltip.top="t.assigned_to === currentUserId ? 'Atribuída a mim' : 'Atribuída a ' + (memberNameMap[t.assigned_to] || '')"
>
<i class="pi pi-user" />
{{ t.assigned_to === currentUserId ? 'Eu' : assigneeLabel(t.assigned_to) }}
</span>
<span v-else-if="!t.patient_name" class="mw-card__unlinked">não vinculado</span>
</div>
</button>
</div>
</div>
</div>
</div>
</div>
</section>
</template>
<style scoped>
/* Convenção Melissa Page (blueprint §6) */
.mw-page {
position: absolute;
inset: 6px 6px calc(var(--m-dock-h, 76px) + 6px) 6px;
z-index: 40;
display: flex;
flex-direction: column;
background: var(--m-bg-medium);
backdrop-filter: blur(32px) saturate(160%);
-webkit-backdrop-filter: blur(32px) saturate(160%);
border: 1px solid var(--m-border);
border-radius: 18px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
overflow: hidden;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
color: var(--m-text);
animation: mw-page-enter 240ms cubic-bezier(0.2, 0.7, 0.3, 1);
}
@keyframes mw-page-enter {
from { opacity: 0; transform: scale(0.985); }
to { opacity: 1; transform: scale(1); }
}
.mw-page__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--m-border);
flex-shrink: 0;
gap: 10px;
}
.mw-page__title {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
font-size: 1rem;
font-weight: 500;
flex-wrap: wrap;
}
.mw-page__title > span:not(.mw-page__count):not(.mw-page__unread) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mw-page__count {
font-size: 0.7rem;
font-weight: 600;
color: var(--m-accent);
background: var(--m-accent-soft);
border: 1px solid color-mix(in srgb, var(--m-accent) 35%, transparent);
padding: 2px 8px;
border-radius: 999px;
}
.mw-page__unread {
font-size: 0.7rem;
font-weight: 600;
color: rgb(248, 113, 113);
background: rgba(248, 113, 113, 0.12);
border: 1px solid rgba(248, 113, 113, 0.3);
padding: 2px 8px;
border-radius: 999px;
}
.mw-page__actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.mw-close,
.mw-head-btn {
width: 32px;
height: 32px;
display: grid;
place-items: center;
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
color: var(--m-text);
border-radius: 9px;
cursor: pointer;
font-family: inherit;
transition: background-color 140ms ease;
}
.mw-close:hover,
.mw-head-btn:hover { background: var(--m-bg-soft-hover); }
.mw-head-btn > i { font-size: 0.85rem; }
.mw-head-btn--compact-only { display: none; }
/* Search inline (xl+) */
.mw-search {
position: relative;
width: 240px;
}
.mw-search__icon {
position: absolute;
top: 50%;
left: 12px;
transform: translateY(-50%);
color: var(--m-text-muted);
font-size: 0.78rem;
}
.mw-search__input {
width: 100%;
height: 32px;
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
color: var(--m-text);
padding: 0 12px 0 32px;
border-radius: 9px;
font-size: 0.78rem;
font-family: inherit;
outline: none;
transition: border-color 140ms ease, background-color 140ms ease;
}
.mw-search__input:focus {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.mw-search__input::placeholder { color: var(--m-text-faint); }
/* Menu mobile */
.mw-menu-btn {
display: none;
height: 32px;
align-items: center;
gap: 6px;
flex-shrink: 0;
background: var(--m-accent);
border: 1px solid var(--m-accent);
color: white;
padding: 0 11px;
border-radius: 9px;
cursor: pointer;
font-family: inherit;
font-size: 0.78rem;
font-weight: 600;
transition: background-color 140ms ease, transform 140ms ease;
}
.mw-menu-btn:hover {
background: color-mix(in srgb, var(--m-accent) 88%, white);
transform: translateY(-1px);
}
.mw-menu-btn > i { font-size: 0.85rem; }
/* Subheader explicativo (blueprint §9) */
.mw-subheader {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 18px;
border-bottom: 1px solid var(--m-border);
background: var(--m-bg-soft);
font-size: 0.78rem;
color: var(--m-text-muted);
line-height: 1.45;
flex-shrink: 0;
}
.mw-subheader__icon {
color: var(--p-primary-color);
font-size: 0.92rem;
flex-shrink: 0;
margin-top: 1px;
}
.mw-subheader__text { flex: 1; min-width: 0; }
.mw-subheader__text strong { color: var(--m-text); font-weight: 600; }
/* Body — sem padding/gap; a sidebar tem bg+border-right próprios e o
main column controla seu padding interno. Espelha o pattern usado
em MelissaGrupos / MelissaTags / MelissaMedicos. */
.mw-body {
flex: 1;
display: flex;
min-height: 0;
position: relative;
gap: 0;
padding: 0;
}
/* Aside — 2 zonas: __scroll (cards) + __footer (Limpar filtros fixo).
bg colorido próprio (--m-bg-soft) + border-right pra separar
visualmente da coluna principal. */
.mw-side {
width: 280px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: var(--m-bg-soft);
border-right: 1px solid var(--m-border);
overflow: hidden;
}
.mw-side__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
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;
}
/* Footer fixo no bottom da sidebar (fora do scroll dos filter cards).
Aparece com fade+collapse quando algum filtro está ativo. */
.mw-side__footer {
flex-shrink: 0;
padding: 12px;
background: var(--m-bg-soft);
border-top: 1px solid var(--m-border);
}
.mw-side__clear-all {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 9px 12px;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
color: var(--m-text);
border-radius: 10px;
cursor: pointer;
font-family: inherit;
font-size: 0.78rem;
font-weight: 600;
transition: background-color 140ms ease, border-color 140ms ease, color 140ms ease;
}
.mw-side__clear-all:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
color: var(--m-text);
}
.mw-side__clear-all > i {
font-size: 0.78rem;
color: var(--m-text-muted);
transition: color 140ms ease;
}
.mw-side__clear-all:hover > i { color: var(--m-text); }
/* X inline ao lado do título de cada filter card — limpa o filtro
individual. Espelha o pattern do MelissaPacientes/Grupos/Tags. */
.mw-side__clear-inline {
width: 18px;
height: 18px;
display: grid;
place-items: center;
background: transparent;
border: 1px solid color-mix(in srgb, rgb(220, 38, 38) 30%, var(--m-border));
color: rgb(220, 38, 38);
border-radius: 4px;
cursor: pointer;
font-family: inherit;
transition: background-color 140ms ease, border-color 140ms ease;
}
.mw-side__clear-inline:hover {
background: rgba(220, 38, 38, 0.10);
border-color: rgba(220, 38, 38, 0.55);
}
.mw-side__clear-inline > i { font-size: 0.6rem; }
/* 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;
}
/* Card-base — alinhado com .ma-w / .mp-w / .mcr-w: surface --m-bg-medium
pra destacar do bg da sidebar (--m-bg-soft). */
.mw-w {
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-radius: 12px;
padding: 12px;
}
/* Modifier pros cards dentro da .mw-side — margem lateral + sombra
sutil pra elevar sobre o bg da sidebar. Espelha .mc-w--side, .mt-w--side. */
.mw-w--side {
margin: 12px 12px 0;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
.mw-w--side:last-of-type { margin-bottom: 12px; }
.mw-w__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.mw-w__title {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.78rem;
font-weight: 600;
}
.mw-w__title > i { color: var(--m-text-muted); font-size: 0.78rem; }
.mw-side__list {
display: flex;
flex-direction: column;
gap: 4px;
}
.mw-side__item {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
background: transparent;
border: 1px solid transparent;
color: var(--m-text);
border-radius: 10px;
cursor: pointer;
font-family: inherit;
font-size: 0.82rem;
text-align: left;
transition: background-color 140ms ease, border-color 140ms ease;
}
.mw-side__item:hover { background: var(--m-bg-soft-hover); }
.mw-side__item.is-active {
background: var(--m-accent-soft);
border-color: color-mix(in srgb, var(--m-accent) 35%, var(--m-border));
}
.mw-side__item.is-warn { background: rgba(248, 113, 113, 0.05); }
.mw-side__item > i {
color: var(--m-text-muted);
font-size: 0.75rem;
width: 14px;
text-align: center;
}
.mw-side__item > span:first-of-type { flex: 1; }
.mw-side__count {
font-size: 0.65rem;
font-weight: 600;
color: var(--m-text-muted);
background: var(--m-bg-medium);
padding: 1px 7px;
border-radius: 999px;
min-width: 22px;
text-align: center;
}
.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);
}
.mw-side__row {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 10px;
border-radius: 10px;
border: 1px solid var(--m-border);
background: var(--m-bg-medium);
font-size: 0.78rem;
}
.mw-side__row > span:first-of-type { flex: 1; }
.mw-side__row > i { font-size: 0.75rem; }
.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); }
.mw-alert {
display: flex;
gap: 10px;
margin: 12px 12px 0;
padding: 12px;
border-radius: 10px;
border: 1px solid rgba(251, 191, 36, 0.3);
background: rgba(251, 191, 36, 0.05);
color: rgb(251, 191, 36);
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
.mw-alert:last-child { margin-bottom: 12px; }
.mw-alert > i { font-size: 0.85rem; margin-top: 2px; }
.mw-alert__title {
font-size: 0.78rem;
font-weight: 600;
}
.mw-alert__hint {
font-size: 0.7rem;
color: var(--m-text-muted);
margin-top: 2px;
}
/* Main — recebe padding interno (o body não tem mais padding/gap;
sidebar fica colada à esquerda com border-right). */
.mw-main {
flex: 1;
min-width: 0;
overflow: hidden;
display: flex;
flex-direction: column;
padding: 12px;
}
/* Kanban */
.mw-kanban {
flex: 1;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
min-height: 0;
}
.mw-col {
display: flex;
flex-direction: column;
/* Coluna do kanban também é "card" — mesma surface dos demais. */
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-radius: 12px;
overflow: hidden;
min-height: 0;
}
.mw-col.is-red { border-color: rgba(248, 113, 113, 0.35); }
.mw-col.is-amber { border-color: rgba(251, 191, 36, 0.35); }
.mw-col.is-blue { border-color: rgba(96, 165, 250, 0.35); }
.mw-col.is-emerald { border-color: rgba(74, 222, 128, 0.35); }
.mw-col__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid var(--m-border);
flex-shrink: 0;
}
.mw-col.is-red .mw-col__head { background: rgba(248, 113, 113, 0.05); border-bottom-color: rgba(248, 113, 113, 0.25); }
.mw-col.is-amber .mw-col__head { background: rgba(251, 191, 36, 0.05); border-bottom-color: rgba(251, 191, 36, 0.25); }
.mw-col.is-blue .mw-col__head { background: rgba(96, 165, 250, 0.05); border-bottom-color: rgba(96, 165, 250, 0.25); }
.mw-col.is-emerald .mw-col__head { background: rgba(74, 222, 128, 0.05); border-bottom-color: rgba(74, 222, 128, 0.25); }
.mw-col__title {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.78rem;
font-weight: 600;
}
.mw-col__title > i { font-size: 0.78rem; }
.mw-col.is-red .mw-col__title > i { color: rgb(248, 113, 113); }
.mw-col.is-amber .mw-col__title > i { color: rgb(251, 191, 36); }
.mw-col.is-blue .mw-col__title > i { color: rgb(96, 165, 250); }
.mw-col.is-emerald .mw-col__title > i { color: rgb(74, 222, 128); }
.mw-col__count {
font-size: 0.65rem;
font-weight: 600;
color: var(--m-text);
background: var(--m-bg-medium);
padding: 1px 7px;
border-radius: 999px;
min-width: 22px;
text-align: center;
}
.mw-col__body {
flex: 1;
overflow-y: auto;
padding: 8px;
display: flex;
flex-direction: column;
gap: 8px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mw-col__body::-webkit-scrollbar { width: 5px; }
.mw-col__body::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
.mw-col__empty {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 24px 12px;
color: var(--m-text-faint);
font-size: 0.72rem;
font-style: italic;
}
.mw-col__empty > i { font-size: 1.5rem; opacity: 0.4; }
/* Card de conversa — fica DENTRO da coluna .mw-col (que agora usa
--m-bg-medium). Pra ter contraste, o card usa --m-bg-soft (mais sutil). */
.mw-card {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
border-radius: 10px;
cursor: pointer;
text-align: left;
font-family: inherit;
color: var(--m-text);
transition: background-color 140ms ease, border-color 140ms ease, transform 140ms ease;
}
.mw-card:hover {
background: var(--m-bg-soft-hover);
border-color: color-mix(in srgb, var(--m-accent) 40%, var(--m-border));
transform: translateY(-1px);
}
.mw-card--skeleton { cursor: default; pointer-events: none; opacity: 0.95; }
.mw-card--skeleton:hover { background: var(--m-bg-soft); transform: none; }
.mw-card__head {
display: flex;
align-items: center;
gap: 6px;
}
.mw-card__channel {
color: var(--m-text-muted);
font-size: 0.7rem;
}
.mw-card__name {
flex: 1;
min-width: 0;
font-size: 0.82rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mw-card__unread {
font-size: 0.62rem;
font-weight: 700;
color: white;
background: rgb(248, 113, 113);
padding: 1px 6px;
border-radius: 999px;
min-width: 18px;
text-align: center;
}
.mw-card__msg {
font-size: 0.74rem;
color: var(--m-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mw-card__tags {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
}
.mw-card__tag {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 0.6rem;
font-weight: 600;
padding: 1px 7px;
border-radius: 999px;
border: 1px solid;
line-height: 1.4;
}
.mw-card__tag > i { font-size: 0.55rem; }
.mw-card__foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
font-size: 0.65rem;
color: var(--m-text-faint);
}
.mw-card__time { font-weight: 500; }
.mw-card__assignee {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 1px 6px;
border-radius: 999px;
border: 1px solid var(--m-border);
background: var(--m-bg-soft);
color: var(--m-text);
font-size: 0.6rem;
font-weight: 600;
}
.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);
}
.mw-card__assignee > i { font-size: 0.55rem; }
.mw-card__unlinked { font-style: italic; }
/* Popover */
.mw-actions {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 240px;
padding: 4px;
}
.mw-actions__group {
display: flex;
flex-direction: column;
gap: 6px;
}
.mw-actions__label {
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--text-color-secondary, var(--m-text-faint));
font-size: 0.62rem;
font-weight: 600;
}
/* Drawer mobile (blueprint §6) */
.mw-mobile-drawer {
position: fixed;
top: 0;
left: 0;
height: 100vh;
height: 100dvh;
width: min(360px, 88vw);
z-index: 80;
background: var(--m-bg-medium);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
border-right: 1px solid var(--m-border);
transform: translateX(-100%);
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
color: var(--m-text);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
display: flex;
flex-direction: column;
}
.mw-mobile-drawer.is-open { transform: translateX(0); }
.mw-mobile-drawer__scroll {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.mw-mobile-drawer__scroll .mw-side {
flex: 1;
min-height: 0;
width: 100%;
overflow: hidden;
background: transparent;
border-right: none;
display: flex;
flex-direction: column;
}
.mw-mobile-drawer__scroll .mw-side__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mw-mobile-drawer__scroll .mw-side__scroll::-webkit-scrollbar { width: 5px; }
.mw-mobile-drawer__scroll .mw-side__scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
.mw-mobile-drawer__scroll .mw-w--side {
margin: 0;
flex-shrink: 0;
}
.mw-mobile-drawer__scroll .mw-w--side:last-of-type { margin-bottom: 0; }
.mw-mobile-drawer__scroll .mw-alert { margin: 0; flex-shrink: 0; }
.mw-mobile-drawer__scroll .mw-side__footer {
flex-shrink: 0;
margin: 0;
padding: 12px;
background: var(--m-bg-medium);
border-top: 1px solid var(--m-border);
backdrop-filter: blur(24px) saturate(160%);
-webkit-backdrop-filter: blur(24px) saturate(160%);
}
.mw-mobile-drawer__backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 79;
}
.mw-drawer-fade-enter-active,
.mw-drawer-fade-leave-active { transition: opacity 200ms ease; }
.mw-drawer-fade-enter-from,
.mw-drawer-fade-leave-to { opacity: 0; }
/* ═══ Compact (<xl) — busca migra pra popover, kanban 2-col ═══ */
@media (max-width: 1279px) {
.mw-search--xl-only { display: none; }
.mw-head-btn--compact-only { display: grid; }
.mw-kanban { grid-template-columns: repeat(2, 1fr); }
}
/* ═══ Mobile (<lg) — drawer + kanban 1-col stacked ═══
Em mobile o kanban vira flex column (stacked) e o scroll passa a ser
global no .mw-main (não interno por coluna). Cada .mw-col cresce com
o conteúdo + min-height pra empty state ter altura visível. */
@media (max-width: 1023px) {
.mw-body { flex-direction: column; padding: 0; }
.mw-main {
width: 100%;
padding: 8px;
overflow-y: auto;
}
.mw-kanban {
display: flex;
flex-direction: column;
flex: none;
gap: 8px;
}
.mw-col {
flex: none;
min-height: 200px;
}
.mw-col__body {
flex: none;
overflow: visible;
min-height: 80px;
}
.mw-page__title > span:first-of-type { display: none; }
.mw-menu-btn--mobile-only { display: inline-flex; }
}
</style>