Files
agenciapsilmno/src/layout/melissa/MelissaAgendamentosRecebidos.vue
T
Leonardo 269b531158 Melissa: blueprint tabular + Cadastros/Agendamentos/Pacientes + restore
Sprint E (05-05). Blueprint tabular oficial pras paginas Melissa de
listagem (DataTable + sidebar com stats e filtros coloridos, view
toggle list/grade, subheader explicativo, mobile pencil+popover).

Novo arquivo:
- blueprints/melissa-table-page-blueprint.md (~530L, 18 secoes) —
  referencia canonica MelissaCadastrosRecebidos

Paginas refatoradas/criadas:
- MelissaCadastrosRecebidos: refator pra blueprint (DataTable + frozen
  action + view toggle + subheader)
- MelissaAgendamentosRecebidos (NOVO): substitui o embed via
  MelissaEmbed; 4 status coloridos (Pendente/Autorizado/Convertido/
  Recusado), 3 acoes condicionais (Recusar/Autorizar/Converter em
  sessao), wired com AgendaEventDialog
- MelissaPacientes: refator parcial (subheader, sombras, status pills
  coloridas, email/phone colunas proprias, mobile pencil+popover, fix
  scroll mobile com min-height:0 na .mp-list, view toggle persistido,
  tags/grupos color fix g.cor->g.color, restore de arquivados)
- MelissaEmbed: agendamentos-recebidos removido do EMBED_MAP
- MelissaLayout: wire-up MelissaAgendamentosRecebidos nativo
- composables/useMelissaPacientes + useMelissaPacientesAside ajustes

Restore de pacientes arquivados:
- patientsRepository: novo restorePatient(id, { tenantId })
- PatientsCadastroPage statusOpts: +Arquivado (fecha gap de
  inconsistencia ao editar paciente arquivado)

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

1970 lines
70 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/layout/melissa/MelissaAgendamentosRecebidos.vue
| Data: 2026-05-05
|
| Página Melissa nativa de Agendamentos Recebidos (solicitações vindas
| do agendador online). Espelha 1:1 o blueprint de
| `blueprints/melissa-table-page-blueprint.md`, com prefixo `mar`.
|
| Difere da CadastrosRecebidos por:
| - Tabela: agendador_solicitacoes (não patient_intake_requests)
| - 4 status: pendente / autorizado / convertido / recusado
| - Ações: Autorizar / Recusar (com motivo) / Converter em sessão
| - Conversão abre AgendaEventDialog (cria paciente + cria evento)
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.vue';
import { useAgendaSettings } from '@/features/agenda/composables/useAgendaSettings';
import { useDeterminedCommitments } from '@/features/agenda/composables/useDeterminedCommitments';
import { useAgendaEvents } from '@/features/agenda/composables/useAgendaEvents';
const emit = defineEmits(['close']);
const toast = useToast();
const confirm = useConfirm();
const tenantStore = useTenantStore();
// ── Identidade ────────────────────────────────────────────
const isClinic = computed(() => tenantStore.role === 'clinic_admin' || tenantStore.role === 'tenant_admin');
const tenantId = computed(() => tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null);
const ownerId = ref(null);
// ── Breakpoints + drawer ───────────────────────────────────
const drawerOpen = ref(false);
const isMobile = ref(false);
let _mqMobile = null;
function _onMqMobileChange(e) {
isMobile.value = e.matches;
if (!e.matches) drawerOpen.value = false;
}
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
function fecharDrawer() { drawerOpen.value = false; }
// ── Estado ─────────────────────────────────────────────────
const loading = ref(false);
const rows = ref([]);
const busca = ref('');
const statusFilter = ref('pendente'); // default — mais actionable
const carregandoInicial = computed(
() => loading.value && rows.value.length === 0
);
// ── Formatters ─────────────────────────────────────────────
function onlyDigits(v) { return String(v ?? '').replace(/\D/g, ''); }
function fmtPhoneBR(v) {
const d = onlyDigits(v);
if (!d) return '—';
if (d.length === 11) return `(${d.slice(0,2)}) ${d.slice(2,7)}-${d.slice(7,11)}`;
if (d.length === 10) return `(${d.slice(0,2)}) ${d.slice(2,6)}-${d.slice(6,10)}`;
return d;
}
function fmtCPF(v) {
const d = onlyDigits(v);
if (!d || d.length !== 11) return d || '—';
return `${d.slice(0,3)}.${d.slice(3,6)}.${d.slice(6,9)}-${d.slice(9,11)}`;
}
function fmtData(iso) {
if (!iso) return '—';
const [y, m, d] = String(iso).split('-');
if (!y || !m || !d) return iso;
const dias = ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb'];
return `${dias[new Date(+y, +m - 1, +d).getDay()]}, ${d}/${m}/${y}`;
}
function fmtHora(h) {
return h ? String(h).slice(0, 5) : '—';
}
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 nomeCompleto(s) {
return `${s?.paciente_nome || ''} ${s?.paciente_sobrenome || ''}`.trim() || '—';
}
function pacienteIniciais(s) {
const partes = `${s?.paciente_nome || ''} ${s?.paciente_sobrenome || ''}`.trim().split(/\s+/);
if (partes.length === 1) return partes[0][0]?.toUpperCase() || '?';
return (partes[0][0] + (partes[partes.length - 1][0] || '')).toUpperCase();
}
function dash(v) {
const s = String(v ?? '').trim();
return s ? s : '—';
}
// ── Labels ─────────────────────────────────────────────────
const TIPO_LABEL = { primeira: 'Primeira Entrevista', retorno: 'Retorno', reagendar: 'Reagendamento' };
const MODAL_LABEL = { presencial: 'Presencial', online: 'Online', ambos: 'Ambos' };
function tipoLabel(t) { return TIPO_LABEL[t] || t || '—'; }
function modalidadeLabel(m) { return MODAL_LABEL[m] || m || '—'; }
// ── Status helpers ─────────────────────────────────────────
function statusLabel(s) {
return {
pendente: 'Pendente',
autorizado: 'Autorizado',
convertido: 'Convertido',
recusado: 'Recusado',
expirado: 'Expirado'
}[s] || s || '—';
}
function statusClass(s) {
return {
pendente: 'is-pendente',
autorizado: 'is-autorizado',
convertido: 'is-convertido',
recusado: 'is-recusado',
expirado: 'is-expirado'
}[s] || '';
}
function isExpirada(s) {
if (s?.status !== 'pendente' || !s?.reservado_ate) return false;
return new Date(s.reservado_ate) < new Date();
}
// ── Stats ──────────────────────────────────────────────────
const stats = computed(() => {
const all = rows.value;
const p = all.filter((r) => r.status === 'pendente').length;
const a = all.filter((r) => r.status === 'autorizado').length;
const c = all.filter((r) => r.status === 'convertido').length;
const x = all.filter((r) => r.status === 'recusado').length;
return [
{ key: 'total', label: 'Total', value: all.length, cls: 'neutral' },
{ key: 'pendente', label: 'Pendentes', value: p, cls: p > 0 ? 'info' : 'neutral' },
{ key: 'autorizado', label: 'Autorizados', value: a, cls: a > 0 ? 'ok' : 'neutral' },
{ key: 'recusado', label: 'Recusados', value: x, cls: x > 0 ? 'danger' : 'neutral' }
];
});
const STATUS_FILTER_OPTIONS = [
{ key: 'pendente', label: 'Pendentes', icon: 'pi pi-clock' },
{ key: 'autorizado', label: 'Autorizados', icon: 'pi pi-check-circle' },
{ key: 'convertido', label: 'Convertidos', icon: 'pi pi-calendar-plus' },
{ key: 'recusado', label: 'Recusados', icon: 'pi pi-times-circle' }
];
function toggleStatusFilter(s) {
statusFilter.value = statusFilter.value === s ? '' : s;
}
// ── Filtragem ──────────────────────────────────────────────
const filtered = computed(() => {
const term = String(busca.value || '').trim().toLowerCase();
let list = rows.value;
if (statusFilter.value) list = list.filter((r) => r.status === statusFilter.value);
if (!term) return list;
return list.filter((r) => {
const nome = nomeCompleto(r).toLowerCase();
const email = String(r.paciente_email || '').toLowerCase();
const tel = onlyDigits(r.paciente_celular);
return nome.includes(term) || email.includes(term) || tel.includes(term);
});
});
// ── Paginação compartilhada (DataTable + grid) ─────────────
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
const rowsMAR = ref(10);
const firstMAR = ref(0);
function onPage(event) {
firstMAR.value = event.first;
rowsMAR.value = event.rows;
}
watch([busca, statusFilter], () => { firstMAR.value = 0; });
function onRowClick(event) {
if (event?.data) openDetails(event.data);
}
function rowStatusClass(data) {
return statusClass(data?.status);
}
// ── View mode persistido ──────────────────────────────────
const VIEW_MODE_KEY = 'mar.viewMode.v1';
const viewMode = ref('list');
try {
const saved = localStorage.getItem(VIEW_MODE_KEY);
if (saved === 'list' || saved === 'grid') viewMode.value = saved;
} catch (_) {}
function setViewMode(m) {
if (m !== 'list' && m !== 'grid') return;
viewMode.value = m;
try { localStorage.setItem(VIEW_MODE_KEY, m); } catch (_) {}
}
const pagedItems = computed(() =>
filtered.value.slice(firstMAR.value, firstMAR.value + rowsMAR.value)
);
// ── Fetch ──────────────────────────────────────────────────
async function loadOwnerId() {
const { data } = await supabase.auth.getUser();
ownerId.value = data?.user?.id || null;
}
async function fetchSolicitacoes() {
if (!ownerId.value) return;
loading.value = true;
try {
let q = supabase
.from('agendador_solicitacoes')
.select('id, owner_id, tenant_id, paciente_nome, paciente_sobrenome, paciente_email, paciente_celular, paciente_cpf, tipo, modalidade, data_solicitada, hora_solicitada, reservado_ate, motivo, como_conheceu, status, recusado_motivo, autorizado_em, created_at')
.order('data_solicitada', { ascending: false })
.order('hora_solicitada', { ascending: true });
if (isClinic.value) q = q.eq('tenant_id', tenantId.value);
else q = q.eq('owner_id', ownerId.value);
const { data, error } = await q;
if (error) throw error;
// Sort: pendente > autorizado > convertido > recusado > expirado
const weight = (s) => ({ pendente: 0, autorizado: 1, convertido: 2, recusado: 3, expirado: 4 }[s] ?? 9);
rows.value = (data || []).slice().sort((a, b) => {
const wa = weight(a.status), wb = weight(b.status);
if (wa !== wb) return wa - wb;
return new Date(b.created_at || 0).getTime() - new Date(a.created_at || 0).getTime();
});
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao carregar', detail: e?.message || String(e), life: 3500 });
} finally {
loading.value = false;
}
}
// ── Dialog detalhes ────────────────────────────────────────
const dlg = ref({ open: false, item: null, saving: false, recusa_note: '' });
function openDetails(row) {
dlg.value.open = true;
dlg.value.item = row;
dlg.value.recusa_note = row?.recusado_motivo || '';
}
function closeDlg() {
dlg.value.open = false;
dlg.value.saving = false;
dlg.value.item = null;
dlg.value.recusa_note = '';
}
const dlgSections = computed(() => {
const i = dlg.value.item;
if (!i) return [];
const sec = (title, rs) => ({ title, rows: rs.filter((r) => r.value && r.value !== '—') });
return [
sec('Solicitação', [
{ label: 'Data', value: fmtData(i.data_solicitada) },
{ label: 'Horário', value: fmtHora(i.hora_solicitada) },
{ label: 'Tipo', value: tipoLabel(i.tipo) },
{ label: 'Modalidade', value: modalidadeLabel(i.modalidade) },
{ label: 'Status', value: statusLabel(i.status) },
{ label: 'Recebida', value: fmtRelative(i.created_at) }
]),
sec('Contato', [
{ label: 'Email', value: dash(i.paciente_email) },
{ label: 'Celular', value: fmtPhoneBR(i.paciente_celular) },
{ label: 'CPF', value: fmtCPF(i.paciente_cpf) }
]),
sec('Detalhes', [
{ label: 'Motivo', value: dash(i.motivo) },
{ label: 'Como conheceu', value: dash(i.como_conheceu) },
{ label: 'Motivo recusa', value: dash(i.recusado_motivo) }
])
].filter((s) => s.rows.length > 0);
});
// ── Aprovar / Autorizar ────────────────────────────────────
async function autorizar() {
const item = dlg.value.item;
if (!item) return;
dlg.value.saving = true;
try {
const { error } = await supabase
.from('agendador_solicitacoes')
.update({ status: 'autorizado', autorizado_em: new Date().toISOString() })
.eq('id', item.id);
if (error) throw error;
toast.add({ severity: 'success', summary: 'Autorizado', detail: `Solicitação de ${nomeCompleto(item)} autorizada.`, life: 2500 });
await fetchSolicitacoes();
const updated = rows.value.find((r) => r.id === item.id);
if (updated) openDetails(updated); else closeDlg();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
} finally {
dlg.value.saving = false;
}
}
// ── Recusar ───────────────────────────────────────────────
async function recusar() {
const item = dlg.value.item;
if (!item) return;
confirm.require({
message: 'Marcar esta solicitação como recusada?',
header: 'Confirmar recusa',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Recusar',
rejectLabel: 'Cancelar',
acceptSeverity: 'danger',
accept: async () => {
dlg.value.saving = true;
try {
const motivo = String(dlg.value.recusa_note || '').trim() || null;
const { error } = await supabase
.from('agendador_solicitacoes')
.update({ status: 'recusado', recusado_motivo: motivo })
.eq('id', item.id);
if (error) throw error;
toast.add({ severity: 'info', summary: 'Recusado', life: 2200 });
await fetchSolicitacoes();
const updated = rows.value.find((r) => r.id === item.id);
if (updated) openDetails(updated); else closeDlg();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
} finally {
dlg.value.saving = false;
}
}
});
}
// ── Converter em sessão ───────────────────────────────────
const { settings: agendaSettings, load: loadAgendaSettings } = useAgendaSettings();
const { create: createEvento } = useAgendaEvents();
const { rows: commitmentRows, load: loadCommitments } = useDeterminedCommitments(tenantId);
const commitmentOptions = computed(() => (commitmentRows.value || []).filter((c) => c.active !== false));
const sessionCommitmentId = computed(() => commitmentOptions.value.find((c) => c.native_key === 'session')?.id || null);
const eventDialogOpen = ref(false);
const eventRow = ref(null);
const convertendoId = ref(null);
let _convertTarget = null;
function isUuid(v) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(v || ''));
}
async function encontrarOuCriarPaciente(s) {
const email = s.paciente_email?.toLowerCase().trim();
if (email) {
const { data: found } = await supabase
.from('patients').select('id')
.eq('tenant_id', tenantId.value)
.ilike('email_principal', email)
.maybeSingle();
if (found?.id) return found.id;
}
const { data: memberData, error: memberErr } = await supabase
.from('tenant_members').select('id')
.eq('tenant_id', tenantId.value)
.eq('user_id', ownerId.value)
.eq('status', 'active')
.maybeSingle();
if (memberErr || !memberData?.id) throw new Error('Membro ativo não encontrado para criação do paciente.');
const scope = isClinic.value ? 'clinic' : 'therapist';
const nome = [s.paciente_nome, s.paciente_sobrenome].filter(Boolean).join(' ');
const { data: novo, error: criErr } = await supabase
.from('patients')
.insert({
tenant_id: tenantId.value,
responsible_member_id: memberData.id,
owner_id: ownerId.value,
nome_completo: nome,
email_principal: email || null,
telefone: onlyDigits(s.paciente_celular) || null,
cpf: onlyDigits(s.paciente_cpf) || null,
onde_nos_conheceu: s.como_conheceu || null,
observacoes: s.motivo ? `Motivo da consulta: ${s.motivo}` : null,
patient_scope: scope,
therapist_member_id: scope === 'therapist' ? memberData.id : null,
status: 'Ativo'
})
.select('id').single();
if (criErr) throw new Error(`Falha ao criar paciente: ${criErr.message}`);
toast.add({ severity: 'info', summary: 'Paciente criado', detail: `${nome} foi adicionado à sua lista.`, life: 3000 });
return novo.id;
}
async function converterEmSessao() {
const item = dlg.value.item;
if (!item || convertendoId.value) return;
convertendoId.value = item.id;
try {
const pacienteId = await encontrarOuCriarPaciente(item);
const hora = fmtHora(item.hora_solicitada);
eventRow.value = {
owner_id: item.owner_id,
tipo: 'sessao',
modalidade: item.modalidade || 'presencial',
inicio_em: `${item.data_solicitada}T${hora}:00`,
patient_id: pacienteId,
paciente_id: pacienteId,
paciente_nome: nomeCompleto(item),
_solicitacaoId: item.id
};
_convertTarget = item;
// Fecha o dialog de detalhes pra abrir o AgendaEventDialog limpo.
dlg.value.open = false;
eventDialogOpen.value = true;
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 });
} finally {
convertendoId.value = null;
}
}
async function onEventSaved(arg) {
eventDialogOpen.value = false;
if (!_convertTarget) return;
const target = _convertTarget;
_convertTarget = null;
try {
const isWrapped = !!arg && Object.prototype.hasOwnProperty.call(arg, 'payload');
const raw = isWrapped ? arg.payload : arg;
const normalized = { ...raw };
if (!normalized.owner_id) normalized.owner_id = ownerId.value;
normalized.tenant_id = tenantId.value;
normalized.tipo = 'sessao';
if (!normalized.status) normalized.status = 'agendado';
if (!String(normalized.titulo || '').trim()) normalized.titulo = 'Sessão';
if (!normalized.visibility_scope) normalized.visibility_scope = 'public';
if (!isUuid(normalized.paciente_id)) normalized.paciente_id = null;
if (normalized.determined_commitment_id && !isUuid(normalized.determined_commitment_id)) normalized.determined_commitment_id = null;
const dbFields = ['tenant_id', 'owner_id', 'terapeuta_id', 'patient_id', 'tipo', 'status', 'titulo', 'observacoes', 'inicio_em', 'fim_em', 'visibility_scope', 'determined_commitment_id', 'titulo_custom', 'extra_fields', 'modalidade'];
const dbPayload = {};
for (const k of dbFields) if (normalized[k] !== undefined) dbPayload[k] = normalized[k];
await createEvento(dbPayload);
const { error } = await supabase
.from('agendador_solicitacoes')
.update({ status: 'convertido' })
.eq('id', target.id);
if (error) throw error;
toast.add({ severity: 'success', summary: 'Convertido!', detail: `Sessão criada para ${nomeCompleto(target)}.`, life: 4000 });
await fetchSolicitacoes();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao converter', detail: e?.message, life: 4000 });
}
}
function onEventDialogClose() {
eventDialogOpen.value = false;
_convertTarget = null;
eventRow.value = null;
}
onMounted(async () => {
if (typeof window !== 'undefined' && window.matchMedia) {
_mqMobile = window.matchMedia('(max-width: 1023px)');
isMobile.value = _mqMobile.matches;
_mqMobile.addEventListener('change', _onMqMobileChange);
}
if (typeof tenantStore.loadSessionAndTenant === 'function') {
await tenantStore.loadSessionAndTenant();
}
await loadOwnerId();
await Promise.all([loadAgendaSettings(), loadCommitments(), fetchSolicitacoes()]);
});
onBeforeUnmount(() => {
if (_mqMobile) _mqMobile.removeEventListener('change', _onMqMobileChange);
});
</script>
<template>
<aside
class="mar-mobile-drawer"
:class="{ 'is-open': drawerOpen }"
v-show="isMobile"
aria-label="Estatísticas e filtros"
>
<div id="mar-mobile-drawer-target" class="mar-mobile-drawer__scroll" />
</aside>
<Transition name="mar-drawer-fade">
<div
v-if="isMobile && drawerOpen"
class="mar-mobile-drawer__backdrop"
@click="fecharDrawer"
/>
</Transition>
<section class="mar-page">
<header class="mar-page__head">
<button
class="mar-menu-btn mar-menu-btn--mobile-only"
v-tooltip.bottom="'Estatísticas & filtros'"
@click="toggleDrawer"
>
<i class="pi pi-bars" />
<span>Menu</span>
</button>
<div class="mar-page__title">
<i class="pi pi-inbox mar-page__title-icon" />
<span>Agendamentos recebidos</span>
<span class="mar-page__count">{{ filtered.length }}</span>
</div>
<div class="mar-page__actions">
<button
class="mar-head-btn"
v-tooltip.bottom="'Recarregar'"
:disabled="loading"
@click="fetchSolicitacoes"
>
<i :class="loading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'" />
</button>
<button class="mar-close" v-tooltip.bottom="'Voltar (Esc)'" @click="emit('close')">
<i class="pi pi-times" />
</button>
</div>
</header>
<!-- Subheader explicativo diferencia visualmente desta página
(vs. Cadastros Recebidos, que tem layout idêntico). Padrão
do melissa-table-page-blueprint.md. -->
<div class="mar-subheader">
<i class="pi pi-info-circle mar-subheader__icon" />
<span class="mar-subheader__text">
Solicitações de horário vindas do agendador online à espera de
ação. <strong>Autorize</strong> pra reservar o slot,
<strong>recuse</strong> com motivo, ou
<strong>converta direto em sessão</strong> a gente cria o
paciente automaticamente se ainda não existir.
</span>
</div>
<div class="mar-body">
<Teleport to="#mar-mobile-drawer-target" :disabled="!isMobile">
<aside class="mar-side">
<!-- Stats -->
<div class="mar-w mar-w--side">
<div class="mar-w__head">
<span class="mar-w__title"><i class="pi pi-chart-bar" /> Estatísticas</span>
</div>
<div class="mar-stats">
<template v-if="carregandoInicial">
<div v-for="i in 4" :key="`stsk-${i}`" class="mar-stat" aria-busy="true">
<div class="mar-stat__val melissa-skeleton melissa-skeleton--number" />
<div class="mar-stat__lbl melissa-skeleton melissa-skeleton--text" style="width: 60%; margin-top: 6px;" />
</div>
</template>
<div
v-for="s in stats"
v-else
:key="s.key"
class="mar-stat"
:class="`is-${s.cls}`"
>
<div class="mar-stat__val">{{ s.value }}</div>
<div class="mar-stat__lbl">{{ s.label }}</div>
</div>
</div>
</div>
<!-- Filtro de status -->
<div class="mar-w mar-w--side">
<div class="mar-w__head">
<span class="mar-w__title"><i class="pi pi-filter" /> Status</span>
<span v-if="statusFilter" class="mar-w__count">1</span>
</div>
<div class="mar-side__list">
<button
v-for="o in STATUS_FILTER_OPTIONS"
:key="o.key"
class="mar-side__item"
:class="[`is-${o.key}`, { 'is-active': statusFilter === o.key }]"
@click="toggleStatusFilter(o.key)"
>
<i :class="o.icon" />
<span>{{ o.label }}</span>
</button>
<Transition name="mar-clear">
<button
v-if="statusFilter"
class="mar-side__item is-clear"
@click="statusFilter = ''"
>
<i class="pi pi-filter-slash" />
<span>Limpar filtro</span>
</button>
</Transition>
</div>
</div>
</aside>
</Teleport>
<div class="mar-main">
<div class="mar-toolbar">
<div class="mar-search">
<i class="pi pi-search mar-search__icon" />
<input
v-model="busca"
type="text"
placeholder="Buscar por nome, email ou telefone…"
class="mar-search__input"
/>
<button
v-if="busca"
class="mar-search__clear"
v-tooltip.bottom="'Limpar busca'"
@click="busca = ''"
>
<i class="pi pi-times" />
</button>
</div>
<div class="mar-view-toggle" role="group" aria-label="Visualização">
<button
class="mar-view-toggle__btn"
:class="{ 'is-active': viewMode === 'list' }"
v-tooltip.bottom="'Lista'"
aria-label="Lista"
@click="setViewMode('list')"
>
<i class="pi pi-list" />
</button>
<button
class="mar-view-toggle__btn"
:class="{ 'is-active': viewMode === 'grid' }"
v-tooltip.bottom="'Grade'"
aria-label="Grade"
@click="setViewMode('grid')"
>
<i class="pi pi-th-large" />
</button>
</div>
</div>
<DataTable
v-if="viewMode === 'list'"
:value="filtered"
:loading="loading"
dataKey="id"
paginator
:rows="rowsMAR"
:first="firstMAR"
:rowsPerPageOptions="PAGE_SIZE_OPTIONS"
paginatorTemplate="RowsPerPageDropdown FirstPageLink PrevPageLink CurrentPageReport NextPageLink LastPageLink"
currentPageReportTemplate="{first}{last} de {totalRecords}"
:rowClass="rowStatusClass"
selectionMode="single"
scrollable
scrollHeight="flex"
tableStyle="min-width: 760px"
class="mar-table"
@row-click="onRowClick"
@page="onPage"
>
<Column header="Paciente" style="min-width: 220px">
<template #body="{ data }">
<div class="mar-row__patient">
<span class="mar-card__avatar mar-card__avatar--sm">
<span>{{ pacienteIniciais(data) }}</span>
</span>
<div class="mar-row__patient-text">
<span class="mar-row__name">{{ nomeCompleto(data) }}</span>
<span class="mar-card__badge" :class="statusClass(data.status)">
{{ statusLabel(data.status) }}
</span>
</div>
</div>
</template>
</Column>
<Column header="Solicitado" style="min-width: 180px">
<template #body="{ data }">
<div class="mar-row__when">
<span class="mar-row__when-data">
<i class="pi pi-calendar" /> {{ fmtData(data.data_solicitada) }}
</span>
<span class="mar-row__when-hora">
<i class="pi pi-clock" /> {{ fmtHora(data.hora_solicitada) }}
</span>
</div>
</template>
</Column>
<Column header="Tipo" style="min-width: 160px">
<template #body="{ data }">
<div class="mar-row__tipo">
<span class="mar-chip">{{ tipoLabel(data.tipo) }}</span>
<span class="mar-chip mar-chip--muted">{{ modalidadeLabel(data.modalidade) }}</span>
</div>
</template>
</Column>
<Column header="Contato" style="min-width: 200px">
<template #body="{ data }">
<div class="mar-row__contact">
<span v-if="data.paciente_email"><i class="pi pi-envelope" /> {{ data.paciente_email }}</span>
<span v-if="data.paciente_celular"><i class="pi pi-phone" /> {{ fmtPhoneBR(data.paciente_celular) }}</span>
<span v-if="!data.paciente_email && !data.paciente_celular" class="mar-row__empty"></span>
</div>
</template>
</Column>
<Column header="Recebido" style="width: 130px">
<template #body="{ data }">
<span class="mar-row__time">
<i class="pi pi-clock" /> {{ fmtRelative(data.created_at) }}
</span>
</template>
</Column>
<Column
header=""
:style="{ width: '60px', maxWidth: '60px', minWidth: '60px' }"
frozen
alignFrozen="right"
>
<template #body="{ data }">
<button
class="mar-row__action"
v-tooltip.left="'Editar'"
aria-label="Editar"
@click.stop="openDetails(data)"
>
<i class="pi pi-pencil" />
</button>
</template>
</Column>
<template #empty>
<div class="mar-empty">
<i class="pi pi-inbox mar-empty__icon" />
<div class="mar-empty__title">Nenhuma solicitação encontrada</div>
<div class="mar-empty__hint">
<template v-if="busca || statusFilter">Ajuste os filtros pra ver mais.</template>
<template v-else>Solicitações vindas do agendador online aparecem aqui.</template>
</div>
</div>
</template>
<template #loading>
<div class="mar-table__loading">
<i class="pi pi-spin pi-spinner" />
<span>Carregando solicitações</span>
</div>
</template>
</DataTable>
<div v-else-if="viewMode === 'grid'" class="mar-grid-wrap">
<div v-if="loading && filtered.length === 0" class="mar-table__loading mar-grid__loading">
<i class="pi pi-spin pi-spinner" />
<span>Carregando solicitações</span>
</div>
<div v-else-if="filtered.length === 0" class="mar-empty">
<i class="pi pi-inbox mar-empty__icon" />
<div class="mar-empty__title">Nenhuma solicitação encontrada</div>
<div class="mar-empty__hint">
<template v-if="busca || statusFilter">Ajuste os filtros pra ver mais.</template>
<template v-else>Solicitações vindas do agendador online aparecem aqui.</template>
</div>
</div>
<div v-else class="mar-grid">
<div
v-for="r in pagedItems"
:key="r.id"
class="mar-grid__card"
:class="statusClass(r.status)"
role="button"
tabindex="0"
@click="openDetails(r)"
@keydown.enter.prevent="openDetails(r)"
@keydown.space.prevent="openDetails(r)"
>
<div class="mar-grid__top">
<span class="mar-card__avatar">
<span>{{ pacienteIniciais(r) }}</span>
</span>
<div class="mar-grid__top-right">
<span class="mar-card__badge" :class="statusClass(r.status)">
{{ statusLabel(r.status) }}
</span>
<button
class="mar-row__action"
v-tooltip.left="'Editar'"
aria-label="Editar"
@click.stop="openDetails(r)"
>
<i class="pi pi-pencil" />
</button>
</div>
</div>
<div class="mar-grid__name">{{ nomeCompleto(r) }}</div>
<div class="mar-grid__when">
<span><i class="pi pi-calendar" /> {{ fmtData(r.data_solicitada) }}</span>
<span><i class="pi pi-clock" /> {{ fmtHora(r.hora_solicitada) }}</span>
</div>
<div class="mar-grid__meta">
<span v-if="r.paciente_email"><i class="pi pi-envelope" /> {{ r.paciente_email }}</span>
<span v-if="r.paciente_celular"><i class="pi pi-phone" /> {{ fmtPhoneBR(r.paciente_celular) }}</span>
</div>
<div class="mar-grid__chips">
<span class="mar-chip">{{ tipoLabel(r.tipo) }}</span>
<span class="mar-chip mar-chip--muted">{{ modalidadeLabel(r.modalidade) }}</span>
</div>
<div class="mar-grid__time">
<i class="pi pi-clock" /> {{ fmtRelative(r.created_at) }}
</div>
</div>
</div>
<Paginator
v-if="filtered.length > 0"
class="mar-paginator"
:rows="rowsMAR"
:totalRecords="filtered.length"
:first="firstMAR"
:rowsPerPageOptions="PAGE_SIZE_OPTIONS"
template="RowsPerPageDropdown FirstPageLink PrevPageLink CurrentPageReport NextPageLink LastPageLink"
currentPageReportTemplate="{first}{last} de {totalRecords}"
@page="onPage"
/>
</div>
</div>
</div>
<!-- Dialog detalhes -->
<Dialog
:visible="dlg.open"
modal
dismissable-mask
:style="{ width: '660px', maxWidth: '94vw' }"
:header="nomeCompleto(dlg.item) || 'Solicitação'"
@update:visible="(v) => !v && closeDlg()"
>
<div v-if="dlg.item" class="flex flex-col gap-4">
<!-- Avatar + status + criado em -->
<div class="flex items-start gap-3">
<div class="mar-dlg__avatar">
<span>{{ pacienteIniciais(dlg.item) }}</span>
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-bold">{{ nomeCompleto(dlg.item) }}</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1 flex items-center gap-2 flex-wrap">
<span class="mar-card__badge" :class="statusClass(dlg.item.status)">
{{ statusLabel(dlg.item.status) }}
</span>
<span v-if="isExpirada(dlg.item)" class="mar-card__badge is-expirado">
Expirada
</span>
<span>· Recebida {{ fmtRelative(dlg.item.created_at) }}</span>
</div>
</div>
</div>
<!-- Seções -->
<div v-for="sec in dlgSections" :key="sec.title" class="flex flex-col gap-1">
<div class="text-[0.62rem] uppercase tracking-wider font-semibold text-[var(--text-color-secondary)] opacity-70">{{ sec.title }}</div>
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
<template v-for="r in sec.rows" :key="r.label">
<div class="text-[var(--text-color-secondary)]">{{ r.label }}</div>
<div class="text-[var(--text-color)]">{{ r.value }}</div>
</template>
</div>
</div>
<!-- Motivo de recusa (editável quando ainda não recusado) -->
<div v-if="dlg.item.status === 'pendente' || dlg.item.status === 'autorizado'" class="flex flex-col gap-1">
<label class="text-[0.62rem] uppercase tracking-wider font-semibold text-[var(--text-color-secondary)] opacity-70">
Motivo da recusa (opcional)
</label>
<Textarea v-model="dlg.recusa_note" autoResize rows="2" class="w-full text-xs" :disabled="dlg.saving" />
</div>
</div>
<template #footer>
<div class="flex items-center gap-2 w-full flex-wrap">
<Button label="Fechar" text @click="closeDlg" />
<div class="flex-1" />
<Button
v-if="dlg.item && dlg.item.status === 'pendente'"
label="Recusar"
severity="danger"
outlined
:disabled="dlg.saving"
@click="recusar"
/>
<Button
v-if="dlg.item && dlg.item.status === 'pendente'"
:label="dlg.saving ? 'Autorizando…' : 'Autorizar'"
:loading="dlg.saving"
:disabled="dlg.saving"
severity="success"
@click="autorizar"
/>
<Button
v-if="dlg.item && (dlg.item.status === 'pendente' || dlg.item.status === 'autorizado')"
:label="convertendoId ? 'Convertendo…' : 'Converter em sessão'"
:loading="!!convertendoId"
:disabled="dlg.saving || !!convertendoId"
@click="converterEmSessao"
/>
</div>
</template>
</Dialog>
<!-- AgendaEventDialog pra criar a sessão durante a conversão.
Props batem com a API canônica usada na AgendamentosRecebidosPage
original (event-row, agenda-settings, commitment-options, etc.). -->
<AgendaEventDialog
v-model="eventDialogOpen"
:event-row="eventRow"
:owner-id="ownerId"
:tenant-id="tenantId"
:agenda-settings="agendaSettings"
:commitment-options="commitmentOptions"
:preset-commitment-id="sessionCommitmentId"
:restrict-patients-to-owner="!isClinic"
:patient-scope-owner-id="!isClinic ? ownerId : null"
@save="onEventSaved"
@update:modelValue="(v) => { if (!v) onEventDialogClose(); }"
/>
</section>
</template>
<style scoped>
.mar-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: mar-page-enter 240ms cubic-bezier(0.2, 0.7, 0.3, 1);
}
@keyframes mar-page-enter {
from { opacity: 0; transform: scale(0.985); }
to { opacity: 1; transform: scale(1); }
}
.mar-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;
}
.mar-page__title {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
font-size: 1rem;
font-weight: 500;
}
.mar-page__title-icon {
color: var(--p-primary-color);
font-size: 1.05rem;
}
.mar-page__title > span:not(.mar-page__count) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mar-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;
}
.mar-page__actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.mar-close, .mar-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;
}
.mar-close:hover, .mar-head-btn:hover { background: var(--m-bg-soft-hover); }
.mar-head-btn > i { font-size: 0.85rem; }
.mar-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;
}
.mar-menu-btn:hover {
background: color-mix(in srgb, var(--m-accent) 88%, white);
transform: translateY(-1px);
}
/* Subheader explicativo — faixa abaixo do page__head com texto contextual
sobre a página. Bg sutil + ícone primary + tipografia menor. Diferencia
esta página de outras tabelas Melissa que têm layout visual idêntico. */
.mar-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;
}
.mar-subheader__icon {
color: var(--p-primary-color);
font-size: 0.92rem;
flex-shrink: 0;
margin-top: 1px;
}
.mar-subheader__text {
flex: 1;
min-width: 0;
}
.mar-subheader__text strong {
color: var(--m-text);
font-weight: 600;
}
.mar-body {
flex: 1;
display: flex;
min-height: 0;
gap: 0;
padding: 0;
}
.mar-side {
width: 280px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0;
overflow-y: auto;
background: var(--m-bg-soft);
border-right: 1px solid var(--m-border);
}
.mar-side::-webkit-scrollbar { width: 5px; }
.mar-side::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
/* Cards na sidebar */
.mar-w {
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-radius: 12px;
padding: 12px;
}
.mar-w--side {
margin: 12px 12px 0;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
.mar-w--side:last-of-type { margin-bottom: 12px; }
.mar-w__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.mar-w__title {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--m-text-muted);
font-weight: 600;
}
.mar-w__title > i { color: var(--m-text-muted); font-size: 0.7rem; }
.mar-w__count {
font-size: 0.65rem;
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: 1px 7px;
border-radius: 999px;
}
.mar-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 6px;
}
.mar-stat {
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
border-radius: 10px;
padding: 8px 10px;
}
.mar-stat__val {
font-size: 1.1rem;
font-weight: 600;
line-height: 1.1;
}
.mar-stat__lbl {
font-size: 0.65rem;
color: var(--m-text-muted);
margin-top: 4px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.mar-stat.is-info .mar-stat__val { color: rgb(37, 99, 235); }
.mar-stat.is-ok .mar-stat__val { color: rgb(22, 163, 74); }
.mar-stat.is-danger .mar-stat__val { color: rgb(220, 38, 38); }
/* Filtro de status — botões coloridos */
.mar-side__list {
display: flex;
flex-direction: column;
gap: 4px;
}
.mar-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, box-shadow 140ms ease;
}
.mar-side__item > i {
font-size: 0.78rem;
width: 14px;
text-align: center;
}
/* Pendente — azul */
.mar-side__item.is-pendente {
background: rgba(37, 99, 235, 0.05);
border-color: rgba(37, 99, 235, 0.18);
}
.mar-side__item.is-pendente > i { color: rgb(37, 99, 235); }
.mar-side__item.is-pendente:hover {
background: rgba(37, 99, 235, 0.10);
border-color: rgba(37, 99, 235, 0.30);
}
.mar-side__item.is-active.is-pendente {
background: rgba(37, 99, 235, 0.16);
border-color: rgba(37, 99, 235, 0.55);
box-shadow: 0 0 0 1px rgba(37, 99, 235, 0.35);
}
/* Autorizado — verde */
.mar-side__item.is-autorizado {
background: rgba(22, 163, 74, 0.05);
border-color: rgba(22, 163, 74, 0.18);
}
.mar-side__item.is-autorizado > i { color: rgb(22, 163, 74); }
.mar-side__item.is-autorizado:hover {
background: rgba(22, 163, 74, 0.10);
border-color: rgba(22, 163, 74, 0.30);
}
.mar-side__item.is-active.is-autorizado {
background: rgba(22, 163, 74, 0.16);
border-color: rgba(22, 163, 74, 0.55);
box-shadow: 0 0 0 1px rgba(22, 163, 74, 0.35);
}
/* Convertido — teal (final, distinto do autorizado) */
.mar-side__item.is-convertido {
background: rgba(13, 148, 136, 0.05);
border-color: rgba(13, 148, 136, 0.18);
}
.mar-side__item.is-convertido > i { color: rgb(13, 148, 136); }
.mar-side__item.is-convertido:hover {
background: rgba(13, 148, 136, 0.10);
border-color: rgba(13, 148, 136, 0.30);
}
.mar-side__item.is-active.is-convertido {
background: rgba(13, 148, 136, 0.16);
border-color: rgba(13, 148, 136, 0.55);
box-shadow: 0 0 0 1px rgba(13, 148, 136, 0.35);
}
/* Recusado — vermelho */
.mar-side__item.is-recusado {
background: rgba(220, 38, 38, 0.05);
border-color: rgba(220, 38, 38, 0.18);
}
.mar-side__item.is-recusado > i { color: rgb(220, 38, 38); }
.mar-side__item.is-recusado:hover {
background: rgba(220, 38, 38, 0.10);
border-color: rgba(220, 38, 38, 0.30);
}
.mar-side__item.is-active.is-recusado {
background: rgba(220, 38, 38, 0.16);
border-color: rgba(220, 38, 38, 0.55);
box-shadow: 0 0 0 1px rgba(220, 38, 38, 0.35);
}
/* Limpar filtro */
.mar-side__item.is-clear {
margin-top: 4px;
background: var(--m-bg-soft);
border-color: var(--m-border);
color: var(--m-text-muted);
font-style: italic;
}
.mar-side__item.is-clear > i { color: var(--m-text-muted); }
.mar-side__item.is-clear:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
color: var(--m-text);
}
.mar-side__item.is-clear:hover > i { color: var(--m-text); }
/* Transition Limpar filtro */
.mar-clear-enter-active,
.mar-clear-leave-active {
transition: opacity 220ms ease, transform 220ms ease, max-height 220ms ease, margin-top 220ms ease;
overflow: hidden;
}
.mar-clear-enter-from,
.mar-clear-leave-to {
opacity: 0;
transform: translateY(-4px);
max-height: 0;
margin-top: 0;
}
.mar-clear-enter-to,
.mar-clear-leave-from {
opacity: 1;
transform: translateY(0);
max-height: 40px;
}
/* Main — toolbar + DataTable + grid */
.mar-main {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
padding: 12px;
gap: 10px;
}
/* Toolbar */
.mar-toolbar {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 10px;
}
.mar-search {
position: relative;
flex: 1;
min-width: 0;
display: flex;
align-items: center;
}
.mar-search__icon {
position: absolute;
left: 12px;
color: var(--m-text-muted);
font-size: 0.78rem;
pointer-events: none;
}
.mar-search__input {
width: 100%;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
color: var(--m-text);
padding: 9px 36px 9px 34px;
border-radius: 10px;
font-size: 0.85rem;
font-family: inherit;
outline: none;
transition: background-color 140ms ease, border-color 140ms ease;
}
.mar-search__input::placeholder { color: var(--m-text-faint); }
.mar-search__input:focus {
border-color: var(--m-border-strong);
}
.mar-search__clear {
position: absolute;
right: 8px;
width: 22px;
height: 22px;
display: grid;
place-items: center;
background: transparent;
border: none;
color: var(--m-text-muted);
border-radius: 6px;
cursor: pointer;
font-family: inherit;
transition: background-color 140ms ease, color 140ms ease;
}
.mar-search__clear:hover {
background: var(--m-bg-soft-hover);
color: var(--m-text);
}
.mar-search__clear > i { font-size: 0.7rem; }
.mar-view-toggle {
flex-shrink: 0;
display: inline-flex;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-radius: 10px;
padding: 2px;
gap: 2px;
}
.mar-view-toggle__btn {
width: 32px;
height: 32px;
display: grid;
place-items: center;
background: transparent;
border: none;
color: var(--m-text-muted);
border-radius: 8px;
cursor: pointer;
font-family: inherit;
transition: background-color 140ms ease, color 140ms ease;
}
.mar-view-toggle__btn:hover {
background: var(--m-bg-soft-hover);
color: var(--m-text);
}
.mar-view-toggle__btn.is-active {
background: var(--m-accent-soft);
color: var(--m-accent);
}
.mar-view-toggle__btn > i { font-size: 0.85rem; }
/* DataTable wrapper */
.mar-table {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.mar-table :deep(.p-datatable) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: transparent;
border: 1px solid var(--m-border);
border-radius: 10px;
overflow: hidden;
}
.mar-table :deep(.p-datatable-table-container) {
flex: 1;
min-height: 0;
background: transparent;
}
.mar-table :deep(.p-datatable-thead),
.mar-table :deep(.p-datatable-thead > tr) {
background: transparent !important;
}
.mar-table :deep(.p-datatable-thead > tr > th) {
background: var(--p-content-background) !important;
color: var(--m-text);
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 10px 14px;
border-bottom: 1px solid var(--m-border);
}
.mar-table :deep(.p-datatable-tbody > tr) {
background: transparent;
color: var(--m-text);
cursor: pointer;
transition: background-color 140ms ease;
border-left: 3px solid var(--m-border);
}
.mar-table :deep(.p-datatable-tbody > tr > td) {
padding: 10px 14px;
border-bottom: 1px solid var(--m-border);
background: transparent;
vertical-align: middle;
}
.mar-table :deep(.p-datatable-tbody > tr:hover) {
background: var(--m-bg-soft-hover);
}
.mar-table :deep(.p-datatable-tbody > tr.p-datatable-row-selected) {
background: var(--m-accent-soft);
}
/* Border-left por status */
.mar-table :deep(.p-datatable-tbody > tr.is-pendente) { border-left-color: rgb(37, 99, 235); }
.mar-table :deep(.p-datatable-tbody > tr.is-autorizado) { border-left-color: rgb(22, 163, 74); }
.mar-table :deep(.p-datatable-tbody > tr.is-convertido) { border-left-color: rgb(13, 148, 136); }
.mar-table :deep(.p-datatable-tbody > tr.is-recusado) { border-left-color: rgb(220, 38, 38); opacity: 0.85; }
.mar-table :deep(.p-datatable-tbody > tr.is-expirado) { border-left-color: var(--m-text-faint); opacity: 0.7; }
/* Loading */
.mar-table :deep(.p-datatable-loading-overlay) {
background: color-mix(in srgb, var(--m-bg-medium) 70%, transparent);
backdrop-filter: blur(2px);
}
.mar-table__loading {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--m-text);
font-size: 0.85rem;
}
/* Paginator */
.mar-table :deep(.p-paginator) {
background: var(--m-bg-medium);
border: none;
border-top: 1px solid var(--m-border);
padding: 8px 12px;
justify-content: center;
flex-wrap: wrap;
gap: 6px;
}
.mar-table :deep(.p-paginator-current) {
color: var(--m-text-muted);
font-size: 0.78rem;
background: transparent;
border: none;
padding: 0 6px;
}
.mar-table :deep(.p-paginator-first),
.mar-table :deep(.p-paginator-prev),
.mar-table :deep(.p-paginator-next),
.mar-table :deep(.p-paginator-last),
.mar-table :deep(.p-paginator-page) {
min-width: 30px;
height: 30px;
color: var(--m-text);
background: transparent;
border: 1px solid var(--m-border);
border-radius: 8px;
font-size: 0.8rem;
transition: background-color 140ms ease, border-color 140ms ease;
}
.mar-table :deep(.p-paginator-page.p-paginator-page-selected) {
background: var(--m-accent-soft);
border-color: var(--m-accent-strong);
color: var(--m-accent);
}
.mar-table :deep(.p-paginator-first:not(.p-disabled):hover),
.mar-table :deep(.p-paginator-prev:not(.p-disabled):hover),
.mar-table :deep(.p-paginator-next:not(.p-disabled):hover),
.mar-table :deep(.p-paginator-last:not(.p-disabled):hover),
.mar-table :deep(.p-paginator-page:not(.p-paginator-page-selected):hover) {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.mar-table :deep(.p-select) {
background: transparent;
border: 1px solid var(--m-border);
border-radius: 8px;
height: 30px;
font-size: 0.78rem;
color: var(--m-text);
display: inline-flex;
align-items: center;
}
.mar-table :deep(.p-select-label) {
padding: 0 8px;
color: var(--m-text);
font-size: 0.78rem;
display: flex;
align-items: center;
line-height: 1;
height: 100%;
background: transparent;
}
/* Coluna frozen "Ação" */
.mar-table :deep(td.p-datatable-frozen-column),
.mar-table :deep(th.p-datatable-frozen-column) {
background: var(--p-content-background) !important;
box-shadow: -3px 0 6px -3px rgba(0, 0, 0, 0.18);
z-index: 1;
}
.mar-table :deep(.p-datatable-tbody > tr:hover td.p-datatable-frozen-column) {
background: var(--m-bg-soft-hover);
}
.mar-table :deep(.p-datatable-tbody > tr.p-datatable-row-selected td.p-datatable-frozen-column) {
background: var(--m-accent-soft);
}
/* Conteúdo das células */
.mar-row__patient {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.mar-row__patient-text {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.mar-row__name {
font-size: 0.88rem;
font-weight: 600;
color: var(--m-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mar-row__when {
display: flex;
flex-direction: column;
gap: 3px;
font-size: 0.78rem;
color: var(--m-text);
}
.mar-row__when-data { font-weight: 500; }
.mar-row__when-hora { color: var(--m-text-muted); font-size: 0.74rem; }
.mar-row__when i { margin-right: 5px; font-size: 0.7rem; }
.mar-row__tipo {
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-start;
}
.mar-row__contact {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.78rem;
color: var(--m-text-muted);
min-width: 0;
}
.mar-row__contact i { margin-right: 5px; font-size: 0.7rem; }
.mar-row__contact span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mar-row__empty { color: var(--m-text-faint); }
.mar-row__time {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.78rem;
color: var(--m-text-muted);
}
.mar-row__time i { font-size: 0.7rem; }
/* Chips (Tipo/Modalidade) */
.mar-chip {
display: inline-flex;
align-items: center;
font-size: 0.66rem;
font-weight: 600;
padding: 2px 8px;
border-radius: 999px;
color: var(--m-text);
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.mar-chip--muted {
color: var(--m-text-muted);
background: transparent;
}
/* Botão de ação (pencil) */
.mar-row__action {
width: 30px;
height: 30px;
display: grid;
place-items: center;
background: var(--p-content-background);
border: 1px solid color-mix(in srgb, var(--p-primary-color) 30%, var(--m-border));
color: var(--p-primary-color);
border-radius: 8px;
cursor: pointer;
font-family: inherit;
transition: background-color 140ms ease, border-color 140ms ease, color 140ms ease;
}
.mar-row__action:hover {
background: color-mix(in srgb, var(--p-primary-color) 12%, var(--p-content-background));
border-color: var(--p-primary-color);
color: var(--p-primary-color);
}
.mar-row__action > i { font-size: 0.78rem; }
/* Avatar */
.mar-card__avatar {
width: 40px; height: 40px;
border-radius: 50%;
background: var(--m-accent-strong);
border: 1px solid var(--m-accent);
color: white;
font-size: 0.78rem;
font-weight: 600;
display: grid; place-items: center;
flex-shrink: 0;
overflow: hidden;
}
.mar-card__avatar--sm {
width: 32px; height: 32px;
font-size: 0.7rem;
}
.mar-dlg__avatar {
width: 56px; height: 56px;
border-radius: 50%;
background: var(--m-accent-strong);
border: 1px solid var(--m-accent);
color: white;
font-size: 1rem;
font-weight: 600;
display: grid; place-items: center;
flex-shrink: 0;
}
/* Badge de status */
.mar-card__badge {
display: inline-flex;
align-items: center;
font-size: 0.62rem;
font-weight: 600;
padding: 2px 8px;
border-radius: 999px;
text-transform: uppercase;
letter-spacing: 0.04em;
border: 1px solid;
align-self: flex-start;
}
.mar-card__badge.is-pendente {
color: rgb(37, 99, 235);
background: rgba(37, 99, 235, 0.12);
border-color: rgba(37, 99, 235, 0.3);
}
.mar-card__badge.is-autorizado {
color: rgb(22, 163, 74);
background: rgba(22, 163, 74, 0.12);
border-color: rgba(22, 163, 74, 0.3);
}
.mar-card__badge.is-convertido {
color: rgb(13, 148, 136);
background: rgba(13, 148, 136, 0.12);
border-color: rgba(13, 148, 136, 0.3);
}
.mar-card__badge.is-recusado {
color: rgb(220, 38, 38);
background: rgba(220, 38, 38, 0.12);
border-color: rgba(220, 38, 38, 0.3);
}
.mar-card__badge.is-expirado {
color: var(--m-text-muted);
background: var(--m-bg-soft);
border-color: var(--m-border);
}
/* Empty state */
.mar-empty {
margin: 24px 0;
padding: 56px 28px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
color: var(--m-text-muted);
border: 2px dashed var(--m-border-strong);
border-radius: 12px;
background: color-mix(in srgb, var(--m-bg-soft) 40%, transparent);
gap: 8px;
}
.mar-empty__icon { font-size: 2rem; color: var(--m-text-faint); margin-bottom: 4px; }
.mar-empty__title { font-size: 0.92rem; font-weight: 600; color: var(--m-text); }
.mar-empty__hint { font-size: 0.78rem; }
/* Grid view */
.mar-grid-wrap {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 10px;
border: 1px solid var(--m-border);
border-radius: 10px;
overflow: hidden;
background: transparent;
}
.mar-grid {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 12px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 10px;
align-content: start;
}
.mar-grid::-webkit-scrollbar { width: 5px; }
.mar-grid::-webkit-scrollbar-thumb { background: var(--m-border-strong); border-radius: 3px; }
.mar-grid__loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
}
.mar-grid__card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
border-left: 3px solid var(--m-border-strong);
border-radius: 10px;
color: var(--m-text);
text-align: left;
cursor: pointer;
font-family: inherit;
transition: background-color 140ms ease, border-color 140ms ease, transform 140ms ease;
}
.mar-grid__card:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
transform: translateY(-1px);
}
.mar-grid__card:focus-visible {
outline: 2px solid var(--p-primary-color);
outline-offset: 2px;
}
.mar-grid__card.is-pendente { border-left-color: rgb(37, 99, 235); }
.mar-grid__card.is-autorizado { border-left-color: rgb(22, 163, 74); }
.mar-grid__card.is-convertido { border-left-color: rgb(13, 148, 136); }
.mar-grid__card.is-recusado { border-left-color: rgb(220, 38, 38); opacity: 0.85; }
.mar-grid__card.is-expirado { border-left-color: var(--m-text-faint); opacity: 0.7; }
.mar-grid__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.mar-grid__top-right {
display: inline-flex;
align-items: center;
gap: 6px;
}
.mar-grid__name {
font-size: 0.92rem;
font-weight: 600;
color: var(--m-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mar-grid__when {
display: flex;
flex-wrap: wrap;
gap: 8px;
font-size: 0.74rem;
color: var(--m-text);
}
.mar-grid__when i { margin-right: 4px; font-size: 0.66rem; color: var(--m-text-muted); }
.mar-grid__meta {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.74rem;
color: var(--m-text-muted);
min-width: 0;
}
.mar-grid__meta i { margin-right: 5px; font-size: 0.7rem; }
.mar-grid__meta span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mar-grid__chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.mar-grid__time {
font-size: 0.68rem;
color: var(--m-text-faint);
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: auto;
}
.mar-grid__time i { font-size: 0.62rem; }
/* Paginator standalone (grid view) */
.mar-paginator.p-paginator {
background: var(--m-bg-medium);
border: none;
border-top: 1px solid var(--m-border);
padding: 8px 12px;
justify-content: center;
flex-wrap: wrap;
gap: 6px;
flex-shrink: 0;
}
.mar-paginator.p-paginator .p-paginator-current {
color: var(--m-text-muted);
font-size: 0.78rem;
background: transparent;
border: none;
padding: 0 6px;
}
.mar-paginator.p-paginator .p-paginator-first,
.mar-paginator.p-paginator .p-paginator-prev,
.mar-paginator.p-paginator .p-paginator-next,
.mar-paginator.p-paginator .p-paginator-last,
.mar-paginator.p-paginator .p-paginator-page {
min-width: 30px;
height: 30px;
color: var(--m-text);
background: transparent;
border: 1px solid var(--m-border);
border-radius: 8px;
font-size: 0.8rem;
transition: background-color 140ms ease, border-color 140ms ease;
}
.mar-paginator.p-paginator .p-paginator-page.p-paginator-page-selected {
background: var(--m-accent-soft);
border-color: var(--m-accent-strong);
color: var(--m-accent);
}
.mar-paginator.p-paginator .p-paginator-first:not(.p-disabled):hover,
.mar-paginator.p-paginator .p-paginator-prev:not(.p-disabled):hover,
.mar-paginator.p-paginator .p-paginator-next:not(.p-disabled):hover,
.mar-paginator.p-paginator .p-paginator-last:not(.p-disabled):hover,
.mar-paginator.p-paginator .p-paginator-page:not(.p-paginator-page-selected):hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.mar-paginator.p-paginator .p-select {
background: transparent;
border: 1px solid var(--m-border);
border-radius: 8px;
height: 30px;
font-size: 0.78rem;
color: var(--m-text);
display: inline-flex;
align-items: center;
}
.mar-paginator.p-paginator .p-select-label {
padding: 0 8px;
color: var(--m-text);
font-size: 0.78rem;
display: flex;
align-items: center;
line-height: 1;
height: 100%;
background: transparent;
}
/* Drawer mobile */
.mar-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;
}
.mar-mobile-drawer.is-open { transform: translateX(0); }
.mar-mobile-drawer__scroll {
height: 100%;
overflow-y: auto;
padding: 12px 12px 24px;
display: flex;
flex-direction: column;
gap: 12px;
}
.mar-mobile-drawer__scroll::-webkit-scrollbar { width: 5px; }
.mar-mobile-drawer__scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
.mar-mobile-drawer__scroll .mar-side {
width: 100%;
height: auto;
overflow: visible;
padding: 0;
background: transparent;
border-right: none;
}
.mar-mobile-drawer__scroll .mar-w--side {
margin: 0 0 12px;
}
.mar-mobile-drawer__scroll .mar-w--side:last-of-type {
margin-bottom: 0;
}
.mar-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;
}
.mar-drawer-fade-enter-active,
.mar-drawer-fade-leave-active { transition: opacity 200ms ease; }
.mar-drawer-fade-enter-from,
.mar-drawer-fade-leave-to { opacity: 0; }
@media (max-width: 1023px) {
.mar-body { flex-direction: column; padding: 0; }
.mar-main { width: 100%; padding: 8px; }
.mar-page__title > span:first-of-type { display: none; }
.mar-menu-btn--mobile-only { display: inline-flex; }
}
</style>