a7f6bcbe66
- 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>
1914 lines
69 KiB
Vue
1914 lines
69 KiB
Vue
<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 cá 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>
|