Files
agenciapsilmno/src/layout/melissa/MelissaFinanceiroLancamentos.vue
T
Leonardo a7f6bcbe66 F3 schema-per-tenant: frontend usa tenantDb() pra tabelas tenant
- useTenantDb composable + lib/supabase/tenantClient (tenantDb/tenantSchemaName)
- tenantStore: getters activeTenantSlug/activeTenantSchema; my_tenants() RPC
  passa a devolver slug+nome (migration 07)
- codemod scripts/codemod-tenant-db.py: supabase.from('<84 tabelas + 6 views
  tenant>') -> tenantDb().from(...) em 139 arquivos (777 chamadas), remove
  .eq('tenant_id') das cadeias tenant (173)
- passada manual (4 agentes): remove tenant_id de payloads insert/upsert/update,
  selects, .or/.is de defaults; onConflict ajustado pros uniques sem tenant_id
  (singletons usam 'singleton'); realtime de tabelas tenant aponta pro schema
  do tenant ativo; repos dropam tenant_id defensivamente de payloads externos
- agendaSelects: tenant_id fora do AGENDA_EVENT_SELECT (quebraria PostgREST)
- zero embeds cross-schema (todos FK embeds sao tenant->tenant ou global->global)
- build de producao passa; 67 .js checados

Pendente (fora do escopo F3, sao cross-tenant/anon -> F4/F6):
- AgendadorPublicoPage (anon, resolve tenant por link_slug)
- Saas{Feriados,NotificationTemplates,DocumentTemplates,Whatsapp}Page
  (gerenciam defaults do sistema / views cross-tenant)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 04:44:59 -03:00

1914 lines
69 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.
<script setup>
/*
* MelissaFinanceiroLancamentos — Página nativa Melissa pros lançamentos
* financeiros (substitui o embed via MelissaEmbed que duplicava headers).
*
* Aplica o blueprint melissa-table-page-blueprint.md:
* - Sidebar com stats + filtros (Status / Tipo / Paciente / Período)
* + Xs inline + footer fixo "Limpar filtros"
* - Main com DataTable lazy paginada (6 colunas + ações por status)
*
* Lógica idêntica à FinanceiroPage (composable useFinancialRecords +
* RPCs + dialogs registrar pagamento e lançamento manual). Só o chrome
* muda pra eliminar o triplo-header.
*/
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 { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore';
import { useFinancialRecords } from '@/composables/useFinancialRecords';
// DataTable/Column/Dialog/Tag/Select/InputNumber/DatePicker/Textarea/Button: auto via PrimeVueResolver
const emit = defineEmits(['close']);
const { records, loading, error, summary, fetchRecords, markAsPaid, cancelRecord, createManualRecord } = useFinancialRecords();
const toast = useToast();
const confirm = useConfirm();
const tenantStore = useTenantStore();
// ── Breakpoints + drawer mobile (mesmo pattern das demais Melissa Pages) ──
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; }
// ── Helpers de formatação ─────────────────────────────
const _brl = new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' });
function fmtBRL(v) { return _brl.format(v ?? 0); }
function fmtDate(iso) {
if (!iso) return '—';
const d = iso.includes('T') ? new Date(iso) : new Date(iso + 'T00:00:00');
return new Intl.DateTimeFormat('pt-BR').format(d);
}
function fmtDateTime(iso) {
if (!iso) return null;
return new Intl.DateTimeFormat('pt-BR', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit'
}).format(new Date(iso));
}
function isOverdueDate(dueDateIso) {
if (!dueDateIso) return false;
return new Date(dueDateIso + 'T00:00:00') < new Date(new Date().toDateString());
}
// ── Pacientes pro filtro ──────────────────────────────
const patients = ref([]);
async function loadPatients() {
const tenantId = tenantStore.activeTenantId;
if (!tenantId) return;
const { data } = await tenantDb().from('patients')
.select('id, nome_completo, identification_color')
.order('nome_completo');
patients.value = data ?? [];
}
// ── Opções de filtro / pagamento / tipo ───────────────
const STATUS_FILTER_OPTIONS = [
{ key: 'pending', label: 'Pendentes', icon: 'pi pi-clock' },
{ key: 'overdue', label: 'Vencidos', icon: 'pi pi-exclamation-circle' },
{ key: 'paid', label: 'Pagos', icon: 'pi pi-check-circle' },
{ key: 'cancelled', label: 'Cancelados', icon: 'pi pi-times-circle' }
];
const TYPE_FILTER_OPTIONS = [
{ key: 'receita', label: 'Receita', icon: 'pi pi-arrow-up-right' },
{ key: 'despesa', label: 'Despesa', icon: 'pi pi-arrow-down-left' }
];
const PAYMENT_METHOD_OPTIONS = [
{ label: 'Pix', value: 'pix' },
{ label: 'Depósito', value: 'deposito' },
{ label: 'Dinheiro', value: 'dinheiro' },
{ label: 'Cartão', value: 'cartao' },
{ label: 'Cartão (maquininha)', value: 'cartao_maquininha' },
{ label: 'Convênio', value: 'convenio' },
{ label: 'Asaas', value: 'asaas' }
];
function paymentLabel(method) {
return PAYMENT_METHOD_OPTIONS.find((o) => o.value === method)?.label ?? method ?? '—';
}
// Abre link de cobrança externa (Asaas/etc) em nova aba.
// noopener/noreferrer pra segurança (gateway não vira janela parent). 2026-05-14.
function openPaymentLink(url) {
if (!url) return;
window.open(url, '_blank', 'noopener,noreferrer');
}
// ── Filtros reativos ──────────────────────────────────
const filterStatus = ref(null);
const filterType = ref(null);
const filterPatient = ref(null);
const filterDateRange = ref(null);
function setStatusFilter(s) {
filterStatus.value = filterStatus.value === s ? null : s;
}
function setTypeFilter(t) {
filterType.value = filterType.value === t ? null : t;
}
const hasActiveFilters = computed(() =>
filterStatus.value !== null
|| filterType.value !== null
|| filterPatient.value !== null
|| filterDateRange.value !== null
);
function clearAllFilters() {
filterStatus.value = null;
filterType.value = null;
filterPatient.value = null;
filterDateRange.value = null;
}
// Aninhamento visual (2026-05-14): records com mesmo agenda_evento_id viram
// "pai + filho(s)" — o mais antigo (created_at) é o pai (sessão); demais
// (multa, taxa de cancelamento, etc) aparecem indentados embaixo. Pai
// sempre antes dos filhos na lista. Records sem agenda_evento_id (avulso
// manual) ficam como itens soltos. Não reordena entre grupos — só dentro
// de cada grupo, preservando ordem de chegada do servidor.
const recordsGrouped = computed(() => {
const list = records.value || [];
if (list.length === 0) return list;
const groupOrder = [];
const groups = new Map();
for (const r of list) {
const key = r.agenda_evento_id || `solo-${r.id}`;
if (!groups.has(key)) {
groups.set(key, []);
groupOrder.push(key);
}
groups.get(key).push(r);
}
const out = [];
for (const key of groupOrder) {
const group = groups
.get(key)
.slice()
.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
group.forEach((r, idx) => {
out.push({ ...r, _isChild: idx > 0 && group.length > 1, _hasChildren: idx === 0 && group.length > 1 });
});
}
// Ordena por paciente pra rowGroupMode='subheader' do PrimeVue agrupar
// corretamente. Sort estável preserva ordem interna (parent → children
// do mesmo agenda_evento_id; séries em ordem cronológica).
out.sort((a, b) => {
const aName = a.patients?.nome_completo || '~'; // sem paciente vai pro final
const bName = b.patients?.nome_completo || '~';
return aName.localeCompare(bName, 'pt-BR');
});
return out;
});
// Grupos expandidos (default: todos os pacientes da página atual).
// Watcher recalcula sempre que recordsGrouped muda — quando user troca
// pagina/filtro, todos os grupos da nova lista entram expandidos.
const expandedGroups = ref([]);
watch(
recordsGrouped,
(list) => {
const ids = [...new Set((list || []).map((r) => r.patient_id).filter(Boolean))];
expandedGroups.value = ids;
},
{ immediate: true }
);
// Conta lançamentos por paciente na lista atual — exibido no header
// de grupo. Inclui só "pais" (não conta filhos de cobranças extras pra
// não inflar o número).
function _countForPatient(pid) {
if (!pid) return 0;
return (recordsGrouped.value || []).filter((r) => r.patient_id === pid && !r._isChild).length;
}
// ── Paginação server-side ─────────────────────────────
const pageFirst = ref(0);
const pageRows = ref(20);
const totalRecords = ref(0);
const hasLoaded = ref(false);
async function applyFilters(resetPage = true) {
if (resetPage) pageFirst.value = 0;
const f = { limit: pageRows.value, offset: pageFirst.value };
if (filterStatus.value) f.status = filterStatus.value;
if (filterType.value) f.type = filterType.value;
if (filterPatient.value) f.patient_id = filterPatient.value.id;
if (filterDateRange.value) {
const [from, to] = filterDateRange.value;
if (from) f.due_date_from = from instanceof Date ? from.toISOString().slice(0, 10) : from;
if (to) f.due_date_to = to instanceof Date ? to.toISOString().slice(0, 10) : to;
}
const result = await fetchRecords(f);
totalRecords.value = result?.total ?? records.value.length;
hasLoaded.value = true;
}
async function onPageChange(e) {
pageFirst.value = e.first;
pageRows.value = e.rows;
await applyFilters(false);
}
watch([filterStatus, filterType, filterPatient, filterDateRange], () => applyFilters(true), { deep: true });
// ── Status visual config ──────────────────────────────
const STATUS_CFG = {
pending: { label: 'Pendente', severity: 'warn' },
paid: { label: 'Pago', severity: 'success' },
overdue: { label: 'Vencido', severity: 'danger' },
partial: { label: 'Parcial', severity: 'info' },
cancelled: { label: 'Cancelado', severity: 'secondary' },
refunded: { label: 'Estornado', severity: 'secondary', extraClass: 'tag-refunded' }
};
// ── Dialog: Registrar pagamento ───────────────────────
const payDlgVisible = ref(false);
const payDlgRecord = ref(null);
const payDlgMethod = ref(null);
const payDlgLoading = ref(false);
function openPayDialog(record) {
payDlgRecord.value = record;
payDlgMethod.value = null;
payDlgVisible.value = true;
}
async function confirmPayment() {
if (!payDlgMethod.value || !payDlgRecord.value) return;
payDlgLoading.value = true;
try {
const result = await markAsPaid(payDlgRecord.value.id, payDlgMethod.value);
if (!result.ok) throw new Error(result.error);
payDlgVisible.value = false;
toast.add({
severity: 'success',
summary: 'Pagamento registrado',
detail: `${fmtBRL(payDlgRecord.value.final_amount)} via ${paymentLabel(payDlgMethod.value)}.`,
life: 3000
});
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Não foi possível registrar o pagamento.', life: 4000 });
} finally {
payDlgLoading.value = false;
}
}
// ── Cancelar cobrança ─────────────────────────────────
function requestCancel(record) {
confirm.require({
message: `Deseja cancelar a cobrança de ${fmtBRL(record.final_amount)} para ${record.patients?.nome_completo ?? '—'}? Esta ação não pode ser desfeita.`,
header: 'Cancelar cobrança',
icon: 'pi pi-exclamation-triangle',
rejectLabel: 'Não',
acceptLabel: 'Sim, cancelar',
acceptSeverity: 'danger',
accept: async () => {
const result = await cancelRecord(record.id);
if (result.ok) {
toast.add({ severity: 'info', summary: 'Cancelado', detail: 'Cobrança cancelada com sucesso.', life: 3000 });
} else {
toast.add({ severity: 'error', summary: 'Erro', detail: result.error || 'Não foi possível cancelar.', life: 4000 });
}
}
});
}
// ── Dialog: Lançamento manual ─────────────────────────
const manualDlgVisible = ref(false);
const manualDlgLoading = ref(false);
const manualForm = ref({
patient: null,
amount: null,
discount_amount: 0,
due_date: null,
payment_method: null,
notes: ''
});
const manualFinalAmount = computed(() => {
const a = manualForm.value.amount ?? 0;
const d = manualForm.value.discount_amount ?? 0;
return Math.max(0, a - d);
});
function openManualDlg() {
manualForm.value = { patient: null, amount: null, discount_amount: 0, due_date: null, payment_method: null, notes: '' };
manualDlgVisible.value = true;
}
async function saveManualRecord() {
if (!manualForm.value.amount || !manualForm.value.due_date) {
toast.add({ severity: 'warn', summary: 'Campos obrigatórios', detail: 'Preencha o valor e a data de vencimento.', life: 3500 });
return;
}
manualDlgLoading.value = true;
try {
const due = manualForm.value.due_date instanceof Date
? manualForm.value.due_date.toISOString().slice(0, 10)
: manualForm.value.due_date;
const result = await createManualRecord({
patient_id: manualForm.value.patient?.id ?? null,
amount: manualForm.value.amount,
discount_amount: manualForm.value.discount_amount ?? 0,
due_date: due,
payment_method: manualForm.value.payment_method ?? null,
notes: manualForm.value.notes || null
});
if (!result.ok) throw new Error(result.error);
manualDlgVisible.value = false;
toast.add({ severity: 'success', summary: 'Lançamento criado', detail: 'Registro financeiro adicionado.', life: 3000 });
await applyFilters(false);
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Não foi possível salvar.', life: 4000 });
} finally {
manualDlgLoading.value = false;
}
}
// ── Lifecycle ─────────────────────────────────────────
onMounted(async () => {
if (typeof window !== 'undefined' && window.matchMedia) {
_mqMobile = window.matchMedia('(max-width: 1023px)');
isMobile.value = _mqMobile.matches;
_mqMobile.addEventListener('change', _onMqMobileChange);
}
// Garante tenant carregado antes de fetch — sem isso, o useFinancialRecords
// retorna vazio na primeira render direta via URL (bug: lista vazia até
// navegar pelo menu e remontar o componente).
if (typeof tenantStore.ensureLoaded === 'function') {
await tenantStore.ensureLoaded();
}
await Promise.all([loadPatients(), applyFilters()]);
});
onBeforeUnmount(() => {
if (_mqMobile) _mqMobile.removeEventListener('change', _onMqMobileChange);
});
</script>
<template>
<ConfirmDialog />
<!-- Drawer host (mobile) sidebar é teleportada pra quando isMobile -->
<aside
class="mfl-mobile-drawer"
:class="{ 'is-open': drawerOpen }"
v-show="isMobile"
aria-label="Estatísticas e filtros"
>
<div id="mfl-mobile-drawer-target" class="mfl-mobile-drawer__scroll" />
</aside>
<Transition name="mfl-drawer-fade">
<div
v-if="isMobile && drawerOpen"
class="mfl-mobile-drawer__backdrop"
@click="fecharDrawer"
/>
</Transition>
<section class="mfl-page">
<header class="mfl-page__head">
<button
class="mfl-menu-btn mfl-menu-btn--mobile-only"
v-tooltip.bottom="'Estatísticas & filtros'"
@click="toggleDrawer"
>
<i class="pi pi-bars" />
<span>Menu Lançamentos</span>
</button>
<div class="mfl-page__title">
<i class="pi pi-list mfl-page__title-icon" />
<span>Lançamentos financeiros</span>
<span class="mfl-page__count">{{ totalRecords }}</span>
</div>
<div class="mfl-page__actions">
<button
class="mfl-act-btn mfl-act-btn--primary"
v-tooltip.bottom="'Novo lançamento manual'"
@click="openManualDlg"
>
<i class="pi pi-plus" />
<span>Lançamento manual</span>
</button>
<button
class="mfl-head-btn"
v-tooltip.bottom="'Recarregar'"
:disabled="loading"
@click="applyFilters(false)"
>
<i :class="loading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'" />
</button>
<button class="mfl-close" v-tooltip.bottom="'Voltar (Esc)'" @click="emit('close')">
<i class="pi pi-times" />
</button>
</div>
</header>
<!-- Subheader -->
<div class="mfl-subheader">
<i class="pi pi-info-circle mfl-subheader__icon" />
<span class="mfl-subheader__text">
Lista detalhada de cobranças e lançamentos financeiros filtre por
<strong>status</strong>, <strong>tipo</strong>, <strong>paciente</strong>
ou <strong>período de vencimento</strong>. Click em "Receber" pra
registrar pagamento.
</span>
</div>
<div class="mfl-body">
<!-- COL 1: Stats + filtros -->
<Teleport to="#mfl-mobile-drawer-target" :disabled="!isMobile">
<aside class="mfl-side">
<div class="mfl-side__scroll">
<!-- Stats -->
<div class="mfl-w mfl-w--side">
<div class="mfl-w__head">
<span class="mfl-w__title"><i class="pi pi-chart-bar" /> Estatísticas</span>
</div>
<div class="mfl-stats">
<div class="mfl-stat is-warn">
<div class="mfl-stat__val">{{ fmtBRL(summary.totalPending) }}</div>
<div class="mfl-stat__lbl">Pendente</div>
</div>
<div class="mfl-stat is-danger">
<div class="mfl-stat__val">{{ fmtBRL(summary.totalOverdue) }}</div>
<div class="mfl-stat__lbl">Vencido</div>
</div>
<div class="mfl-stat is-ok">
<div class="mfl-stat__val">{{ fmtBRL(summary.totalPaidThisMonth) }}</div>
<div class="mfl-stat__lbl">Pago (mês)</div>
</div>
<div class="mfl-stat is-neutral">
<div class="mfl-stat__val">{{ totalRecords }}</div>
<div class="mfl-stat__lbl">Total</div>
</div>
</div>
</div>
<!-- Filtro Status -->
<div class="mfl-w mfl-w--side">
<div class="mfl-w__head">
<span class="mfl-w__title"><i class="pi pi-filter" /> Status</span>
<button
v-if="filterStatus"
class="mfl-side__clear-inline"
v-tooltip.top="'Limpar filtro de status'"
@click="filterStatus = null"
>
<i class="pi pi-times" />
</button>
</div>
<div class="mfl-side__list">
<button
v-for="o in STATUS_FILTER_OPTIONS"
:key="o.key"
class="mfl-side__item"
:class="[`is-status-${o.key}`, { 'is-active': filterStatus === o.key }]"
@click="setStatusFilter(o.key)"
>
<i :class="o.icon" />
<span>{{ o.label }}</span>
</button>
</div>
</div>
<!-- Filtro Tipo -->
<div class="mfl-w mfl-w--side">
<div class="mfl-w__head">
<span class="mfl-w__title"><i class="pi pi-tag" /> Tipo</span>
<button
v-if="filterType"
class="mfl-side__clear-inline"
v-tooltip.top="'Limpar filtro de tipo'"
@click="filterType = null"
>
<i class="pi pi-times" />
</button>
</div>
<div class="mfl-side__list">
<button
v-for="o in TYPE_FILTER_OPTIONS"
:key="o.key"
class="mfl-side__item"
:class="[`is-type-${o.key}`, { 'is-active': filterType === o.key }]"
@click="setTypeFilter(o.key)"
>
<i :class="o.icon" />
<span>{{ o.label }}</span>
</button>
</div>
</div>
<!-- Filtro Paciente -->
<div class="mfl-w mfl-w--side">
<div class="mfl-w__head">
<span class="mfl-w__title"><i class="pi pi-user" /> Paciente</span>
<button
v-if="filterPatient"
class="mfl-side__clear-inline"
v-tooltip.top="'Limpar filtro de paciente'"
@click="filterPatient = null"
>
<i class="pi pi-times" />
</button>
</div>
<Select
v-model="filterPatient"
:options="patients"
optionLabel="nome_completo"
filter
:filterFields="['nome_completo']"
showClear
placeholder="Todos os pacientes"
class="w-full mfl-side__select"
:disabled="patients.length === 0"
>
<template #option="{ option }">
<div class="flex items-center gap-2">
<span
class="h-2 w-2 rounded-full shrink-0"
:style="option.identification_color ? { background: option.identification_color } : { background: 'var(--m-border-strong)' }"
/>
<span class="truncate">{{ option.nome_completo }}</span>
</div>
</template>
</Select>
</div>
<!-- Filtro Período -->
<div class="mfl-w mfl-w--side">
<div class="mfl-w__head">
<span class="mfl-w__title"><i class="pi pi-calendar" /> Período</span>
<button
v-if="filterDateRange"
class="mfl-side__clear-inline"
v-tooltip.top="'Limpar filtro de período'"
@click="filterDateRange = null"
>
<i class="pi pi-times" />
</button>
</div>
<DatePicker
v-model="filterDateRange"
selectionMode="range"
showIcon
iconDisplay="input"
dateFormat="dd/mm/yy"
:manualInput="false"
placeholder="Vencimento"
class="w-full"
/>
</div>
</div>
<!-- Footer fixo: Limpar filtros -->
<Transition name="mfl-clear">
<div v-if="hasActiveFilters" class="mfl-side__footer">
<button class="mfl-side__clear-all" @click="clearAllFilters">
<i class="pi pi-filter-slash" />
<span>Limpar filtros</span>
</button>
</div>
</Transition>
</aside>
</Teleport>
<!-- COL 2: DataTable -->
<div class="mfl-main">
<!-- Erro de carregamento -->
<div v-if="error" class="mfl-error">
<i class="pi pi-exclamation-triangle" />
<span>{{ error }}</span>
<Button
icon="pi pi-refresh"
severity="danger"
text
size="small"
class="ml-auto"
@click="applyFilters(false)"
/>
</div>
<DataTable
:value="recordsGrouped"
dataKey="id"
:loading="loading"
lazy
paginator
:rows="pageRows"
:first="pageFirst"
:totalRecords="totalRecords"
:rowsPerPageOptions="[10, 20, 50]"
paginatorTemplate="RowsPerPageDropdown FirstPageLink PrevPageLink CurrentPageReport NextPageLink LastPageLink"
currentPageReportTemplate="{first}{last} de {totalRecords}"
scrollable
scrollHeight="flex"
tableStyle="min-width: 880px"
:rowClass="(r) => [r.status === 'overdue' ? 'mfl-row-overdue' : '', r._isChild ? 'mfl-row-child' : '', r._hasChildren ? 'mfl-row-parent' : ''].filter(Boolean).join(' ')"
class="mfl-table"
rowGroupMode="subheader"
groupRowsBy="patient_id"
:expandableRowGroups="true"
v-model:expandedRowGroups="expandedGroups"
@page="onPageChange"
>
<template #groupheader="{ data }">
<div class="mfl-group-header">
<span
class="mfl-row__avatar"
:style="data.patients?.identification_color ? { background: data.patients.identification_color } : null"
>
{{ data.patients?.nome_completo?.[0]?.toUpperCase() ?? '?' }}
</span>
<span class="mfl-group-header__name">{{ data.patients?.nome_completo ?? 'Sem paciente' }}</span>
<span class="mfl-group-header__count">{{ _countForPatient(data.patient_id) }} lançamento(s)</span>
</div>
</template>
<Column header="Paciente" style="min-width: 13rem">
<template #body="{ data }">
<!-- Em records "filhos" (multa, taxa) do mesmo agenda_evento_id,
esconde avatar+nome e mostra "↳ {descrição}" indentado.
Mesmo paciente do pai logo acima reduz ruído visual. -->
<div v-if="data._isChild" class="mfl-row__child">
<i class="pi pi-arrow-right-and-arrow-left-up-down mfl-row__child-icon" />
<span class="mfl-row__child-label">{{ data.description || 'Cobrança extra' }}</span>
</div>
<div v-else class="mfl-row__patient">
<span
class="mfl-row__avatar"
:style="data.patients?.identification_color ? { background: data.patients.identification_color } : null"
>
{{ data.patients?.nome_completo?.[0]?.toUpperCase() ?? '?' }}
</span>
<span class="mfl-row__name">{{ data.patients?.nome_completo ?? '—' }}</span>
</div>
</template>
</Column>
<Column header="Sessão" style="min-width: 11rem">
<template #body="{ data }">
<span v-if="data.agenda_eventos" class="mfl-row__session">
{{ fmtDateTime(data.agenda_eventos.inicio_em) }}
</span>
<span v-else class="mfl-row__manual">
<i class="pi pi-pencil" />
Manual
</span>
</template>
</Column>
<Column header="Tipo" style="width: 7rem">
<template #body="{ data }">
<Tag
:value="data.type === 'receita' ? 'Receita' : 'Despesa'"
:severity="data.type === 'receita' ? 'success' : 'danger'"
class="text-xs"
/>
</template>
</Column>
<Column header="Valor" style="width: 9rem" sortable field="final_amount">
<template #body="{ data }">
<div class="flex flex-col">
<span class="mfl-row__amount">{{ fmtBRL(data.final_amount) }}</span>
<span v-if="data.discount_amount > 0" class="mfl-row__amount-original">
{{ fmtBRL(data.amount) }}
</span>
</div>
</template>
</Column>
<Column header="Vencimento" style="width: 9rem" sortable field="due_date">
<template #body="{ data }">
<span
class="mfl-row__due"
:class="{ 'is-overdue': data.status === 'overdue' || (data.status === 'pending' && isOverdueDate(data.due_date)) }"
>
<i v-if="data.status === 'overdue'" class="pi pi-exclamation-circle" />
{{ fmtDate(data.due_date) }}
</span>
</template>
</Column>
<Column header="Status" style="width: 8rem">
<template #body="{ data }">
<Tag
:value="STATUS_CFG[data.status]?.label ?? data.status"
:severity="STATUS_CFG[data.status]?.severity ?? 'secondary'"
:class="['text-xs', STATUS_CFG[data.status]?.extraClass ?? '']"
/>
</template>
</Column>
<Column header="Ações" style="width: 11rem; min-width: 11rem">
<template #body="{ data }">
<div v-if="data.status === 'pending' || data.status === 'overdue'" class="flex items-center gap-2">
<!-- Info do método (Asaas/etc): ícone + texto em azul info
na linha de cima; "Ver boleto" como texto-link na linha
de baixo (disabled enquanto integração Asaas não preenche
payment_link, tooltip muda dinâmico). 2026-05-14. -->
<div v-if="data.payment_method === 'asaas'" class="mfl-row__pending-asaas">
<div class="mfl-row__pending-method">
<i class="pi pi-link" />
{{ paymentLabel(data.payment_method) }}
</div>
<button
type="button"
class="mfl-row__pending-link"
:disabled="!data.payment_link"
v-tooltip.top="data.payment_link ? 'Abrir link de pagamento' : 'Aguardando integração Asaas'"
@click="openPaymentLink(data.payment_link)"
>
Ver boleto
</button>
</div>
<!-- Convênio: aguarda fechamento mensal do plano
pill visual pra distinguir de cobrança particular
direto ao paciente. 2026-05-19. -->
<div v-else-if="data.payment_method === 'convenio'" class="mfl-row__pending-convenio">
<i class="pi pi-id-card" />
{{ paymentLabel(data.payment_method) }}
</div>
<Button
label="Receber"
icon="pi pi-check"
size="small"
class="rounded-full"
@click="openPayDialog(data)"
/>
<Button
icon="pi pi-times"
size="small"
severity="danger"
outlined
class="rounded-full h-7 w-7"
v-tooltip.top="'Cancelar cobrança'"
@click="requestCancel(data)"
/>
</div>
<div v-else-if="data.status === 'paid'" class="mfl-row__paid">
<div class="mfl-row__paid-method">
<i class="pi pi-check-circle" />
{{ paymentLabel(data.payment_method) }}
</div>
<div class="mfl-row__paid-date">{{ fmtDate(data.paid_at) }}</div>
</div>
<span v-else class="mfl-row__none"></span>
</template>
</Column>
<template #empty>
<div class="mfl-empty">
<i class="pi pi-wallet mfl-empty__icon" />
<div class="mfl-empty__title">Nenhum registro encontrado</div>
<div class="mfl-empty__hint">
<template v-if="hasActiveFilters">
Ajuste os filtros pra ver mais resultados.
</template>
<template v-else>
Use "Lançamento manual" pra adicionar um registro.
</template>
</div>
<button class="mfl-act-btn mfl-act-btn--primary mfl-empty__btn" @click="openManualDlg">
<i class="pi pi-plus" />
<span>Lançamento manual</span>
</button>
</div>
</template>
<template #loading>
<div class="mfl-table__loading">
<i class="pi pi-spin pi-spinner" />
<span>Carregando lançamentos</span>
</div>
</template>
</DataTable>
</div>
</div>
<!-- Dialog: Registrar pagamento -->
<Dialog
v-model:visible="payDlgVisible"
modal
:draggable="false"
header="Registrar pagamento"
class="w-[94vw] max-w-md"
>
<div v-if="payDlgRecord" class="flex flex-col gap-5 pt-1">
<div class="mfl-pay-summary">
<div class="min-w-0">
<div class="mfl-pay-summary__name">{{ payDlgRecord.patients?.nome_completo ?? '—' }}</div>
<div class="mfl-pay-summary__sub">
Vencimento: {{ fmtDate(payDlgRecord.due_date) }}
</div>
</div>
<div class="text-right shrink-0">
<div class="mfl-pay-summary__amount">{{ fmtBRL(payDlgRecord.final_amount) }}</div>
<div v-if="payDlgRecord.discount_amount > 0" class="mfl-pay-summary__discount">
{{ fmtBRL(payDlgRecord.amount) }}
</div>
</div>
</div>
<div>
<div class="mfl-pay-label">Método de pagamento</div>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
<button
v-for="opt in PAYMENT_METHOD_OPTIONS"
:key="opt.value"
type="button"
class="mfl-pay-method"
:class="{ 'is-selected': payDlgMethod === opt.value }"
@click="payDlgMethod = opt.value"
>
<i
:class="{
'pi pi-bolt': opt.value === 'pix',
'pi pi-building': opt.value === 'deposito',
'pi pi-money-bill': opt.value === 'dinheiro',
'pi pi-credit-card': opt.value === 'cartao',
'pi pi-id-card': opt.value === 'convenio'
}"
/>
{{ opt.label }}
</button>
</div>
</div>
</div>
<template #footer>
<Button
label="Cancelar"
severity="secondary"
outlined
class="rounded-full"
:disabled="payDlgLoading"
@click="payDlgVisible = false"
/>
<Button
label="Confirmar pagamento"
icon="pi pi-check"
class="rounded-full"
:loading="payDlgLoading"
:disabled="!payDlgMethod"
@click="confirmPayment"
/>
</template>
</Dialog>
<!-- Dialog: Lançamento manual -->
<Dialog
v-model:visible="manualDlgVisible"
modal
:draggable="false"
header="Lançamento manual"
class="w-[94vw] max-w-lg"
>
<div class="flex flex-col gap-4 pt-1">
<div>
<label class="mfl-pay-label">
Paciente <span class="opacity-60 font-normal">(opcional)</span>
</label>
<Select
v-model="manualForm.patient"
:options="patients"
optionLabel="nome_completo"
filter
:filterFields="['nome_completo']"
showClear
placeholder="Selecionar paciente…"
class="w-full"
>
<template #option="{ option }">
<div class="flex items-center gap-2">
<span
class="h-2 w-2 rounded-full shrink-0"
:style="option.identification_color ? { background: option.identification_color } : { background: 'var(--m-border-strong)' }"
/>
<span>{{ option.nome_completo }}</span>
</div>
</template>
</Select>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="mfl-pay-label">Valor *</label>
<InputNumber
v-model="manualForm.amount"
mode="currency"
currency="BRL"
locale="pt-BR"
placeholder="R$ 0,00"
class="w-full"
:min="0"
/>
</div>
<div>
<label class="mfl-pay-label">Desconto</label>
<InputNumber
v-model="manualForm.discount_amount"
mode="currency"
currency="BRL"
locale="pt-BR"
placeholder="R$ 0,00"
class="w-full"
:min="0"
/>
</div>
</div>
<div class="mfl-pay-summary mfl-pay-summary--small">
<span class="mfl-pay-summary__sub">Valor final a cobrar</span>
<span class="mfl-pay-summary__amount">{{ fmtBRL(manualFinalAmount) }}</span>
</div>
<div>
<label class="mfl-pay-label">Data de vencimento *</label>
<DatePicker
v-model="manualForm.due_date"
showIcon
iconDisplay="input"
dateFormat="dd/mm/yy"
:manualInput="false"
class="w-full"
/>
</div>
<div>
<label class="mfl-pay-label">
Método <span class="opacity-60 font-normal">(opcional)</span>
</label>
<Select
v-model="manualForm.payment_method"
:options="PAYMENT_METHOD_OPTIONS"
optionLabel="label"
optionValue="value"
showClear
placeholder="Selecionar método…"
class="w-full"
/>
</div>
<div>
<label class="mfl-pay-label">Observações</label>
<Textarea
v-model="manualForm.notes"
placeholder="Anotações internas…"
rows="3"
class="w-full resize-none"
/>
</div>
</div>
<template #footer>
<Button
label="Cancelar"
severity="secondary"
outlined
class="rounded-full"
:disabled="manualDlgLoading"
@click="manualDlgVisible = false"
/>
<Button
label="Salvar lançamento"
icon="pi pi-check"
class="rounded-full"
:loading="manualDlgLoading"
@click="saveManualRecord"
/>
</template>
</Dialog>
</section>
</template>
<style scoped>
/* ─── Page chrome ─── */
.mfl-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: mfl-page-enter 240ms cubic-bezier(0.2, 0.7, 0.3, 1);
}
@keyframes mfl-page-enter {
from { opacity: 0; transform: scale(0.985); }
to { opacity: 1; transform: scale(1); }
}
.mfl-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;
}
.mfl-page__title {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
font-size: 1rem;
font-weight: 500;
}
.mfl-page__title-icon { color: var(--p-primary-color); font-size: 1.05rem; }
.mfl-page__title > span:not(.mfl-page__count) {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.mfl-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;
}
.mfl-page__actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.mfl-close, .mfl-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;
}
.mfl-close:hover, .mfl-head-btn:hover { background: var(--m-bg-soft-hover); }
.mfl-head-btn > i { font-size: 0.85rem; }
.mfl-head-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.mfl-act-btn {
display: inline-flex;
align-items: center;
gap: 6px;
height: 32px;
padding: 0 12px;
border-radius: 9px;
cursor: pointer;
font-family: inherit;
font-size: 0.78rem;
font-weight: 600;
transition: background-color 140ms ease, transform 140ms ease;
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
color: var(--m-text);
}
.mfl-act-btn--primary {
background: var(--m-accent);
border-color: var(--m-accent);
color: white;
}
.mfl-act-btn--primary:hover {
background: color-mix(in srgb, var(--m-accent) 88%, white);
transform: translateY(-1px);
}
.mfl-act-btn > i { font-size: 0.78rem; }
/* Subheader */
.mfl-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;
}
.mfl-subheader__icon { color: var(--p-primary-color); font-size: 0.92rem; flex-shrink: 0; margin-top: 1px; }
.mfl-subheader__text { flex: 1; min-width: 0; }
.mfl-subheader__text strong { color: var(--m-text); font-weight: 600; }
/* Body */
.mfl-body {
flex: 1;
display: flex;
min-height: 0;
gap: 0;
padding: 0;
}
/* ─── Sidebar ─── */
.mfl-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;
}
.mfl-side__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.mfl-side__scroll::-webkit-scrollbar { width: 5px; }
.mfl-side__scroll::-webkit-scrollbar-thumb { background: var(--m-border-strong); border-radius: 3px; }
.mfl-side__footer {
flex-shrink: 0;
padding: 12px;
background: var(--m-bg-soft);
border-top: 1px solid var(--m-border);
}
.mfl-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;
}
.mfl-side__clear-all:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.mfl-side__clear-all > i {
font-size: 0.78rem;
color: var(--m-text-muted);
}
.mfl-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;
transition: background-color 140ms ease, border-color 140ms ease;
}
.mfl-side__clear-inline:hover {
background: rgba(220, 38, 38, 0.10);
border-color: rgba(220, 38, 38, 0.55);
}
.mfl-side__clear-inline > i { font-size: 0.6rem; }
.mfl-clear-enter-active,
.mfl-clear-leave-active {
transition: opacity 220ms ease, transform 220ms ease, max-height 240ms ease;
overflow: hidden;
}
.mfl-clear-enter-from, .mfl-clear-leave-to {
opacity: 0; transform: translateY(6px); max-height: 0;
}
.mfl-clear-enter-to, .mfl-clear-leave-from {
opacity: 1; transform: translateY(0); max-height: 80px;
}
.mfl-w {
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-radius: 12px;
padding: 12px;
}
.mfl-w--side {
margin: 12px 12px 0;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
.mfl-w--side:last-of-type { margin-bottom: 12px; }
.mfl-w__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.mfl-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;
}
.mfl-w__title > i { color: var(--m-text-muted); font-size: 0.7rem; }
.mfl-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 6px;
}
.mfl-stat {
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
border-radius: 10px;
padding: 8px 10px;
}
.mfl-stat__val {
font-size: 0.95rem;
font-weight: 700;
line-height: 1.1;
}
.mfl-stat__lbl {
font-size: 0.62rem;
color: var(--m-text-muted);
margin-top: 4px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.mfl-stat.is-warn .mfl-stat__val { color: rgb(217, 119, 6); }
.mfl-stat.is-danger .mfl-stat__val { color: rgb(220, 38, 38); }
.mfl-stat.is-ok .mfl-stat__val { color: rgb(22, 163, 74); }
/* Filter buttons */
.mfl-side__list {
display: flex;
flex-direction: column;
gap: 4px;
}
.mfl-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;
}
.mfl-side__item > i { font-size: 0.78rem; width: 14px; text-align: center; }
/* Status colors: pending amber / overdue red / paid green / cancelled neutral */
.mfl-side__item.is-status-pending {
background: rgba(217, 119, 6, 0.05);
border-color: rgba(217, 119, 6, 0.18);
}
.mfl-side__item.is-status-pending > i { color: rgb(217, 119, 6); }
.mfl-side__item.is-status-pending:hover {
background: rgba(217, 119, 6, 0.10);
border-color: rgba(217, 119, 6, 0.30);
}
.mfl-side__item.is-active.is-status-pending {
background: rgba(217, 119, 6, 0.16);
border-color: rgba(217, 119, 6, 0.55);
box-shadow: 0 0 0 1px rgba(217, 119, 6, 0.35);
}
.mfl-side__item.is-status-overdue {
background: rgba(220, 38, 38, 0.05);
border-color: rgba(220, 38, 38, 0.18);
}
.mfl-side__item.is-status-overdue > i { color: rgb(220, 38, 38); }
.mfl-side__item.is-status-overdue:hover {
background: rgba(220, 38, 38, 0.10);
border-color: rgba(220, 38, 38, 0.30);
}
.mfl-side__item.is-active.is-status-overdue {
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);
}
.mfl-side__item.is-status-paid {
background: rgba(22, 163, 74, 0.05);
border-color: rgba(22, 163, 74, 0.18);
}
.mfl-side__item.is-status-paid > i { color: rgb(22, 163, 74); }
.mfl-side__item.is-status-paid:hover {
background: rgba(22, 163, 74, 0.10);
border-color: rgba(22, 163, 74, 0.30);
}
.mfl-side__item.is-active.is-status-paid {
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);
}
.mfl-side__item.is-status-cancelled {
background: var(--m-bg-soft);
border-color: var(--m-border);
}
.mfl-side__item.is-status-cancelled > i { color: var(--m-text-muted); }
.mfl-side__item.is-status-cancelled:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.mfl-side__item.is-active.is-status-cancelled {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
box-shadow: 0 0 0 1px var(--m-border-strong);
}
/* Tipo: receita verde / despesa vermelho */
.mfl-side__item.is-type-receita {
background: rgba(22, 163, 74, 0.05);
border-color: rgba(22, 163, 74, 0.18);
}
.mfl-side__item.is-type-receita > i { color: rgb(22, 163, 74); }
.mfl-side__item.is-type-receita:hover {
background: rgba(22, 163, 74, 0.10);
border-color: rgba(22, 163, 74, 0.30);
}
.mfl-side__item.is-active.is-type-receita {
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);
}
.mfl-side__item.is-type-despesa {
background: rgba(220, 38, 38, 0.05);
border-color: rgba(220, 38, 38, 0.18);
}
.mfl-side__item.is-type-despesa > i { color: rgb(220, 38, 38); }
.mfl-side__item.is-type-despesa:hover {
background: rgba(220, 38, 38, 0.10);
border-color: rgba(220, 38, 38, 0.30);
}
.mfl-side__item.is-active.is-type-despesa {
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);
}
/* Select de Paciente na sidebar */
.mfl-side__select :deep(.p-select) {
width: 100%;
background: var(--m-bg-soft);
border-radius: 9px;
height: 36px;
}
/* ─── Main + DataTable ─── */
.mfl-main {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
padding: 12px;
gap: 10px;
}
.mfl-error {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
background: rgba(220, 38, 38, 0.10);
border: 1px solid rgba(220, 38, 38, 0.30);
border-radius: 10px;
color: rgb(220, 38, 38);
font-size: 0.85rem;
flex-shrink: 0;
}
.mfl-table {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.mfl-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;
}
.mfl-table :deep(.p-datatable-table-container) {
flex: 1;
min-height: 0;
background: transparent;
}
.mfl-table :deep(.p-datatable-thead),
.mfl-table :deep(.p-datatable-thead > tr) { background: transparent !important; }
.mfl-table :deep(.p-datatable-thead > tr > th) {
background: var(--p-content-background) !important;
color: var(--m-text);
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 10px 14px;
border-bottom: 1px solid var(--m-border);
}
.mfl-table :deep(.p-datatable-tbody > tr) {
background: transparent;
color: var(--m-text);
transition: background-color 140ms ease;
}
.mfl-table :deep(.p-datatable-tbody > tr > td) {
padding: 10px 14px;
border-bottom: 1px solid var(--m-border);
background: transparent;
vertical-align: middle;
font-size: 0.85rem;
}
.mfl-table :deep(.p-datatable-tbody > tr:hover) { background: var(--m-bg-soft-hover); }
.mfl-table :deep(.p-datatable-tbody > tr.mfl-row-overdue) {
background: rgba(220, 38, 38, 0.04);
}
.mfl-table :deep(.p-datatable-tbody > tr.mfl-row-overdue:hover) {
background: rgba(220, 38, 38, 0.08);
}
/* Aninhamento visual (2026-05-14): pai ganha border-bottom mais discreto,
filho herda fundo sutil + sem border-top → parece "continuação" do pai. */
.mfl-table :deep(.p-datatable-tbody > tr.mfl-row-parent > td) {
border-bottom-style: dashed !important;
border-bottom-color: var(--m-border, rgba(255, 255, 255, 0.08)) !important;
}
.mfl-table :deep(.p-datatable-tbody > tr.mfl-row-child) {
background: color-mix(in srgb, var(--p-primary-color) 4%, transparent);
}
.mfl-table :deep(.p-datatable-tbody > tr.mfl-row-child > td) {
border-top: none !important;
}
.mfl-table :deep(.p-datatable-tbody > tr.mfl-row-child:hover) {
background: color-mix(in srgb, var(--p-primary-color) 8%, transparent);
}
.mfl-table :deep(.p-datatable-loading-overlay) {
background: color-mix(in srgb, var(--m-bg-medium) 70%, transparent);
backdrop-filter: blur(2px);
}
.mfl-table__loading {
display: inline-flex; align-items: center; gap: 8px;
color: var(--m-text); font-size: 0.85rem;
}
/* Paginator */
.mfl-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;
}
.mfl-table :deep(.p-paginator-current) {
color: var(--m-text-muted);
font-size: 0.78rem;
background: transparent;
border: none;
padding: 0 6px;
}
.mfl-table :deep(.p-paginator-first),
.mfl-table :deep(.p-paginator-prev),
.mfl-table :deep(.p-paginator-next),
.mfl-table :deep(.p-paginator-last),
.mfl-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;
}
.mfl-table :deep(.p-paginator-page.p-paginator-page-selected) {
background: var(--m-accent-soft);
border-color: var(--m-accent-strong);
color: var(--m-accent);
}
/* Row content */
/* Group header — paciente como subheader da DataTable, com avatar
pequeno + nome + contagem. Click no chevron (auto via PrimeVue
expandableRowGroups) expande/contrai o bloco do paciente. */
.mfl-group-header {
display: inline-flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.mfl-group-header__name {
color: var(--text-color);
font-size: 0.92rem;
}
.mfl-group-header__count {
font-size: 0.72rem;
color: var(--text-color-secondary);
font-weight: 500;
padding: 2px 8px;
border-radius: 999px;
background: color-mix(in srgb, var(--surface-border), transparent 40%);
}
.mfl-row__patient {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
/* Bloco "filho" (multa/taxa do mesmo agenda_evento): indent + ícone setinha. */
.mfl-row__child {
display: flex;
align-items: center;
gap: 8px;
padding-left: 22px;
min-width: 0;
}
.mfl-row__child-icon {
color: var(--m-text-muted);
font-size: 0.65rem;
transform: scaleY(-1);
flex-shrink: 0;
}
.mfl-row__child-label {
font-size: 0.82rem;
font-style: italic;
color: var(--m-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mfl-row__avatar {
width: 28px; height: 28px;
border-radius: 50%;
background: var(--m-accent-strong);
color: white;
font-size: 0.7rem;
font-weight: 700;
display: grid; place-items: center;
flex-shrink: 0;
}
.mfl-row__name {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mfl-row__session { color: var(--m-text); }
.mfl-row__manual {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.74rem;
color: var(--m-text-muted);
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
padding: 2px 8px;
border-radius: 999px;
}
.mfl-row__manual > i { font-size: 0.62rem; }
.mfl-row__amount {
font-weight: 600;
color: var(--m-text);
}
.mfl-row__amount-original {
font-size: 0.7rem;
color: var(--m-text-muted);
text-decoration: line-through;
}
.mfl-row__due {
color: var(--m-text);
display: inline-flex;
align-items: center;
gap: 4px;
}
.mfl-row__due.is-overdue {
color: rgb(220, 38, 38);
font-weight: 600;
}
.mfl-row__due > i { font-size: 0.7rem; }
.mfl-row__paid {
display: flex;
flex-direction: column;
gap: 2px;
}
.mfl-row__paid-method {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.74rem;
color: rgb(22, 163, 74);
font-weight: 500;
}
.mfl-row__paid-date {
font-size: 0.7rem;
color: var(--m-text-muted);
}
.mfl-row__pending-asaas {
display: flex;
flex-direction: column;
gap: 2px;
}
/* Pill "Convênio" — visual distinto do Asaas (violeta) pra deixar claro
que essa cobrança aguarda fechamento mensal do plano, não webhook. */
.mfl-row__pending-convenio {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.74rem;
color: rgb(124, 58, 237); /* violet-600 */
font-weight: 500;
}
.mfl-row__pending-method {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.74rem;
color: rgb(37, 99, 235); /* azul info — cobrança aguardando, não paga */
font-weight: 500;
}
/* "Ver boleto" como texto-link (sem botão visual). Habilitado quando
payment_link existe — vira underline + cursor pointer. Disabled hoje
enquanto integração Asaas não preenche — tooltip explica. 2026-05-14. */
.mfl-row__pending-link {
background: none;
border: none;
padding: 0;
font-size: 0.7rem;
color: rgb(37, 99, 235);
cursor: pointer;
text-align: left;
font-family: inherit;
}
.mfl-row__pending-link:hover:not(:disabled) {
text-decoration: underline;
}
.mfl-row__pending-link:disabled {
color: var(--m-text-muted);
cursor: not-allowed;
opacity: 0.7;
}
.mfl-row__none {
color: var(--m-text-faint);
font-style: italic;
font-size: 0.78rem;
}
/* Empty state */
.mfl-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;
}
.mfl-empty__icon { font-size: 2rem; color: var(--m-text-faint); }
.mfl-empty__title { font-size: 0.92rem; font-weight: 600; color: var(--m-text); }
.mfl-empty__hint { font-size: 0.78rem; }
.mfl-empty__btn { margin-top: 8px; }
/* ─── Dialog: Pay summary + method buttons ─── */
.mfl-pay-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border: 1px solid var(--m-border);
background: var(--m-bg-soft);
border-radius: 10px;
}
.mfl-pay-summary--small {
padding: 8px 12px;
}
.mfl-pay-summary__name {
font-weight: 600;
color: var(--m-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mfl-pay-summary__sub {
font-size: 0.74rem;
color: var(--m-text-muted);
margin-top: 2px;
}
.mfl-pay-summary__amount {
font-size: 1.1rem;
font-weight: 700;
color: var(--m-text);
}
.mfl-pay-summary__discount {
font-size: 0.7rem;
color: var(--m-text-muted);
text-decoration: line-through;
}
.mfl-pay-label {
font-size: 0.85rem;
font-weight: 600;
color: var(--m-text);
display: block;
margin-bottom: 6px;
}
.mfl-pay-method {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 10px 12px;
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
border-radius: 10px;
color: var(--m-text-muted);
cursor: pointer;
font-family: inherit;
font-size: 0.85rem;
font-weight: 500;
transition: background-color 140ms ease, border-color 140ms ease, color 140ms ease;
}
.mfl-pay-method:hover {
border-color: color-mix(in srgb, var(--p-primary-color) 40%, var(--m-border));
}
.mfl-pay-method.is-selected {
background: color-mix(in srgb, var(--p-primary-color) 12%, transparent);
border-color: var(--p-primary-color);
color: var(--p-primary-color);
}
.mfl-pay-method > i { font-size: 1rem; }
/* Estornado tag (roxo — sem severity nativo) */
:deep(.tag-refunded) {
background: rgb(168, 85, 247) !important;
color: white !important;
}
/* ─── Botão "Menu" mobile (abre drawer com sidebar) ─── */
.mfl-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;
}
.mfl-menu-btn:hover {
background: color-mix(in srgb, var(--m-accent) 88%, white);
transform: translateY(-1px);
}
.mfl-menu-btn > i { font-size: 0.85rem; }
/* ─── Drawer mobile (Teleport target) ─────────────────────
Pattern espelhado do AppMenu/layout-sidebar do Rail:
- .xx-mobile-drawer = flex column, altura 100vh
- __scroll = container do Teleport (flex: 1, sem scroll proprio,
overflow hidden — o scroll fica num filho interno)
- .xx-side teleportada = flex column ocupando todo o espaco,
com __scroll interno (flex: 1, overflow-y auto) e __footer
(flex-shrink: 0) que naturalmente fica colado no bottom. */
.mfl-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;
}
.mfl-mobile-drawer.is-open { transform: translateX(0); }
.mfl-mobile-drawer__scroll {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Sidebar teleportada — vira a "casca" flex column do drawer */
.mfl-mobile-drawer__scroll .mfl-side {
flex: 1;
min-height: 0;
width: 100%;
overflow: hidden;
background: transparent;
border-right: none;
display: flex;
flex-direction: column;
}
/* O scroll interno vive aqui — só os cards rolam, o footer NÃO.
Padding compensa a remoção do padding do __scroll do host. */
.mfl-mobile-drawer__scroll .mfl-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;
}
.mfl-mobile-drawer__scroll .mfl-side__scroll::-webkit-scrollbar { width: 5px; }
.mfl-mobile-drawer__scroll .mfl-side__scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
.mfl-mobile-drawer__scroll .mfl-w--side {
margin: 0;
flex-shrink: 0;
}
.mfl-mobile-drawer__scroll .mfl-w--side:last-of-type { margin-bottom: 0; }
/* Footer fixo no bottom do drawer — flex-shrink:0 + estar fora do
__scroll garante que sempre fica visivel, sem precisar de sticky. */
.mfl-mobile-drawer__scroll .mfl-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%);
}
.mfl-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;
}
.mfl-drawer-fade-enter-active,
.mfl-drawer-fade-leave-active { transition: opacity 200ms ease; }
.mfl-drawer-fade-enter-from,
.mfl-drawer-fade-leave-to { opacity: 0; }
/* ─── Mobile (<1024px) ─── */
@media (max-width: 1023px) {
/* Sidebar saiu pro drawer via Teleport — body fica só com .mfl-main. */
.mfl-body { flex-direction: column; padding: 0; }
.mfl-main { width: 100%; padding: 8px; }
.mfl-page__title > span:first-of-type { display: none; }
.mfl-page__title-icon { display: none; }
.mfl-menu-btn--mobile-only { display: inline-flex; }
.mfl-act-btn--primary span { display: none; }
.mfl-act-btn--primary { width: 32px; padding: 0; justify-content: center; }
}
</style>