MelissaPaciente Fase 6: Tab Financeiro completa + mark paid (mutation que legacy nao tem)

EXTENSAO src/features/patients/utils/patientFormatters.js
- recordStatus(r): pago / vencido (paid_at NULL && due_date < hoje) / pendente
- RECORD_STATUS_LABEL map
- fmtPaymentMethod(v): PIX/Cartao/Dinheiro/Boleto/Transferencia/Convenio
  cobrindo variantes pt-br + camelCase

EXTENSAO src/features/patients/composables/usePatientFinancial.js
- ref `busy` + `_lastPatientId` interno
- recordsOrdenados computed: DESC por due_date com fallback created_at
- markPaid(recordId): UPDATE financial_records SET paid_at=NOW() +
  auto-reload via _lastPatientId. Retorna {ok, error?}
- markUnpaid(recordId): reverte (paid_at=NULL) + auto-reload

MELISSAPACIENTE.VUE — script
- Imports: recordStatus, RECORD_STATUS_LABEL, fmtPaymentMethod
- markRecordPaid(r): chama financialHook.markPaid + toast success/error
- revertRecordPaid(r): chama markUnpaid + toast

MELISSAPACIENTE.VUE — Tab Financeiro reescrita (substitui placeholder Fase 1)
- Loading state
- Empty state com CTA "Novo lancamento" (mpa-quick-btn--cta)
- 3 KPIs: Pago / Pendente com proxVenc / Em atraso (cor adaptativa
  vermelho quando > 0, cinza quando 0)
- Header "Lancamentos" com badge count + botao "+ Novo" no canto
- Tabela 6-col responsiva:
  - Vencimento (date mono + relative)
  - Descricao
  - Forma (PIX/Cartao/etc)
  - Valor (mono right-aligned)
  - Status pill colorida (verde pago / vermelho vencido / azul pendente)
  - Action button (pi-check verde marca pago / pi-undo amarelo reverte)
- border-left adaptativa por status
- Mobile: tabela colapsa em cards 2-col 4-row

DIFERENCA DO LEGACY: o PatientProntuario.vue exibe a tabela mas NAO
permite marcar pago/reverter direto dela. MelissaPaciente adiciona essa
acao inline (mutation auto-reload).

CSS: ~190L novos. Padrao Melissa: status pills com color-mix, JetBrains
Mono pra valores, header cell uppercase letter-spacing.

ESLint: 0 errors da minha mudanca.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-08 09:57:42 -03:00
parent 8a8d2e05bd
commit e7c0f6c4f5
4 changed files with 506 additions and 17 deletions
+44
View File
@@ -50,6 +50,50 @@ Touched: none
## [2026-05-08 00:00] session | Melissa cfg-* nativas + temas + cronometro DB
Touched: none
## [2026-05-08 17:30] session | MelissaPaciente Fase 6 — Tab Financeiro completa + mark paid mutation
Touched: none
Detalhes: Tab Financeiro espelha o legacy + adiciona mutation que o
legacy NAO tem (mark/unmark pago direto da tabela).
EXTENSAO patientFormatters.js:
- recordStatus(r): pago | vencido | pendente
- RECORD_STATUS_LABEL map.
- fmtPaymentMethod(v): PIX/Cartao/Dinheiro/Boleto/Transferencia/Convenio
cobrindo variantes.
EXTENSAO usePatientFinancial.js:
- ref `busy` + `_lastPatientId` interno.
- recordsOrdenados computed (DESC por due_date com fallback created_at).
- markPaid(recordId): UPDATE financial_records SET paid_at=NOW() + auto-
reload. Retorna {ok, error?}.
- markUnpaid(recordId): UPDATE SET paid_at=NULL + auto-reload (reverte).
MELISSAPACIENTE.VUE — script
- Imports: recordStatus, RECORD_STATUS_LABEL, fmtPaymentMethod.
- markRecordPaid(record) handler: chama markPaid + toast success/error.
- revertRecordPaid(record): chama markUnpaid + toast.
MELISSAPACIENTE.VUE — Tab Financeiro reescrita
- Loading state.
- Empty state com CTA "Novo lancamento" (botao mpa-quick-btn--cta).
- 3 KPIs (Pago / Pendente com proxVenc / Em atraso com cor adaptativa).
- Header "Lancamentos" com badge count + botao "+ Novo" no canto.
- Tabela 6-col: Vencimento (date mono+rel) | Descricao | Forma | Valor
(mono right) | Status pill colorida (pago verde / vencido vermelho /
pendente azul) | Action button.
- Action: pi-check (verde) pra marcar pago, pi-undo (amarelo) pra reverter.
- border-left adaptativa por status (verde pago / vermelho vencido /
azul pendente).
- Mobile: tabela colapsa em cards 2-col 4-row (date|amount / desc /
method|status / action).
CSS: ~190L novos pros componentes (mpa-fin__table/row/date/desc/method/
amount/status/action + responsive). Padrao Melissa: status pills com
color-mix, JetBrains Mono pra valores, header cell uppercase letter-
spacing.
ESLint: 0 errors da minha mudanca.
## [2026-05-08 16:30] session | MelissaPaciente Fase 5 — Tab Agenda completa
Touched: none
Detalhes: Tab Agenda com KPIs, filtros, agrupamento por mes e acoes
@@ -17,8 +17,11 @@ export function usePatientFinancial() {
const records = ref([]);
const loading = ref(false);
const error = ref('');
const busy = ref(false);
let _lastPatientId = null;
async function load(patientId) {
_lastPatientId = patientId || null;
if (!patientId) {
records.value = [];
return;
@@ -101,15 +104,74 @@ export function usePatientFinancial() {
};
});
/**
* Lancamentos ordenados DESC por due_date (fallback created_at).
* Mais recente primeiro pra alimentar a tabela da Tab Financeiro.
*/
const recordsOrdenados = computed(() =>
[...records.value].sort((a, b) => {
const da = a.due_date || a.created_at;
const db = b.due_date || b.created_at;
return new Date(db) - new Date(da);
})
);
/**
* Marca um lancamento como pago (paid_at = now). Auto-reload.
* Retorna {ok, error?}.
*/
async function markPaid(recordId) {
if (!recordId || busy.value) return { ok: false, error: 'busy' };
busy.value = true;
try {
const { error: err } = await supabase
.from('financial_records')
.update({ paid_at: new Date().toISOString() })
.eq('id', recordId);
if (err) throw err;
if (_lastPatientId) await load(_lastPatientId);
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'Erro ao marcar como pago' };
} finally {
busy.value = false;
}
}
/**
* Reverte: remove paid_at (volta pra pendente). Auto-reload.
*/
async function markUnpaid(recordId) {
if (!recordId || busy.value) return { ok: false, error: 'busy' };
busy.value = true;
try {
const { error: err } = await supabase
.from('financial_records')
.update({ paid_at: null })
.eq('id', recordId);
if (err) throw err;
if (_lastPatientId) await load(_lastPatientId);
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'Erro ao reverter pagamento' };
} finally {
busy.value = false;
}
}
return {
records,
loading,
error,
busy,
load,
totalRecebido,
totalEmAberto,
totalAtrasado,
ultimoPago,
statusFinanceiro
statusFinanceiro,
recordsOrdenados,
markPaid,
markUnpaid
};
}
@@ -222,6 +222,42 @@ export const STATUS_LABEL = {
bloqueado: 'Bloqueado'
};
/**
* Determina o status financeiro de um lancamento:
* - "pago": paid_at preenchido
* - "vencido": due_date < hoje E paid_at vazio
* - "pendente": demais casos com paid_at vazio
*/
export function recordStatus(r) {
if (r?.paid_at) return 'pago';
if (r?.due_date) {
const ms = new Date(r.due_date + 'T23:59:59').getTime();
if (!Number.isNaN(ms) && ms < Date.now()) return 'vencido';
}
return 'pendente';
}
export const RECORD_STATUS_LABEL = {
pago: 'Pago',
pendente: 'Pendente',
vencido: 'Vencido'
};
/**
* Mapeia variantes de payment_method pra label legivel.
*/
export function fmtPaymentMethod(v) {
const s = String(v ?? '').toLowerCase();
if (!s) return '';
if (s === 'pix') return 'PIX';
if (s === 'cartao' || s === 'cartão' || s === 'credit_card') return 'Cartão';
if (s === 'dinheiro' || s === 'cash') return 'Dinheiro';
if (s === 'boleto') return 'Boleto';
if (s === 'transferencia' || s === 'transfer' || s === 'ted' || s === 'doc') return 'Transferência';
if (s === 'convenio' || s === 'convênio') return 'Convênio';
return v;
}
export const STATUS_SEVERITY = {
agendado: 'info',
realizado: 'success',
+362 -15
View File
@@ -41,6 +41,9 @@ import {
fmtMarital,
fmtPhoneMobile,
sessionDuration,
recordStatus,
RECORD_STATUS_LABEL,
fmtPaymentMethod,
STATUS_LABEL,
STATUS_SEVERITY,
tagStyle as tagStyleHelper
@@ -243,6 +246,34 @@ const agendaAgrupadas = computed(() => {
return groups;
});
// Handler de mutacao financeira: marcar pago / reverter
async function markRecordPaid(record) {
const result = await financialHook.markPaid(record.id);
if (result.ok) {
toast.add({ severity: 'success', summary: 'Lançamento marcado como pago', life: 2200 });
} else {
toast.add({
severity: 'error',
summary: 'Falha ao marcar pago',
detail: result.error || 'Erro inesperado',
life: 4000
});
}
}
async function revertRecordPaid(record) {
const result = await financialHook.markUnpaid(record.id);
if (result.ok) {
toast.add({ severity: 'success', summary: 'Pagamento revertido', life: 2200 });
} else {
toast.add({
severity: 'error',
summary: 'Falha ao reverter',
detail: result.error || 'Erro inesperado',
life: 4000
});
}
}
// Handler de mutacao de status (Realizada / Falta / Cancelar)
async function updateSessionStatus(ev, novoStatus, msg) {
const result = await sessionsHook.updateStatus(ev.id, novoStatus);
@@ -1454,24 +1485,157 @@ onBeforeUnmount(() => {
</template>
</div>
<!-- ABA: Financeiro -->
<!-- ABA: Financeiro (Fase 6 KPIs + tabela + mark paid) -->
<div v-else-if="activeTab === 'financ'" class="mpa-tab">
<div class="mpa-w">
<div class="mpa-w__head">
<div class="mpa-w__icon mpa-w__icon--orange"><i class="pi pi-wallet" /></div>
<div class="mpa-w__title">
<div class="mpa-w__title-text">Financeiro Fase 6</div>
<div class="mpa-w__sub">Recebíveis, atrasos, histórico</div>
</div>
</div>
<div class="mpa-w__body">
<p class="mpa-placeholder">
Em desenvolvimento <strong>Fase 6</strong>. Recebido R$ {{ financialHook.totalRecebido.value.toFixed(2) }};
em aberto R$ {{ financialHook.totalEmAberto.value.toFixed(2) }};
atrasado R$ {{ financialHook.totalAtrasado.value.toFixed(2) }}.
</p>
<!-- Loading -->
<div v-if="financialHook.loading.value" class="mpa-empty">
<i class="pi pi-spin pi-spinner mr-2" /> Carregando
</div>
<!-- Empty state com CTA -->
<div v-else-if="!financialHook.records.value.length" class="mpa-empty mpa-empty--rich">
<div class="mpa-empty__icon"><i class="pi pi-wallet" /></div>
<div class="mpa-empty__title">Sem lançamentos financeiros</div>
<div class="mpa-empty__sub">
Adicione o primeiro lançamento de cobrança ou recebimento deste paciente.
</div>
<button type="button" class="mpa-quick-btn mpa-quick-btn--cta" @click="addFinancial">
<i class="pi pi-plus" :style="{ color: '#f59e0b' }" />
<span>Novo lançamento</span>
</button>
</div>
<template v-else>
<!-- 3 KPIs financeiros -->
<div class="mpa-kpis">
<article class="mpa-kpi" style="--c:#4ade80">
<span class="mpa-kpi__num">01</span>
<header class="mpa-kpi__head">
<div class="mpa-kpi__icon"><i class="pi pi-check-circle" /></div>
<span class="mpa-kpi__tag">Pago</span>
</header>
<div class="mpa-kpi__big mpa-kpi__big--small">
{{ fmtCurrency(financialHook.statusFinanceiro.value.totalPago) }}
</div>
<div class="mpa-kpi__cap">
{{ financialHook.records.value.filter((r) => !!r.paid_at).length }}
{{ financialHook.records.value.filter((r) => !!r.paid_at).length === 1 ? 'lançamento' : 'lançamentos' }}
</div>
</article>
<article class="mpa-kpi" style="--c:var(--p-primary-color)">
<span class="mpa-kpi__num">02</span>
<header class="mpa-kpi__head">
<div class="mpa-kpi__icon"><i class="pi pi-clock" /></div>
<span class="mpa-kpi__tag">Pendente</span>
</header>
<div class="mpa-kpi__big mpa-kpi__big--small">
{{ fmtCurrency(financialHook.statusFinanceiro.value.totalPendente) }}
</div>
<div class="mpa-kpi__cap">
<template v-if="financialHook.statusFinanceiro.value.proxVenc">
Próx. venc. {{ fmtDateBR(financialHook.statusFinanceiro.value.proxVenc.due_date) }}
</template>
<template v-else>A receber</template>
</div>
</article>
<article
class="mpa-kpi"
:style="financialHook.statusFinanceiro.value.vencidos > 0 ? '--c:#f87171' : '--c:#94a3b8'"
>
<span class="mpa-kpi__num">03</span>
<header class="mpa-kpi__head">
<div class="mpa-kpi__icon"><i class="pi pi-exclamation-triangle" /></div>
<span class="mpa-kpi__tag">Em atraso</span>
</header>
<div class="mpa-kpi__big">{{ financialHook.statusFinanceiro.value.vencidos }}</div>
<div class="mpa-kpi__cap">
{{ financialHook.statusFinanceiro.value.vencidos === 0
? 'Tudo em dia'
: (financialHook.statusFinanceiro.value.vencidos === 1 ? 'lançamento vencido' : 'lançamentos vencidos') }}
</div>
</article>
</div>
<!-- Tabela de lançamentos -->
<section class="mpa-panel">
<header class="mpa-panel__head">
<div class="mpa-panel__title"><i class="pi pi-list" /> Lançamentos</div>
<div class="mpa-fin__head-actions">
<span class="mpa-panel__badge">{{ financialHook.recordsOrdenados.value.length }}</span>
<button type="button" class="mpa-icon-btn-sm" v-tooltip.left="'Novo lançamento'" @click="addFinancial">
<i class="pi pi-plus" />
</button>
</div>
</header>
<div class="mpa-fin__table" role="table">
<div class="mpa-fin__row mpa-fin__row--head" role="row">
<span role="columnheader">Vencimento</span>
<span role="columnheader">Descrição</span>
<span role="columnheader" class="mpa-fin__col-method">Forma</span>
<span role="columnheader" class="mpa-fin__col-amount">Valor</span>
<span role="columnheader" class="mpa-fin__col-status">Status</span>
<span role="columnheader" class="mpa-fin__col-action"></span>
</div>
<div
v-for="r in financialHook.recordsOrdenados.value"
:key="r.id"
class="mpa-fin__row"
:data-status="recordStatus(r)"
role="row"
>
<span class="mpa-fin__date" role="cell">
<span class="mpa-fin__date-main">
{{ r.due_date ? fmtDateBR(r.due_date) : fmtDateBR(r.created_at) }}
</span>
<span v-if="r.due_date" class="mpa-fin__date-rel">
{{ fmtRelative(r.due_date) }}
</span>
</span>
<span class="mpa-fin__desc" role="cell">
{{ r.description || (r.category ? r.category : 'Lançamento') }}
</span>
<span class="mpa-fin__method" role="cell">
{{ fmtPaymentMethod(r.payment_method) || '—' }}
</span>
<span class="mpa-fin__amount" role="cell">
{{ fmtCurrency(Number(r.amount) || 0) }}
</span>
<span class="mpa-fin__status-cell" role="cell">
<span class="mpa-fin__status" :data-status="recordStatus(r)">
<span class="mpa-fin__status-dot" />
{{ RECORD_STATUS_LABEL[recordStatus(r)] }}
</span>
</span>
<span class="mpa-fin__action" role="cell">
<!-- Marca como pago se ainda nao foi -->
<button
v-if="!r.paid_at"
type="button"
v-tooltip.left="'Marcar como pago'"
class="mpa-ag__act mpa-ag__act--ok"
:disabled="financialHook.busy.value"
@click="markRecordPaid(r)"
>
<i class="pi pi-check" />
</button>
<!-- Reverte pagamento -->
<button
v-else
type="button"
v-tooltip.left="'Reverter pagamento'"
class="mpa-ag__act mpa-ag__act--warn"
:disabled="financialHook.busy.value"
@click="revertRecordPaid(r)"
>
<i class="pi pi-undo" />
</button>
</span>
</div>
</div>
</section>
</template>
</div>
<!-- ABA: Documentos -->
@@ -2498,6 +2662,189 @@ onBeforeUnmount(() => {
font-size: 0.66rem !important;
}
/* ═══════ Tab Financeiro (Fase 6) ═══════ */
.mpa-fin__head-actions {
display: flex;
align-items: center;
gap: 6px;
}
.mpa-icon-btn-sm {
width: 26px;
height: 26px;
display: grid;
place-items: center;
border-radius: 6px;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
color: var(--p-primary-color);
cursor: pointer;
font-family: inherit;
transition: background-color 120ms ease, border-color 120ms ease;
}
.mpa-icon-btn-sm:hover {
background: color-mix(in srgb, var(--p-primary-color) 14%, transparent);
border-color: color-mix(in srgb, var(--p-primary-color) 38%, transparent);
}
.mpa-icon-btn-sm > i { font-size: 0.78rem; }
/* Quick btn variant CTA pra empty state */
.mpa-quick-btn--cta {
margin: 12px auto 0;
padding: 10px 16px;
background: var(--m-bg-medium);
border-color: var(--m-border-strong);
}
/* Tabela financeira */
.mpa-fin__table {
display: flex;
flex-direction: column;
}
.mpa-fin__row {
display: grid;
grid-template-columns: 110px 1fr 90px 100px 100px 36px;
gap: 12px;
align-items: center;
padding: 10px 14px;
border-top: 1px solid var(--m-border);
font-size: 0.82rem;
border-left: 3px solid transparent;
transition: background-color 120ms ease;
}
.mpa-fin__row:first-child { border-top: none; }
.mpa-fin__row:hover:not(.mpa-fin__row--head) { background: var(--m-bg-medium); }
.mpa-fin__row[data-status="pago"] { border-left-color: rgb(34, 197, 94); }
.mpa-fin__row[data-status="vencido"] { border-left-color: rgb(239, 68, 68); }
.mpa-fin__row[data-status="pendente"] { border-left-color: rgb(96, 165, 250); }
.mpa-fin__row--head {
background: var(--m-bg-medium);
font-size: 0.66rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--m-text-muted);
border-left-color: transparent !important;
}
.mpa-fin__date {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.mpa-fin__date-main {
font-weight: 700;
color: var(--m-text);
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 0.78rem;
}
.mpa-fin__date-rel {
font-size: 0.66rem;
color: var(--m-text-muted);
}
.mpa-fin__desc {
color: var(--m-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.mpa-fin__method {
color: var(--m-text-muted);
font-size: 0.74rem;
}
.mpa-fin__amount {
font-weight: 800;
color: var(--m-text);
text-align: right;
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 0.82rem;
}
.mpa-fin__col-amount { text-align: right; }
.mpa-fin__col-method,
.mpa-fin__col-status,
.mpa-fin__col-action { text-align: center; }
.mpa-fin__status {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 10px;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 700;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
}
.mpa-fin__status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.mpa-fin__status[data-status="pago"] {
background: color-mix(in srgb, rgb(34, 197, 94) 12%, transparent);
color: rgb(34, 197, 94);
border-color: color-mix(in srgb, rgb(34, 197, 94) 35%, transparent);
}
.mpa-fin__status[data-status="vencido"] {
background: color-mix(in srgb, rgb(239, 68, 68) 12%, transparent);
color: rgb(239, 68, 68);
border-color: color-mix(in srgb, rgb(239, 68, 68) 35%, transparent);
}
.mpa-fin__status[data-status="pendente"] {
background: color-mix(in srgb, rgb(96, 165, 250) 12%, transparent);
color: rgb(96, 165, 250);
border-color: color-mix(in srgb, rgb(96, 165, 250) 35%, transparent);
}
.mpa-fin__action {
display: flex;
justify-content: center;
}
/* Mobile: stack tabela em cards 2-col */
@media (max-width: 720px) {
.mpa-fin__row--head { display: none; }
.mpa-fin__row {
grid-template-columns: 1fr auto;
grid-template-rows: auto auto auto;
gap: 4px 10px;
padding: 12px 14px;
}
.mpa-fin__date {
grid-row: 1;
grid-column: 1;
}
.mpa-fin__amount {
grid-row: 1;
grid-column: 2;
text-align: right;
}
.mpa-fin__desc {
grid-row: 2;
grid-column: 1 / -1;
font-size: 0.78rem;
}
.mpa-fin__method {
grid-row: 3;
grid-column: 1;
}
.mpa-fin__status-cell {
grid-row: 3;
grid-column: 2;
text-align: right;
}
.mpa-fin__action {
grid-row: 4;
grid-column: 1 / -1;
justify-content: flex-end;
}
}
/* ═══════ Tab Agenda (Fase 5) ═══════ */
.mpa-ag__group + .mpa-ag__group { margin-top: 10px; }
.mpa-ag__list {