Files
agenciapsilmno/src/layout/melissa/MelissaFinanceiroLancamentos.vue
T
Leonardo 387043b3b2 MelissaFinanceiro + MelissaFinanceiroLancamentos nativas
Promove '/melissa/financeiro' e '/melissa/financeiro-lancamentos' do
embed pra paginas nativas, eliminando o triplo header.

MelissaFinanceiro (dashboard, ~700L):
- Layout 1-col empilhado (sem sidebar — so cards de resumo)
- Header com ícone wallet + titulo + badge mes corrente +
  botao "Ver lancamentos" + Recarregar + Voltar
- Subheader explicativo
- 4 cards empilhados:
  1. Quick stats grid (Recebido verde / Pendente amber / Vencido red /
     Despesas neutral)
  2. Card Grafico Receita x Despesa (Chart.js bar, 6 meses)
  3. Card Projecao de Caixa (cobrancas em aberto, proximos 6 meses
     com receita/despesa/saldo + count badge)
  4. Card Ultimos lancamentos (DataTable 5 mais recentes)
- Click "Ver lancamentos" / "Ver todos" navega pra
  /melissa/financeiro-lancamentos

MelissaFinanceiroLancamentos (lista, ~1100L):
- Blueprint tabular Melissa completo
- Header com botao "Lancamento manual" + Recarregar + Voltar
- Subheader
- Sidebar com __scroll + __footer fixo:
  - Stats (Pendente amber / Vencido red / Pago verde / Total)
  - Filtro Status (button list: Pendentes amber / Vencidos red /
    Pagos green / Cancelados neutral) + X inline
  - Filtro Tipo (Receita green / Despesa red) + X inline
  - Filtro Paciente (Select com filter + identification_color dot)
    + X inline
  - Filtro Periodo (DatePicker range vencimento) + X inline
  - Footer fixo "Limpar filtros" (Transition fade+collapse)
- Main: DataTable lazy + paginator com 7 colunas (Paciente +
  avatar / Sessao / Tipo / Valor + desconto / Vencimento / Status /
  Acoes). Row overdue com bg vermelho tinted.
- Acoes por status:
  - pending/overdue: botoes "Receber" (abre dialog pagamento) +
    "Cancelar" (Confirm)
  - paid: badge "metodo + data"
  - cancelled: travessao
- Mobile: sidebar vira topo (max-height 50vh)

Dialogs preservados:
- Registrar pagamento (5 metodos com icones: pix/deposito/dinheiro/
  cartao/convenio)
- Lancamento manual (Paciente opcional + Valor + Desconto + Valor
  final read-only + Data vencimento + Metodo opcional + Obs)

Logica preservada do composable useFinancialRecords + RPCs
(get_financial_summary, list_financial_records, view
v_cashflow_projection, mark_as_paid, cancel_record,
create_manual_record).

FinanceiroDashboardPage e FinanceiroPage continuam intactas no
layout Rail (/admin/financeiro, /therapist/financeiro).

Wire-up: imports + render blocks + 'financeiro' e
'financeiro-lancamentos' em NON_CONFIG_SLUGS; removidos de
MELISSA_EMBED_KEYS. Entries removidos do EMBED_MAP em MelissaEmbed.

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

1525 lines
54 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 } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { supabase } from '@/lib/supabase/client';
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();
// ── 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 supabase
.from('patients')
.select('id, nome_completo, identification_color')
.eq('tenant_id', tenantId)
.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: 'Convênio', value: 'convenio' }
];
function paymentLabel(method) {
return PAYMENT_METHOD_OPTIONS.find((o) => o.value === method)?.label ?? method ?? '—';
}
// ── 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;
}
// ── 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 () => {
await Promise.all([loadPatients(), applyFilters()]);
});
</script>
<template>
<ConfirmDialog />
<section class="mfl-page">
<header class="mfl-page__head">
<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 -->
<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>
<!-- 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="records"
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' : '')"
class="mfl-table"
@page="onPageChange"
>
<Column header="Paciente" style="min-width: 13rem">
<template #body="{ data }">
<div 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-1">
<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);
}
.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 */
.mfl-row__patient {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.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__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;
}
/* ─── Mobile (<1024px) ─── */
@media (max-width: 1023px) {
.mfl-body { flex-direction: column; padding: 0; }
.mfl-side {
width: 100%;
max-height: 50vh;
border-right: none;
border-bottom: 1px solid var(--m-border);
}
.mfl-main { padding: 8px; }
.mfl-page__title > span:first-of-type { display: none; }
.mfl-act-btn--primary span { display: none; }
.mfl-act-btn--primary { width: 32px; padding: 0; justify-content: center; }
}
</style>