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:
@@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user