387043b3b2
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>
1525 lines
54 KiB
Vue
1525 lines
54 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 } 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>
|