Files
agenciapsilmno/src/layout/melissa/MelissaFinanceiro.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

858 lines
29 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>
/*
* MelissaFinanceiro — Página nativa Melissa pro dashboard financeiro
* (substitui o embed via MelissaEmbed que duplicava headers).
*
* Layout 1-col empilhado (sem sidebar — não há filtros aqui, só
* cards de resumo). Espelha o pattern do MelissaLinkExterno.
*
* Cards (de cima pra baixo):
* 1. Quick stats (Recebido / Pendente / Vencido / Despesas) — grid 4-col
* 2. Gráfico Receita × Despesa (Chart.js bar, últimos 6 meses)
* 3. Projeção de Caixa (cobranças em aberto, próximos 6 meses)
* 4. Últimos lançamentos (DataTable 5 mais recentes)
*
* Lógica idêntica à FinanceiroDashboardPage (RPCs get_financial_summary +
* list_financial_records + view v_cashflow_projection). Só o chrome muda.
*/
import { ref, computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { supabase } from '@/lib/supabase/client';
// Chart/DataTable/Column/Skeleton/Tag: auto via PrimeVueResolver
const emit = defineEmits(['close']);
const router = useRouter();
// ── Helpers ────────────────────────────────────────────
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);
}
async function getUid() {
const { data } = await supabase.auth.getUser();
return data?.user?.id ?? null;
}
function getLast6Months() {
const months = [];
const now = new Date();
for (let i = 5; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
months.push({
year: d.getFullYear(),
month: d.getMonth() + 1,
label: d.toLocaleDateString('pt-BR', { month: 'short', year: '2-digit' })
});
}
return months;
}
// Label do mês corrente pro badge do header.
const currentMonthLabel = computed(() => {
const now = new Date();
return now.toLocaleDateString('pt-BR', { month: 'short', year: '2-digit' });
});
// ── Estado: cards de resumo ────────────────────────────
const summaryLoading = ref(true);
const totalRecebido = ref(0);
const totalPendente = ref(0);
const totalVencido = ref(0);
const totalDespesas = ref(0);
async function loadSummary(uid) {
summaryLoading.value = true;
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
try {
const { data: rpc } = await supabase.rpc('get_financial_summary', {
p_owner_id: uid, p_year: year, p_month: month
});
const s = Array.isArray(rpc) ? rpc[0] : rpc;
totalRecebido.value = Number(s?.total_receitas ?? 0);
totalDespesas.value = Number(s?.total_despesas ?? 0);
const { data: pendRows } = await supabase
.from('financial_records')
.select('status, final_amount')
.eq('owner_id', uid)
.is('deleted_at', null)
.in('status', ['pending', 'overdue']);
let pen = 0, ove = 0;
for (const r of pendRows ?? []) {
if (r.status === 'pending') pen += Number(r.final_amount ?? 0);
else ove += Number(r.final_amount ?? 0);
}
totalPendente.value = pen;
totalVencido.value = ove;
} finally {
summaryLoading.value = false;
}
}
// ── Estado: gráfico 6 meses ────────────────────────────
const chartLoading = ref(true);
const chartData = ref(null);
const chartOptions = ref({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'top' },
tooltip: {
callbacks: { label: (ctx) => ` ${_brl.format(ctx.parsed.y)}` }
}
},
scales: {
y: {
ticks: { callback: (v) => _brl.format(v) },
beginAtZero: true
}
}
});
async function loadChart(uid) {
chartLoading.value = true;
const months = getLast6Months();
try {
const results = await Promise.all(
months.map((m) => supabase.rpc('get_financial_summary', {
p_owner_id: uid, p_year: m.year, p_month: m.month
}))
);
const receitas = results.map((r) => Number((Array.isArray(r.data) ? r.data[0] : r.data)?.total_receitas ?? 0));
const despesas = results.map((r) => Number((Array.isArray(r.data) ? r.data[0] : r.data)?.total_despesas ?? 0));
chartData.value = {
labels: months.map((m) => m.label),
datasets: [
{ label: 'Receita', data: receitas, backgroundColor: 'rgb(22, 163, 74)', borderRadius: 4 },
{ label: 'Despesa', data: despesas, backgroundColor: 'rgb(220, 38, 38)', borderRadius: 4 }
]
};
} finally {
chartLoading.value = false;
}
}
// ── Estado: fluxo de caixa ─────────────────────────────
const cashflowLoading = ref(true);
const cashflowRows = ref([]);
const cashflowError = ref(false);
async function loadCashflow() {
cashflowLoading.value = true;
cashflowError.value = false;
try {
const { data, error } = await supabase
.from('v_cashflow_projection')
.select('mes_label, receitas_projetadas, despesas_projetadas, saldo_projetado, count_registros')
.order('mes', { ascending: true });
if (error) throw error;
cashflowRows.value = data ?? [];
} catch {
cashflowError.value = true;
} finally {
cashflowLoading.value = false;
}
}
// ── Estado: últimos lançamentos ────────────────────────
const recentLoading = ref(true);
const recentRecords = ref([]);
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: 'contrast' }
};
async function loadRecent(uid) {
recentLoading.value = true;
try {
const { data } = await supabase.rpc('list_financial_records', {
p_owner_id: uid, p_limit: 5, p_offset: 0
});
recentRecords.value = data ?? [];
} finally {
recentLoading.value = false;
}
}
// ── Loading agregado pro botão Recarregar ──────────────
const loading = computed(() =>
summaryLoading.value || chartLoading.value || cashflowLoading.value || recentLoading.value
);
async function reload() {
const uid = await getUid();
if (!uid) return;
await Promise.all([loadSummary(uid), loadChart(uid), loadCashflow(), loadRecent(uid)]);
}
// ── Navegação: "Ver lançamentos" → MelissaFinanceiroLancamentos ──
// Quando essa página existir, abre /melissa/financeiro-lancamentos.
// Por enquanto o slug ja roteia pelo MelissaEmbed.
function goToLancamentos() {
router.push('/melissa/financeiro-lancamentos');
}
// ── Lifecycle ──────────────────────────────────────────
onMounted(async () => {
await reload();
});
</script>
<template>
<section class="mf-page">
<header class="mf-page__head">
<div class="mf-page__title">
<i class="pi pi-wallet mf-page__title-icon" />
<span>Financeiro</span>
<span class="mf-page__count">{{ currentMonthLabel }}</span>
</div>
<div class="mf-page__actions">
<button
class="mf-act-btn mf-act-btn--primary"
v-tooltip.bottom="'Ver lista detalhada de lançamentos'"
@click="goToLancamentos"
>
<i class="pi pi-list" />
<span>Ver lançamentos</span>
</button>
<button
class="mf-head-btn"
v-tooltip.bottom="'Recarregar'"
:disabled="loading"
@click="reload"
>
<i :class="loading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'" />
</button>
<button class="mf-close" v-tooltip.bottom="'Voltar (Esc)'" @click="emit('close')">
<i class="pi pi-times" />
</button>
</div>
</header>
<!-- Subheader explicativo -->
<div class="mf-subheader">
<i class="pi pi-info-circle mf-subheader__icon" />
<span class="mf-subheader__text">
Resumo financeiro do <strong>mês corrente</strong> recebido,
pendências, vencidos e despesas. Veja o gráfico de 6 meses,
a projeção de caixa e os últimos lançamentos.
</span>
</div>
<!-- Body com scroll -->
<div class="mf-body">
<!-- 1. Quick stats -->
<div class="mf-stats">
<template v-if="summaryLoading">
<div v-for="n in 4" :key="`stsk-${n}`" class="mf-stat mf-stat--skeleton">
<Skeleton width="60%" height="22px" />
<Skeleton width="40%" height="11px" class="mt-1" />
</div>
</template>
<template v-else>
<div class="mf-stat is-recebido">
<div class="mf-stat__val">{{ fmtBRL(totalRecebido) }}</div>
<div class="mf-stat__lbl">
<i class="pi pi-check-circle" />
Recebido (mês)
</div>
</div>
<div class="mf-stat is-pendente">
<div class="mf-stat__val">{{ fmtBRL(totalPendente) }}</div>
<div class="mf-stat__lbl">
<span class="mf-stat__pulse" />
Pendente
</div>
</div>
<div class="mf-stat" :class="totalVencido > 0 ? 'is-vencido' : 'is-neutral'">
<div class="mf-stat__val">{{ fmtBRL(totalVencido) }}</div>
<div class="mf-stat__lbl">
<i class="pi pi-exclamation-circle" />
Vencido
</div>
</div>
<div class="mf-stat is-neutral">
<div class="mf-stat__val">{{ fmtBRL(totalDespesas) }}</div>
<div class="mf-stat__lbl">
<i class="pi pi-arrow-down-left" />
Despesas (mês)
</div>
</div>
</template>
</div>
<!-- 2. Gráfico Receita × Despesa -->
<div class="mf-card">
<div class="mf-card__head">
<div class="mf-card__icon mf-card__icon--primary">
<i class="pi pi-chart-bar" />
</div>
<div class="mf-card__title">
<div class="mf-card__title-text">Receita × Despesa</div>
<div class="mf-card__sub">Comparativo dos últimos 6 meses</div>
</div>
</div>
<div class="mf-card__body">
<div v-if="chartLoading" class="mf-chart-wrap">
<Skeleton height="100%" />
</div>
<div v-else-if="chartData" class="mf-chart-wrap">
<Chart type="bar" :data="chartData" :options="chartOptions" style="height: 100%" />
</div>
<div v-else class="mf-empty-inline">
<i class="pi pi-info-circle" /> Sem dados para o gráfico.
</div>
</div>
</div>
<!-- 3. Projeção de Caixa -->
<div class="mf-card">
<div class="mf-card__head">
<div class="mf-card__icon mf-card__icon--primary">
<i class="pi pi-calendar" />
</div>
<div class="mf-card__title">
<div class="mf-card__title-text">Projeção de Caixa</div>
<div class="mf-card__sub">Cobranças em aberto próximos 6 meses</div>
</div>
</div>
<div class="mf-card__body">
<div v-if="cashflowLoading" class="flex flex-col gap-2">
<Skeleton v-for="n in 6" :key="`cfsk-${n}`" height="2.4rem" border-radius="6px" />
</div>
<div v-else-if="cashflowError" class="mf-empty-inline">
<i class="pi pi-info-circle" /> Projeção indisponível.
</div>
<div v-else-if="!cashflowRows.length" class="mf-empty-inline">
<i class="pi pi-check-circle" /> Sem cobranças futuras em aberto.
</div>
<div v-else class="mf-cashflow">
<div
v-for="row in cashflowRows"
:key="row.mes_label"
class="mf-cashflow__row"
>
<span class="mf-cashflow__mes">{{ row.mes_label }}</span>
<div class="mf-cashflow__line">
<span class="mf-cashflow__pos">
<i class="pi pi-arrow-up-right" />
{{ fmtBRL(row.receitas_projetadas) }}
</span>
<span class="mf-cashflow__sep">·</span>
<span class="mf-cashflow__neg">
<i class="pi pi-arrow-down-left" />
{{ fmtBRL(row.despesas_projetadas) }}
</span>
<span class="mf-cashflow__sep">·</span>
<span
class="mf-cashflow__saldo"
:class="Number(row.saldo_projetado) >= 0 ? 'is-positive' : 'is-negative'"
>
saldo {{ fmtBRL(row.saldo_projetado) }}
</span>
</div>
<Tag
:value="row.count_registros + ' cobranças'"
severity="secondary"
class="mf-cashflow__count"
/>
</div>
</div>
</div>
</div>
<!-- 4. Últimos lançamentos -->
<div class="mf-card">
<div class="mf-card__head">
<div class="mf-card__icon mf-card__icon--primary">
<i class="pi pi-list" />
</div>
<div class="mf-card__title">
<div class="mf-card__title-text">Últimos lançamentos</div>
<div class="mf-card__sub">Cobranças e receitas recentes</div>
</div>
<button class="mf-card__action-link" @click="goToLancamentos">
Ver todos <i class="pi pi-arrow-right" />
</button>
</div>
<div v-if="recentLoading" class="mf-card__body">
<div class="flex flex-col gap-2">
<Skeleton v-for="n in 5" :key="`rsk-${n}`" height="2.5rem" border-radius="6px" />
</div>
</div>
<div v-else-if="!recentRecords.length" class="mf-empty-inline">
<i class="pi pi-wallet" /> Nenhum lançamento encontrado.
</div>
<DataTable
v-else
:value="recentRecords"
size="small"
:show-gridlines="false"
class="mf-recent-table"
>
<Column field="due_date" header="Data">
<template #body="{ data }">
{{ fmtDate(data.paid_at ?? data.due_date) }}
</template>
</Column>
<Column field="description" header="Descrição">
<template #body="{ data }">
{{ data.description || data.notes || '—' }}
</template>
</Column>
<Column header="Tipo">
<template #body="{ data }">
<Tag
:value="data.type === 'receita' ? 'Receita' : 'Despesa'"
:severity="data.type === 'receita' ? 'success' : 'danger'"
class="text-xs"
/>
</template>
</Column>
<Column header="Valor">
<template #body="{ data }">
<span :class="data.type === 'receita' ? 'mf-amount mf-amount--pos' : 'mf-amount mf-amount--neg'">
{{ fmtBRL(data.final_amount ?? data.amount) }}
</span>
</template>
</Column>
<Column header="Status">
<template #body="{ data }">
<Tag
:value="STATUS_CFG[data.status]?.label ?? data.status"
:severity="STATUS_CFG[data.status]?.severity ?? 'secondary'"
class="text-xs"
/>
</template>
</Column>
</DataTable>
</div>
</div>
</section>
</template>
<style scoped>
/* ─── Page chrome (espelha demais Melissa Pages) ─── */
.mf-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: mf-page-enter 240ms cubic-bezier(0.2, 0.7, 0.3, 1);
}
@keyframes mf-page-enter {
from { opacity: 0; transform: scale(0.985); }
to { opacity: 1; transform: scale(1); }
}
.mf-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;
}
.mf-page__title {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
font-size: 1rem;
font-weight: 500;
}
.mf-page__title-icon {
color: var(--p-primary-color);
font-size: 1.05rem;
}
.mf-page__title > span:not(.mf-page__count) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mf-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;
text-transform: capitalize;
}
.mf-page__actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.mf-close, .mf-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;
}
.mf-close:hover, .mf-head-btn:hover { background: var(--m-bg-soft-hover); }
.mf-head-btn > i { font-size: 0.85rem; }
.mf-head-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.mf-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);
}
.mf-act-btn--primary {
background: var(--m-accent);
border-color: var(--m-accent);
color: white;
}
.mf-act-btn--primary:hover {
background: color-mix(in srgb, var(--m-accent) 88%, white);
transform: translateY(-1px);
}
.mf-act-btn > i { font-size: 0.78rem; }
/* Subheader (blueprint §9) */
.mf-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;
}
.mf-subheader__icon {
color: var(--p-primary-color);
font-size: 0.92rem;
flex-shrink: 0;
margin-top: 1px;
}
.mf-subheader__text { flex: 1; min-width: 0; }
.mf-subheader__text strong { color: var(--m-text); font-weight: 600; }
/* Body com scroll */
.mf-body {
flex: 1;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mf-body::-webkit-scrollbar { width: 5px; }
.mf-body::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
/* ─── Quick stats grid ─── */
.mf-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
@media (min-width: 768px) {
.mf-stats { grid-template-columns: repeat(4, 1fr); }
}
.mf-stat {
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
border-radius: 12px;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.mf-stat--skeleton { box-shadow: none; }
.mf-stat__val {
font-size: 1.35rem;
font-weight: 700;
line-height: 1.1;
color: var(--m-text);
}
.mf-stat__lbl {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.7rem;
font-weight: 600;
color: var(--m-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.mf-stat__lbl > i { font-size: 0.75rem; }
.mf-stat__pulse {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
animation: mf-pulse 1.6s ease-in-out infinite;
}
@keyframes mf-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.45; }
}
/* Variantes coloridas: Recebido verde / Pendente amber / Vencido red */
.mf-stat.is-recebido {
background: rgba(22, 163, 74, 0.07);
border-color: rgba(22, 163, 74, 0.30);
}
.mf-stat.is-recebido .mf-stat__val { color: rgb(22, 163, 74); }
.mf-stat.is-recebido .mf-stat__lbl { color: rgb(22, 163, 74); }
.mf-stat.is-pendente {
background: rgba(217, 119, 6, 0.07);
border-color: rgba(217, 119, 6, 0.30);
}
.mf-stat.is-pendente .mf-stat__val { color: rgb(217, 119, 6); }
.mf-stat.is-pendente .mf-stat__lbl { color: rgb(217, 119, 6); }
.mf-stat.is-vencido {
background: rgba(220, 38, 38, 0.07);
border-color: rgba(220, 38, 38, 0.30);
}
.mf-stat.is-vencido .mf-stat__val { color: rgb(220, 38, 38); }
.mf-stat.is-vencido .mf-stat__lbl { color: rgb(220, 38, 38); }
.mf-stat.is-neutral {
background: var(--m-bg-soft);
border-color: var(--m-border);
}
/* ─── Card-base (Gráfico, Projeção, Lançamentos) ─── */
.mf-card {
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.mf-card__head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border-bottom: 1px solid var(--m-border);
}
.mf-card__icon {
width: 36px;
height: 36px;
display: grid;
place-items: center;
border-radius: 9px;
flex-shrink: 0;
font-size: 1rem;
}
.mf-card__icon--primary {
background: color-mix(in srgb, var(--p-primary-color) 15%, transparent);
color: var(--p-primary-color);
}
.mf-card__title {
flex: 1;
min-width: 0;
}
.mf-card__title-text {
font-size: 0.92rem;
font-weight: 700;
color: var(--m-text);
}
.mf-card__sub {
font-size: 0.74rem;
color: var(--m-text-muted);
margin-top: 2px;
}
.mf-card__action-link {
display: inline-flex;
align-items: center;
gap: 4px;
background: transparent;
border: none;
color: var(--p-primary-color);
font-family: inherit;
font-size: 0.78rem;
font-weight: 600;
cursor: pointer;
padding: 0;
flex-shrink: 0;
transition: opacity 140ms ease;
}
.mf-card__action-link:hover { opacity: 0.75; }
.mf-card__action-link > i { font-size: 0.7rem; }
.mf-card__body {
padding: 14px 16px;
}
/* Gráfico wrapper */
.mf-chart-wrap {
height: 240px;
}
/* Empty inline (cinza, com ícone) */
.mf-empty-inline {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 32px 20px;
color: var(--m-text-muted);
font-size: 0.85rem;
}
.mf-empty-inline > i { font-size: 0.92rem; opacity: 0.7; }
/* ─── Cashflow rows ─── */
.mf-cashflow {
display: flex;
flex-direction: column;
gap: 6px;
}
.mf-cashflow__row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 9px;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
transition: background-color 140ms ease;
flex-wrap: wrap;
}
.mf-cashflow__row:hover {
background: var(--m-bg-soft-hover);
}
.mf-cashflow__mes {
font-weight: 700;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--m-text);
min-width: 56px;
flex-shrink: 0;
}
.mf-cashflow__line {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
flex-wrap: wrap;
font-size: 0.82rem;
}
.mf-cashflow__pos {
display: inline-flex;
align-items: center;
gap: 4px;
color: rgb(22, 163, 74);
font-weight: 600;
}
.mf-cashflow__neg {
display: inline-flex;
align-items: center;
gap: 4px;
color: rgb(220, 38, 38);
font-weight: 600;
}
.mf-cashflow__sep {
color: var(--m-text-faint);
opacity: 0.5;
}
.mf-cashflow__saldo {
font-weight: 700;
}
.mf-cashflow__saldo.is-positive { color: rgb(22, 163, 74); }
.mf-cashflow__saldo.is-negative { color: rgb(220, 38, 38); }
.mf-cashflow__pos > i, .mf-cashflow__neg > i { font-size: 0.7rem; }
.mf-cashflow__count {
margin-left: auto;
flex-shrink: 0;
}
/* ─── Recent table ─── */
.mf-recent-table :deep(.p-datatable-thead > tr > th) {
background: var(--m-bg-medium) !important;
color: var(--m-text);
font-size: 0.74rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 10px 14px;
border-bottom: 1px solid var(--m-border);
}
.mf-recent-table :deep(.p-datatable-tbody > tr > td) {
padding: 10px 14px;
border-bottom: 1px solid var(--m-border);
background: transparent;
font-size: 0.85rem;
}
.mf-recent-table :deep(.p-datatable-tbody > tr:hover) {
background: var(--m-bg-soft-hover);
}
.mf-amount {
font-weight: 600;
}
.mf-amount--pos { color: rgb(22, 163, 74); }
.mf-amount--neg { color: rgb(220, 38, 38); }
/* ─── Mobile (<1024px) ─── */
@media (max-width: 1023px) {
.mf-page__title > span:first-of-type { display: none; }
.mf-act-btn--primary span { display: none; }
.mf-act-btn--primary { width: 32px; padding: 0; justify-content: center; }
.mf-cashflow__count {
order: -1;
margin-left: 0;
}
}
</style>