MelissaPaciente: fix openWhatsapp + dialog inline novo lancamento financeiro

DOIS BUGS DE COMPORTAMENTO:

1. openWhatsapp nao abria o drawer
   conversationDrawerStore.openForPatient(patientId) espera STRING id,
   nao objeto. Eu passava { id, name, phone, avatar_url } — store
   ignorava e drawer nunca abria.
   FIX: passar String(props.patientId) (mesmo pattern que MelissaPacientes).
   BONUS: a store seta this.error sem dar throw quando paciente nao tem
   telefone cadastrado. Detectamos com `if (err && !isOpen)` e mostramos
   toast warn com a mensagem da store ("Paciente sem telefone cadastrado").
   Funcao virou async pra aguardar o openForPatient.

2. addFinancial era placeholder "Em breve"
   User correto: o sistema ja tem suporte (composables/useFinancialRecords
   tem createManualRecord). Implementado dialog inline simples no
   prontuario.

NOVO em src/features/patients/composables/usePatientFinancial.js
- createRecord(patientId, payload) — INSERT financial_records com
  type='receita', resolve owner_id (auth.getUser) e tenant_id (lazy
  import tenantStore pra evitar circular). Auto-reload via _lastPatientId.
  Retorna {ok, data?, error?}.

NOVO em MelissaPaciente.vue
- Refs novoLancOpen + novoLancForm (description/amount/due_date/payment_method)
- PAYMENT_METHODS array (Pix/Cartao/Dinheiro/Transferencia/Boleto/Convenio)
- addFinancial() agora abre o dialog (era toast "em breve")
- salvarLancamento() handler com validacao (valor > 0, due_date obrigatorio)
- <Dialog> v-model:visible 420px com form: descricao + grid 2-col
  (valor InputNumber BRL + vencimento date input) + select forma
- CSS .mpa-novo-lanc + responsive (1-col em <540px)

ESLint: 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-08 11:12:22 -03:00
parent 301a7124a7
commit 64005a5b07
2 changed files with 226 additions and 20 deletions
@@ -138,6 +138,58 @@ export function usePatientFinancial() {
} }
} }
/**
* Cria um novo lancamento manual (type=receita) pro paciente.
* Insere com tenant_id + owner_id resolvidos via auth/tenant store.
* Auto-reload ao final pra refletir nos KPIs e tabela.
*
* payload: { description, amount, due_date, payment_method? }
* Retorna {ok, data?, error?}.
*/
async function createRecord(patientId, payload = {}) {
if (!patientId || busy.value) return { ok: false, error: 'busy' };
if (!payload?.amount || Number.isNaN(Number(payload.amount))) {
return { ok: false, error: 'Valor invalido' };
}
busy.value = true;
try {
const { data: userData } = await supabase.auth.getUser();
const ownerId = userData?.user?.id;
// tenant_id: tenta tenantStore lazy import, fallback null (RLS
// via owner_id ainda permite insert).
let tenantId = null;
try {
const { useTenantStore } = await import('@/stores/tenantStore');
tenantId = useTenantStore().activeTenantId || null;
} catch { /* sem tenant store — segue */ }
const row = {
patient_id: patientId,
owner_id: ownerId,
tenant_id: tenantId,
type: 'receita',
amount: Number(payload.amount),
due_date: payload.due_date || null,
description: String(payload.description || '').trim() || null,
payment_method: payload.payment_method || null,
paid_at: null
};
const { data, error: err } = await supabase
.from('financial_records')
.insert([row])
.select()
.single();
if (err) throw err;
if (_lastPatientId) await load(_lastPatientId);
return { ok: true, data };
} catch (e) {
return { ok: false, error: e?.message || 'Erro ao criar lançamento' };
} finally {
busy.value = false;
}
}
/** /**
* Reverte: remove paid_at (volta pra pendente). Auto-reload. * Reverte: remove paid_at (volta pra pendente). Auto-reload.
*/ */
@@ -172,6 +224,7 @@ export function usePatientFinancial() {
statusFinanceiro, statusFinanceiro,
recordsOrdenados, recordsOrdenados,
markPaid, markPaid,
markUnpaid markUnpaid,
createRecord
}; };
} }
+172 -19
View File
@@ -357,19 +357,27 @@ async function onPatientSaved() {
} }
} }
// Open WhatsApp: usa o conversationDrawerStore global (mesmo padrao // Open WhatsApp: usa o conversationDrawerStore global (mesmo pattern
// que MelissaPacientes usa). O drawer desce sobre o Melissa sem fechar. // que MelissaPacientes — passa STRING id, nao objeto). A store cuida
function openWhatsapp() { // de buscar/criar a thread + telefone do paciente. Se nao tem telefone
// cadastrado, store.error eh setado e mostramos toast warn.
async function openWhatsapp() {
emit('open-whatsapp', props.patientId); emit('open-whatsapp', props.patientId);
if (!props.patientId) return; if (!props.patientId) {
const data = patientData.value || {}; toast.add({ severity: 'warn', summary: 'Paciente sem ID', life: 2500 });
return;
}
try { try {
if (typeof conversationDrawerStore.openForPatient === 'function') { await conversationDrawerStore.openForPatient(String(props.patientId));
conversationDrawerStore.openForPatient({ // A store seta this.error sem dar throw quando paciente nao tem
id: props.patientId, // telefone — drawer simplesmente nao abre. Detectamos aqui.
name: data.nome_completo || data.nome || '', const err = conversationDrawerStore.error;
phone: data.telefone || data.phone || '', if (err && !conversationDrawerStore.isOpen) {
avatar_url: data.avatar_url || data.avatar || '' toast.add({
severity: 'warn',
summary: 'Não foi possível abrir',
detail: err.message || 'Verifique o telefone do paciente.',
life: 4000
}); });
} }
} catch (e) { } catch (e) {
@@ -382,17 +390,64 @@ function openWhatsapp() {
} }
} }
// Add financial: por enquanto so emite. Futuro: dialog inline de // Add financial: abre dialog inline pra criar lancamento manual.
// novo lancamento (Fase 9). const novoLancOpen = ref(false);
const novoLancForm = ref({
description: '',
amount: null,
due_date: '',
payment_method: ''
});
function addFinancial() { function addFinancial() {
emit('add-financial', props.patientId); emit('add-financial', props.patientId);
toast.add({ if (!props.patientId) {
severity: 'info', toast.add({ severity: 'warn', summary: 'Paciente sem ID', life: 2500 });
summary: 'Em breve', return;
detail: 'Dialog de novo lançamento será adicionado numa próxima sessão.', }
life: 3000 novoLancForm.value = {
}); description: '',
amount: null,
due_date: new Date().toISOString().slice(0, 10),
payment_method: ''
};
novoLancOpen.value = true;
} }
async function salvarLancamento() {
const f = novoLancForm.value;
if (!f.amount || Number(f.amount) <= 0) {
toast.add({ severity: 'warn', summary: 'Valor inválido', detail: 'Informe um valor maior que zero.', life: 3000 });
return;
}
if (!f.due_date) {
toast.add({ severity: 'warn', summary: 'Vencimento obrigatório', life: 2500 });
return;
}
const result = await financialHook.createRecord(props.patientId, {
description: f.description,
amount: Number(f.amount),
due_date: f.due_date,
payment_method: f.payment_method || null
});
if (result.ok) {
toast.add({ severity: 'success', summary: 'Lançamento criado', life: 2200 });
novoLancOpen.value = false;
} else {
toast.add({
severity: 'error',
summary: 'Falha ao criar',
detail: result.error || 'Erro inesperado',
life: 4000
});
}
}
const PAYMENT_METHODS = [
{ label: 'Pix', value: 'pix' },
{ label: 'Cartão', value: 'cartao' },
{ label: 'Dinheiro', value: 'dinheiro' },
{ label: 'Transferência', value: 'transferencia' },
{ label: 'Boleto', value: 'boleto' },
{ label: 'Convênio', value: 'convenio' }
];
// ── Load data quando patientId muda ──────────────────────── // ── Load data quando patientId muda ────────────────────────
async function loadAll(id) { async function loadAll(id) {
@@ -1891,6 +1946,75 @@ onBeforeUnmount(() => {
:patient-id="cadastroPatientId" :patient-id="cadastroPatientId"
@created="onPatientSaved" @created="onPatientSaved"
/> />
<!-- Dialog: novo lancamento manual (chamado pelo botao "Lancamento"
da sidebar Acoes Rapidas e CTA do empty state da Tab Financeiro). -->
<Dialog
v-model:visible="novoLancOpen"
modal
dismissable-mask
:style="{ width: '420px', maxWidth: '92vw' }"
header="Novo lançamento"
>
<div class="mpa-novo-lanc">
<div class="mpa-novo-lanc__field">
<label class="mpa-novo-lanc__label">Descrição</label>
<InputText
v-model="novoLancForm.description"
placeholder="Ex: Sessão semanal, Avaliação inicial…"
class="w-full"
/>
</div>
<div class="mpa-novo-lanc__row">
<div class="mpa-novo-lanc__field">
<label class="mpa-novo-lanc__label">Valor (R$) *</label>
<InputNumber
v-model="novoLancForm.amount"
mode="currency"
currency="BRL"
locale="pt-BR"
:min="0"
class="w-full"
input-class="w-full"
/>
</div>
<div class="mpa-novo-lanc__field">
<label class="mpa-novo-lanc__label">Vencimento *</label>
<InputText
v-model="novoLancForm.due_date"
type="date"
class="w-full"
/>
</div>
</div>
<div class="mpa-novo-lanc__field">
<label class="mpa-novo-lanc__label">Forma de pagamento</label>
<Select
v-model="novoLancForm.payment_method"
:options="PAYMENT_METHODS"
option-label="label"
option-value="value"
placeholder="Opcional"
class="w-full"
show-clear
/>
</div>
</div>
<template #footer>
<button class="mpa-quick-btn" @click="novoLancOpen = false">
<i class="pi pi-times" />
<span>Cancelar</span>
</button>
<button
class="mpa-quick-btn mpa-quick-btn--cta"
:disabled="financialHook.busy.value"
@click="salvarLancamento"
>
<i :class="financialHook.busy.value ? 'pi pi-spin pi-spinner' : 'pi pi-check'" :style="{ color: '#10b981' }" />
<span>Salvar lançamento</span>
</button>
</template>
</Dialog>
</template> </template>
<style scoped> <style scoped>
@@ -2886,6 +3010,35 @@ onBeforeUnmount(() => {
font-size: 0.66rem !important; font-size: 0.66rem !important;
} }
/* ═══════ Dialog: novo lancamento (form simples) ═══════ */
.mpa-novo-lanc {
display: flex;
flex-direction: column;
gap: 12px;
padding-top: 4px;
}
.mpa-novo-lanc__row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
@media (max-width: 540px) {
.mpa-novo-lanc__row {
grid-template-columns: 1fr;
}
}
.mpa-novo-lanc__field {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.mpa-novo-lanc__label {
font-size: 0.74rem;
font-weight: 700;
color: var(--m-text);
}
/* ═══════ Tab Conversas (Fase 7) — CTA pra drawer ═══════ */ /* ═══════ Tab Conversas (Fase 7) — CTA pra drawer ═══════ */
.mpa-conv-cta { .mpa-conv-cta {
display: flex; display: flex;