agenda: C5+C6 testes OK + atalho Gerar fatura + RPC idempotência fix
DB - migration 20260519000001: create_financial_record_for_session passa a ignorar status='cancelled' na idempotência (era bug — cancelar e tentar regerar travava silencioso retornando o cancelado) Cenário 5 (convênio) — fixes pra save + visualização - Convênio: amount lia 'price' (null) → agora detecta via insurance_plan_id e usa insurance_value. payment_method forçado 'convenio' (era 'asaas') - Popover: ev.price era null em convênio → normalize expõe insurance_value e paymentLabel faz fallback. Linha mostra "A receber R$ X" corretamente - /financeiro: branch novo pra payment_method='convenio' → pill violeta com pi-id-card (antes ficava sem indicador, igual particular) Cenário 6 (recorrente sem pacote, Maria Magali) — materialização - chargeMode='none' não materializava a 1ª (todas viravam virtuais, sem badge $). Agora materializa a 1ª no fluxo de criação recorrente - Bug intermediário: usei 'paciente_id' (Portuguese) mas agendaRepository dropa esse campo. Corrigido pra 'patient_id' (English DB column) Atalho "Gerar fatura" no popover - Pill amber pequeno ao lado de "A cobrar R$ X" no popover (paymentVariant ='none' + sessão materializada) - Wire em MelissaLayout via emit gerar-cobranca + handler onGerarCobrancaQuick (chama gerarCobrancaManual, fecha popover pra impedir double-click) - Bulk-load do useMelissaAgenda e fetchRecord do AgendaEventoFinanceiroPanel agora filtram status='cancelled' (resolve badge $ residual + botão sumido) Header do popover: info de pacote/série - "Sessão · Pacote · N sessões" ou "Sessão X de Y" abaixo do tipo (computed seriesLabel lê do _raw da rule) Título do dialog "Sessão do Pacote · Sessão" - Quando commitment name é "Sessão" (default), drop pra evitar duplicação - Outros nomes (Avaliação, etc) permanecem com forma completa Excluir série inteira (popover) - Novo botão "Excluir série" no popover quando evento pertence a recorrência - Hard delete: financial_records pendentes → agenda_eventos materializados → recurrence_rules (CASCADE leva exceptions + rule_services) - Bloqueia se algum record tem status='paid' (estornar primeiro) cancel_session some da agenda - useRecurrence.expandRules agora pula occurrence com exception type= 'cancel_session' (era visível com status cancelado; doc dizia "some da agenda" mas código mantinha. Honra a promessa do diálogo) - patient_missed / therapist_canceled / holiday_block permanecem visíveis como histórico UX outros - "+ Novo convênio" toolbar em ConfiguracoesConveniosPage (botão faltava — empty state mandava clicar em botão inexistente) - InsurancePlanServiceQuickCreateDialog: cadastrar procedimento POR CIMA do AgendaEventDialog sem sair da agenda. Auto-seleciona quando nada estava selecionado antes - Hint contextual abaixo do card Sessão/Honorários: convênio = "Nº guia opcional"; gratuito = "sem cobrança". Particular sem hint - recurrence_exceptions cancel agora usa upsert com onConflict (idempotente, não quebra com unique violation em re-cancel) - goToConveniosConfig removida (dead code após quick-create inline) CSS - .aed-row-50 perdeu margin-bottom (user request) - .field-card.mb-4 ganhou margin-top: 1rem (scoped a composer wrappers) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+21
-21
@@ -1,38 +1,36 @@
|
||||
# HANDOFF — 2026-05-18 noite (C1-C4 OK, UX convênio refinado, C5 ainda não rodou save)
|
||||
# HANDOFF — 2026-05-19 (C1-C6 ✅, próximo C7 — pacote upfront)
|
||||
|
||||
Documento de continuidade. **Quando voltar, comece lendo esta página até o fim.**
|
||||
|
||||
> **🎯 SE A FORÇA CAIR / SESSÃO PERDER CONTEXTO:** estamos na rodada de
|
||||
> testes manuais dos 13 cenários do doc viva
|
||||
> `src/docs/agenda-compromisso-financeiro-cenarios.html`. C1-C4 ✅, **C5
|
||||
> ainda não rodou save** — a tarde do dia 18 foi consumida refinando UX
|
||||
> de convênio (3 bugs/melhorias) e preparando o C5. Próximo passo:
|
||||
> **executar de fato o save do C5** (Sándor + Unimed Nacional + R$ 95).
|
||||
> `src/docs/agenda-compromisso-financeiro-cenarios.html`. **C1-C6 ✅**.
|
||||
> Próximo passo: **Cenário 7** (Donald Winnicott · pacote UPFRONT · 4 ×
|
||||
> R$ 200 = cobrança única de R$ 800).
|
||||
|
||||
> **🟡 WORKING TREE BEM PESADO** — refactor de payment, indicadores
|
||||
> visuais (barra verde + popover + Resumo do dialog), inline quick-create
|
||||
> de procedimento, fix de rota convênios, botão "+ Novo convênio",
|
||||
> hint contextual. Migrations da Fase 5 já rodadas em 14/05. **Considerar
|
||||
> commitar antes de mais trabalho** — diff tá grande.
|
||||
> **🟢 WORKING TREE LIMPO** após commit/push de 19/05 da manhã. Migration
|
||||
> nova (20260519000001) já rodada no DB local. Pronto pra próximo bloco.
|
||||
|
||||
---
|
||||
|
||||
## 🔴 PRÓXIMO PASSO IMEDIATO — Cenário 5 (Convênio)
|
||||
|
||||
Receita do doc HTML. Resumo:
|
||||
## 🔴 PRÓXIMO PASSO IMEDIATO — Cenário 7 (Pacote UPFRONT)
|
||||
|
||||
| Campo | Valor |
|
||||
|---|---|
|
||||
| Paciente | **Sándor** |
|
||||
| Convênio | **Unimed Nacional** (criar via `InsurancePlanQuickCreateDialog` se não existir) |
|
||||
| Valor | **R$ 95** |
|
||||
| Paciente | **Donald Winnicott** |
|
||||
| Frequência | **Semanal · 4 ocorrências** |
|
||||
| Serviço | qualquer (ex: Sessão particular R$ 200) |
|
||||
| Cobrança ao salvar | **Gerar cobrança** + estilo **"Pacote único (upfront)"** |
|
||||
| Total esperado | **R$ 800** (4 × 200) |
|
||||
|
||||
**Esperado:**
|
||||
- Record com `insurance_plan_id` preenchido + pill "convênio" visível
|
||||
- Na agenda: badge $ amber (record pendente até fechamento mensal do convênio)
|
||||
- Popover: linha amber "A receber R$ 95,00 (cobrança pendente)"
|
||||
- 1 row em `recurrence_rules` + **1 row em `agenda_eventos`** (1ª materializada)
|
||||
- 1 row em `billing_contracts` (type=package, charging_style=upfront, total_sessions=4, package_price=800)
|
||||
- **1 row em `financial_records`** com amount=800, status=pending (cobrança ÚNICA do pacote, vencimento na 1ª sessão)
|
||||
- Agenda: 1ª com badge $ (R$ 800 a cobrar) + 3 virtuais limpas (cobertas pelo pacote)
|
||||
|
||||
Após o 5 passar: 6-9 (recorrentes) → 10-13 (status change + edit cobrada). Quando todos passarem, replicar em **Rail** (`AgendaTerapeutaPage.vue`) e **Clínica** (`AgendaClinicaPage.vue`).
|
||||
Após C7: C8 (saldo) → C9 (per_session) → C10-C13 (status change + edit cobrada).
|
||||
Quando todos passarem, replicar em **Rail** (`AgendaTerapeutaPage.vue`) e **Clínica** (`AgendaClinicaPage.vue`).
|
||||
|
||||
---
|
||||
|
||||
@@ -150,7 +148,9 @@ User tentou rodar C5 e bateu em 3 problemas seguidos. Cada um virou um fix:
|
||||
| 2 | Avulsa sem cobrança | ✅ |
|
||||
| 3 | Avulsa cobrar ao salvar | ✅ |
|
||||
| 4 | Avulsa "já recebi" no salvar | ✅ |
|
||||
| **5** | **Avulsa convênio (Sándor + Unimed)** | 🔴 **PRÓXIMO** |
|
||||
| 5 | Avulsa convênio (Sándor + Unimed) | ✅ |
|
||||
| 6 | Recorrente sem pacote (Maria Magali / Anna Freud) | ✅ |
|
||||
| **7** | **Pacote UPFRONT (Donald Winnicott 4 × R$ 200)** | 🔴 **PRÓXIMO** |
|
||||
| 6 | Recorrente sem pacote (Anna Freud 4 sem) | ⏳ |
|
||||
| 7 | Pacote upfront (Donald Winnicott 4× R$ 200) | ⏳ |
|
||||
| 8 | Pacote saldo (Carl Jung 4× R$ 40) | ⏳ |
|
||||
|
||||
@@ -14,6 +14,74 @@ Chronological, append-only record of everything that's happened in this wiki.
|
||||
|
||||
---
|
||||
|
||||
## [2026-05-19 14:00] session | C5+C6 OK + atalho gerar fatura + RPC idempotencia fix
|
||||
Touched: project_rpc_idempotency_cancelled (nova)
|
||||
Detalhes: sessao longa cobrindo C5 e C6 ate green checkmark, com varios
|
||||
fixes intermediarios:
|
||||
|
||||
CENARIO 5 (Sandor + Unimed convenio):
|
||||
- 2 bugs no save: amount lia 'price' (null em convenio) e payment_method
|
||||
caia em 'asaas' por fallback. Fix: detecta convenio via insurance_plan_id,
|
||||
amount=insurance_value, payment_method='convenio'. markPaidNow ignorado
|
||||
pra convenio (sempre nasce pending).
|
||||
- 2 bugs na visualizacao: popover so mostrava "Cobranca pendente" sem
|
||||
valor (lia ev.price) e /financeiro nao mostrava pill convenio.
|
||||
Fix: normalize expoe insurance_value; popover fallback price ?? insurance_value;
|
||||
/financeiro ganhou branch pra payment_method='convenio' (pill violeta).
|
||||
|
||||
CENARIO 6 (Maria Magali, recorrente sem pacote):
|
||||
- chargeMode='none' nao materializava 1a (todas viravam virtuais, sem badge).
|
||||
Fix: adicionado bloco no fluxo de criacao recorrente que materializa 1a
|
||||
quando chargeMode='none' && paciente_id. Package/per_session ja
|
||||
materializam nos seus helpers; package saldo intencionalmente NAO.
|
||||
- Primeira tentativa de fix usou paciente_id (Portuguese) mas
|
||||
agendaRepository dropa esse campo (legado). Corrigido pra patient_id
|
||||
(English DB column). Fallback `normalized.patient_id ?? normalized.paciente_id`.
|
||||
|
||||
ATALHO "Gerar fatura" no popover:
|
||||
- Pill amber "Gerar fatura" ao lado de "A cobrar R$ X" no popover.
|
||||
- Wire em MelissaLayout via emit gerar-cobranca + handler onGerarCobrancaQuick.
|
||||
- Fecha popover apos sucesso pra impedir click duplicado.
|
||||
- Tooltip simplificado: "Gerar fatura agora".
|
||||
|
||||
BUG RPC IDEMPOTENCIA (gravado em memoria):
|
||||
- create_financial_record_for_session checava idempotencia por
|
||||
agenda_evento_id + deleted_at IS NULL, MAS sem filtrar status='cancelled'.
|
||||
User cancelava cobranca sem querer → todo regenerar retornava cancelado
|
||||
e nada inseria. Toast verde mentindo.
|
||||
- Fix: migration 20260519000001 adiciona AND status != 'cancelled'.
|
||||
- Memoria salva em project_rpc_idempotency_cancelled.md (padrao a aplicar
|
||||
em toda RPC futura com idempotencia por chave natural).
|
||||
|
||||
INFO PACOTE NO POPOVER:
|
||||
- Header do popover agora mostra "Sessao · Pacote · N sessoes" ou similar
|
||||
(computed seriesLabel le do _raw). Visual: violeta sem caps lock.
|
||||
|
||||
TITULO DIALOG "Sessao do Pacote · Sessao":
|
||||
- Quando commitment name eh "Sessao" (default), drop pra evitar duplicacao.
|
||||
Outros nomes (Avaliacao etc) permanecem com forma completa.
|
||||
|
||||
CSS:
|
||||
- .aed-row-50 perdeu margin-bottom (user request).
|
||||
- .field-card.mb-4 ganhou margin-top: 1rem (scoped via composer wrappers).
|
||||
|
||||
OUTROS BUGS RESOLVIDOS NO CAMINHO:
|
||||
- AgendaEventoFinanceiroPanel.fetchRecord agora filtra cancelled.
|
||||
- bulk-load do useMelissaAgenda agora filtra cancelled.
|
||||
- recurrence_exceptions cancel agora usa upsert (idempotente, nao quebra
|
||||
com unique constraint quando user cancela 2x ou tem exception zumbi).
|
||||
- "Excluir serie inteira" botao novo no popover (hard delete: rule +
|
||||
materializadas + financial_records pendentes; bloqueia se paid).
|
||||
- cancel_session exception agora SOME da agenda (era visivel com status
|
||||
cancelado, doc dizia "some da agenda" mas codigo mantinha).
|
||||
- ConfiguracoesConveniosPage ganhou botao "+ Novo convenio" (faltava).
|
||||
- goToConveniosConfig removida (dead code pos quick-create inline).
|
||||
- Quick-create inline de procedimento (InsurancePlanServiceQuickCreateDialog).
|
||||
- Hint contextual abaixo do card Sessao/Honorarios (convenio = "N guia
|
||||
opcional"; gratuito = "sem cobranca").
|
||||
|
||||
PROXIMO: Cenario 7 (Donald Winnicott · pacote UPFRONT · 4 × R$ 200).
|
||||
|
||||
## [2026-05-18 23:30] session | UX de convenio refinado (3 fixes) + hint contextual
|
||||
Touched: none (sem nova wiki page; tudo em codigo + HANDOFF)
|
||||
Detalhes: tarde inteira consumida em refinar UX de convenio antes do
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
-- ============================================================================
|
||||
-- create_financial_record_for_session: idempotência ignora cancelled
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Bug: a função recusava criar um novo financial_record quando já existia
|
||||
-- um record cancelled pro mesmo agenda_evento_id, porque a checagem de
|
||||
-- idempotência só filtrava `deleted_at IS NULL` (e cancel preserva
|
||||
-- deleted_at = NULL pra manter auditoria).
|
||||
--
|
||||
-- Consequência: user cancelava cobrança sem querer e ficava preso — todo
|
||||
-- "Gerar cobrança" subsequente retornava o registro cancelado sem inserir
|
||||
-- nova linha (frontend recebia data, achava sucesso, mas DB ficava como
|
||||
-- estava).
|
||||
--
|
||||
-- Fix: adiciona `AND status != 'cancelled'` na checagem. Cancelled passa a
|
||||
-- ser tratado como "sem cobrança ativa" pra idempotência. Audit history
|
||||
-- continua preservado (rows cancelled permanecem na tabela).
|
||||
--
|
||||
-- Idempotente: CREATE OR REPLACE substitui a função existente.
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.create_financial_record_for_session(
|
||||
p_tenant_id uuid,
|
||||
p_owner_id uuid,
|
||||
p_patient_id uuid,
|
||||
p_agenda_evento_id uuid,
|
||||
p_amount numeric,
|
||||
p_due_date date
|
||||
) RETURNS SETOF public.financial_records
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
SET search_path TO 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_existing public.financial_records%ROWTYPE;
|
||||
v_new public.financial_records%ROWTYPE;
|
||||
BEGIN
|
||||
-- Idempotência: retorna o registro existente se já foi criado.
|
||||
-- Ignora cancelled (treat as "no active record") pra permitir regenerar
|
||||
-- cobrança após cancelamento.
|
||||
SELECT * INTO v_existing
|
||||
FROM public.financial_records
|
||||
WHERE agenda_evento_id = p_agenda_evento_id
|
||||
AND deleted_at IS NULL
|
||||
AND status != 'cancelled'
|
||||
LIMIT 1;
|
||||
|
||||
IF FOUND THEN
|
||||
RETURN NEXT v_existing;
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- Cria o novo registro
|
||||
INSERT INTO public.financial_records (
|
||||
tenant_id,
|
||||
owner_id,
|
||||
patient_id,
|
||||
agenda_evento_id,
|
||||
amount,
|
||||
discount_amount,
|
||||
final_amount,
|
||||
status,
|
||||
due_date
|
||||
) VALUES (
|
||||
p_tenant_id,
|
||||
p_owner_id,
|
||||
p_patient_id,
|
||||
p_agenda_evento_id,
|
||||
p_amount,
|
||||
0,
|
||||
p_amount,
|
||||
'pending',
|
||||
p_due_date
|
||||
)
|
||||
RETURNING * INTO v_new;
|
||||
|
||||
-- Marca o evento da agenda como billed = true
|
||||
UPDATE public.agenda_eventos
|
||||
SET billed = TRUE
|
||||
WHERE id = p_agenda_evento_id;
|
||||
|
||||
RETURN NEXT v_new;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMIT;
|
||||
@@ -106,10 +106,15 @@ async function fetchRecord() {
|
||||
|
||||
fetching.value = true;
|
||||
try {
|
||||
// Ignora records cancelados — permite que o user gere nova cobrança
|
||||
// após cancelar (caso comum: cancelou sem querer ou quer recobrar).
|
||||
// Sem esse filtro, o scenario ficava em 'com-cobranca' mostrando
|
||||
// o cancelado, e o botão "Gerar cobrança" sumia.
|
||||
const { data, error } = await supabase
|
||||
.from('financial_records')
|
||||
.select('id, amount, discount_amount, final_amount, status, due_date, paid_at, payment_method')
|
||||
.eq('agenda_evento_id', props.evento.id)
|
||||
.neq('status', 'cancelled')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
|
||||
@@ -924,7 +924,14 @@ const headerMainLabel = computed(() => {
|
||||
const name = selectedCommitment.value?.name || '';
|
||||
if (!name) return headerTitle.value;
|
||||
if (isEdit.value) {
|
||||
if (hasSerie.value) return `Sessão do Pacote · ${name}`;
|
||||
if (hasSerie.value) {
|
||||
// Evita "Sessão do Pacote · Sessão" (nome duplicado) — quando
|
||||
// o commitment chama 'Sessão' (default), mostra só "Sessão do
|
||||
// Pacote". Outros nomes (ex: Avaliação) entram normais.
|
||||
const lower = name.toLowerCase();
|
||||
if (lower === 'sessão' || lower === 'sessao') return 'Sessão do Pacote';
|
||||
return `Sessão do Pacote · ${name}`;
|
||||
}
|
||||
return `Editar ${name}`;
|
||||
}
|
||||
if (isPacote.value) {
|
||||
@@ -2303,20 +2310,6 @@ onBeforeUnmount(() => {
|
||||
</Message>
|
||||
</div>
|
||||
|
||||
<!-- Hint contextual abaixo do card Sessão / Honorários
|
||||
(fluxo principal Melissa, fora do occurrenceMode).
|
||||
Esclarece dúvidas comuns por tipo de cobrança sem
|
||||
poluir o form. Esconde quando há cobrança paga/
|
||||
pendente (lock-edit) — a Message do panel já cobre. -->
|
||||
<div v-if="isSessionEvent && !occFinancialRecord && billingType === 'convenio'" class="aed-billing-hint mb-3">
|
||||
<i class="pi pi-info-circle" />
|
||||
<span><b>Nº da guia é opcional</b> — você pode salvar a sessão e preencher depois, quando o convênio responder.</span>
|
||||
</div>
|
||||
<div v-else-if="isSessionEvent && !occFinancialRecord && billingType === 'gratuito'" class="aed-billing-hint mb-3">
|
||||
<i class="pi pi-gift" />
|
||||
<span>Sessão <b>gratuita</b> — nenhum lançamento será gerado no Financeiro.</span>
|
||||
</div>
|
||||
|
||||
<!-- ── FREQUÊNCIA (só sessão, sem série) ──────────── -->
|
||||
<div v-if="isSessionEvent && !hasSerie" class="field-card mb-4">
|
||||
<div class="field-card__header">
|
||||
@@ -2431,6 +2424,21 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hint contextual por tipo de cobrança — fullwidth, acima
|
||||
do card Extras. Estava antes dentro do aed-row-50 (grid
|
||||
50/50) e ocupava uma cell, empurrando Frequência pra
|
||||
baixo. Movido pra fora do row em 2026-05-19.
|
||||
Esconde quando há cobrança paga/pendente (lock-edit) —
|
||||
a Message do panel já cobre. -->
|
||||
<div v-if="isSessionEvent && !occFinancialRecord && billingType === 'convenio'" class="aed-billing-hint mb-4">
|
||||
<i class="pi pi-info-circle" />
|
||||
<span><b>Nº da guia é opcional</b> — você pode salvar a sessão e preencher depois, quando o convênio responder.</span>
|
||||
</div>
|
||||
<div v-else-if="isSessionEvent && !occFinancialRecord && billingType === 'gratuito'" class="aed-billing-hint mb-4">
|
||||
<i class="pi pi-gift" />
|
||||
<span>Sessão <b>gratuita</b> — nenhum lançamento será gerado no Financeiro.</span>
|
||||
</div>
|
||||
|
||||
<!-- ── EXTRAS (herdados do compromisso determinado) — último card.
|
||||
'notes' é caso especial: bindamos em form.observacoes pra
|
||||
manter compat com a coluna nativa agenda_eventos.observacoes
|
||||
@@ -4351,7 +4359,8 @@ onBeforeUnmount(() => {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.85rem;
|
||||
margin-bottom: 1rem;
|
||||
/* margin-bottom removido em 2026-05-19 (user request) — o espaçamento
|
||||
agora vem do mt do próximo .field-card.mb-4 (ver regra abaixo). */
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.aed-row-50 {
|
||||
@@ -4363,6 +4372,16 @@ onBeforeUnmount(() => {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
/* mt-4 em .field-card.mb-4 (user request 2026-05-19) — pra compensar
|
||||
a remoção do mb do .aed-row-50 e dar respiro vertical entre os
|
||||
cards do composer. Restrito aos wrappers do composer pra não
|
||||
afetar field-cards de outros lugares. */
|
||||
.composer-left .field-card.mb-4,
|
||||
.composer-right .field-card.mb-4,
|
||||
.composer-occurrence .field-card.mb-4 {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* ── side panel ─────────────────────────────────── */
|
||||
.composer-right {
|
||||
/* layout single-col agora — sticky removido (nao faz sentido) */
|
||||
|
||||
@@ -247,8 +247,16 @@ export function expandRules(rules, exceptions, rangeStart, rangeEnd) {
|
||||
if (exception) handledExIds.add(exception.id);
|
||||
|
||||
// ── exceção: cancela esta ocorrência ──
|
||||
if (exception?.type === 'cancel_session' || exception?.type === 'patient_missed' || exception?.type === 'therapist_canceled' || exception?.type === 'holiday_block') {
|
||||
// ainda inclui no calendário mas com status especial
|
||||
// cancel_session = cancel preemptivo (terapeuta apagou antes de
|
||||
// qualquer interação) → SOME da agenda (honra o "Ela some da
|
||||
// agenda" do diálogo de exclusão).
|
||||
if (exception?.type === 'cancel_session') {
|
||||
continue;
|
||||
}
|
||||
// Demais cancelamentos são HISTÓRICO (paciente faltou, terapeuta
|
||||
// cancelou após começar, feriado) → permanecem visíveis com
|
||||
// status especial pra ficar no registro.
|
||||
if (exception?.type === 'patient_missed' || exception?.type === 'therapist_canceled' || exception?.type === 'holiday_block') {
|
||||
occurrences.push(buildOccurrence(rule, date, iso, exception));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -35,8 +35,10 @@ const emit = defineEmits([
|
||||
'whatsapp',
|
||||
'historico',
|
||||
'delete-sessao', // botão "Excluir sessão" — só pra sessões avulsas (sem recorrência)
|
||||
'delete-series', // botão "Excluir série inteira" — hard delete da regra + materializadas + records pendentes
|
||||
'ver-lancamentos', // botão "Lançamentos" — abre dialog com financial_records vinculados
|
||||
'antecipar-pagamento' // botão "Antecipar pagamento" — paciente quer pagar antes da sessão (pacote saldo)
|
||||
'antecipar-pagamento', // botão "Antecipar pagamento" — paciente quer pagar antes da sessão (pacote saldo)
|
||||
'gerar-cobranca' // botão inline "Gerar fatura" ao lado de "A cobrar R$ X" — atalho sem precisar abrir dialog
|
||||
]);
|
||||
|
||||
// Regra atualizada (2026-05-14): botão Excluir aparece SEMPRE pra sessão.
|
||||
@@ -51,6 +53,27 @@ const canDelete = computed(() => {
|
||||
return true;
|
||||
});
|
||||
|
||||
// Mostra "Excluir série inteira" apenas pra eventos que pertencem a
|
||||
// uma regra de recorrência (materializados OU ocorrências virtuais).
|
||||
const isPartOfSeries = computed(() => {
|
||||
const e = ev.value;
|
||||
if (!e) return false;
|
||||
return !!(e.recurrence_id || e.serie_id);
|
||||
});
|
||||
|
||||
// Label "Pacote · X sessões" / "Série semanal" pro popover quando o
|
||||
// evento pertence a uma recorrência. Lê do _raw que carrega os campos
|
||||
// da regra (max_occurrences, frequency_type quando expandido).
|
||||
const seriesLabel = computed(() => {
|
||||
if (!isPartOfSeries.value) return null;
|
||||
const raw = ev.value._raw || {};
|
||||
const total = raw.max_occurrences || raw.qtd_sessoes || raw.series_total || null;
|
||||
const idx = raw.occurrence_index || raw.serie_index || null;
|
||||
if (total && idx) return `Sessão ${idx} de ${total}`;
|
||||
if (total) return `Pacote · ${total} sessões`;
|
||||
return 'Série recorrente';
|
||||
});
|
||||
|
||||
const ev = computed(() => props.evento || {});
|
||||
|
||||
const tipoLabel = computed(() => {
|
||||
@@ -105,7 +128,9 @@ const paymentIcon = computed(() => {
|
||||
});
|
||||
const paymentLabel = computed(() => {
|
||||
const state = ev.value.paymentState;
|
||||
const valor = ev.value.price;
|
||||
// Em sessão particular, valor mora em price. Em convênio, vai pra
|
||||
// insurance_value (price = null). Fallback cobre os dois casos.
|
||||
const valor = ev.value.price ?? ev.value.insurance_value;
|
||||
const valorFmt = (valor != null && !Number.isNaN(Number(valor)))
|
||||
? Number(valor).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
|
||||
: null;
|
||||
@@ -148,7 +173,10 @@ function modalidadeIcon(mod) {
|
||||
<div class="evento-head__main">
|
||||
<div class="evento-pill" :style="{ backgroundColor: ev.color }" />
|
||||
<div class="min-w-0">
|
||||
<div class="evento-tipo">{{ tipoLabel }}</div>
|
||||
<div class="evento-tipo">
|
||||
{{ tipoLabel }}
|
||||
<span v-if="seriesLabel" class="evento-tipo__series">· {{ seriesLabel }}</span>
|
||||
</div>
|
||||
<div class="evento-titulo">
|
||||
{{ isSessaoComPaciente ? ev.pacienteNome : (ev.label || ev.titulo || '—') }}
|
||||
</div>
|
||||
@@ -193,12 +221,38 @@ function modalidadeIcon(mod) {
|
||||
<i class="pi pi-trash" />
|
||||
<span>Excluir sessão</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="isPartOfSeries"
|
||||
type="button"
|
||||
class="evento-row__edit evento-row__edit--danger"
|
||||
:disabled="busy"
|
||||
v-tooltip.top="'Excluir a série inteira (todas as sessões da recorrência)'"
|
||||
@click="emit('delete-series')"
|
||||
>
|
||||
<i class="pi pi-history" />
|
||||
<span>Excluir série</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showPaymentRow" class="evento-row evento-row--pay" :class="`evento-row--pay-${paymentVariant}`">
|
||||
<i :class="paymentIcon" />
|
||||
<span>{{ paymentLabel }}</span>
|
||||
<!-- Atalho "Gerar fatura" — só pra sessão materializada
|
||||
com paymentState='none' (cobrança ainda não gerada).
|
||||
Pago/pendente já existe um record; nesses casos não
|
||||
cabe gerar de novo. -->
|
||||
<button
|
||||
v-if="paymentVariant === 'none' && !ev.is_occurrence"
|
||||
type="button"
|
||||
class="evento-row__pay-action"
|
||||
:disabled="busy"
|
||||
v-tooltip.top="'Gerar fatura agora'"
|
||||
@click="emit('gerar-cobranca')"
|
||||
>
|
||||
<i class="pi pi-plus" />
|
||||
<span>Gerar fatura</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="ev.modalidade" class="evento-row">
|
||||
@@ -415,6 +469,13 @@ function modalidadeIcon(mod) {
|
||||
letter-spacing: 0.2em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.evento-tipo__series {
|
||||
color: var(--p-primary-color);
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
font-weight: 500;
|
||||
margin-left: 4px;
|
||||
}
|
||||
.evento-titulo {
|
||||
color: var(--m-text);
|
||||
font-size: 1.1rem;
|
||||
@@ -501,6 +562,43 @@ html.app-dark .evento-row--pay-pending > i,
|
||||
html.app-dark .evento-row--pay-none > i {
|
||||
color: #fbbf24;
|
||||
}
|
||||
/* Atalho "Gerar fatura" — pill amber pequeno ao lado de "A cobrar R$ X".
|
||||
Aparece só pra paymentVariant='none' (sem cobrança ainda). Click emite
|
||||
'gerar-cobranca' pro parent que chama gerarCobrancaManual sem abrir
|
||||
o AgendaEventDialog. */
|
||||
.evento-row__pay-action {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, #f59e0b 16%, transparent);
|
||||
border: 1px solid color-mix(in srgb, #f59e0b 40%, transparent);
|
||||
color: #b45309;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
.evento-row__pay-action:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, #f59e0b 26%, transparent);
|
||||
border-color: color-mix(in srgb, #f59e0b 60%, transparent);
|
||||
}
|
||||
.evento-row__pay-action:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.evento-row__pay-action > i {
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
html.app-dark .evento-row__pay-action {
|
||||
color: #fbbf24;
|
||||
background: color-mix(in srgb, #fbbf24 14%, transparent);
|
||||
border-color: color-mix(in srgb, #fbbf24 30%, transparent);
|
||||
}
|
||||
|
||||
/* Stack de botões "Editar sessão" + "Excluir sessão" (Fase 5, 2026-05-14).
|
||||
Empilhados verticalmente à direita da linha das horas. */
|
||||
.evento-row__edit-stack {
|
||||
|
||||
@@ -688,6 +688,13 @@ onBeforeUnmount(() => {
|
||||
Ver boleto
|
||||
</button>
|
||||
</div>
|
||||
<!-- Convênio: aguarda fechamento mensal do plano —
|
||||
pill visual pra distinguir de cobrança particular
|
||||
direto ao paciente. 2026-05-19. -->
|
||||
<div v-else-if="data.payment_method === 'convenio'" class="mfl-row__pending-convenio">
|
||||
<i class="pi pi-id-card" />
|
||||
{{ paymentLabel(data.payment_method) }}
|
||||
</div>
|
||||
<Button
|
||||
label="Receber"
|
||||
icon="pi pi-check"
|
||||
@@ -1566,6 +1573,16 @@ onBeforeUnmount(() => {
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
/* Pill "Convênio" — visual distinto do Asaas (violeta) pra deixar claro
|
||||
que essa cobrança aguarda fechamento mensal do plano, não webhook. */
|
||||
.mfl-row__pending-convenio {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.74rem;
|
||||
color: rgb(124, 58, 237); /* violet-600 */
|
||||
font-weight: 500;
|
||||
}
|
||||
.mfl-row__pending-method {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -91,6 +91,7 @@ import { useMelissaWhatsapp } from './composables/useMelissaWhatsapp';
|
||||
import { useMelissaAgenda, MELISSA_AGENDA_KEY } from './composables/useMelissaAgenda';
|
||||
import { useMelissaDockPins } from './composables/useMelissaDockPins';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useAgendaFinanceiro } from '@/composables/useAgendaFinanceiro';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
|
||||
import ConversationDrawer from '@/components/conversations/ConversationDrawer.vue';
|
||||
@@ -613,6 +614,7 @@ const melissaAgendaRef = ref(null); // pra chamar openProntuario + setView
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const tenantStore = useTenantStore();
|
||||
const { gerarCobrancaManual } = useAgendaFinanceiro();
|
||||
const conversationDrawerStore = useConversationDrawerStore();
|
||||
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
@@ -957,13 +959,17 @@ async function onDeleteEvento() {
|
||||
eventoBusy.value = true;
|
||||
try {
|
||||
const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
|
||||
const { error } = await supabase.from('recurrence_exceptions').insert({
|
||||
// upsert pra ser idempotente: se já existe exception
|
||||
// pra (recurrence_id, original_date) — edição anterior,
|
||||
// conflito de criação, cancel duplicado — sobrescreve em
|
||||
// vez de quebrar com unique violation.
|
||||
const { error } = await supabase.from('recurrence_exceptions').upsert({
|
||||
recurrence_id: recId,
|
||||
tenant_id: tenantId,
|
||||
original_date: origDate,
|
||||
type: 'cancel_session',
|
||||
reason: 'Cancelado pelo terapeuta antes de qualquer interação'
|
||||
});
|
||||
}, { onConflict: 'recurrence_id,original_date' });
|
||||
if (error) throw error;
|
||||
toast.add({ severity: 'success', summary: 'Ocorrência cancelada', life: 2500 });
|
||||
M.refetch();
|
||||
@@ -1032,14 +1038,16 @@ async function onDeleteEvento() {
|
||||
const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
|
||||
const origDate = ev.recurrence_date || ev.original_date || (ev.inicio_em ? String(ev.inicio_em).slice(0, 10) : null);
|
||||
if (origDate) {
|
||||
const { error: exErr } = await supabase.from('recurrence_exceptions').insert({
|
||||
// upsert pra ser idempotente: se já existe exception
|
||||
// pra (recurrence_id, original_date), sobrescreve.
|
||||
const { error: exErr } = await supabase.from('recurrence_exceptions').upsert({
|
||||
recurrence_id: ev.recurrence_id || ev.serie_id,
|
||||
tenant_id: tenantId,
|
||||
original_date: origDate,
|
||||
type: 'cancel_session',
|
||||
reason: 'Cancelado pelo terapeuta'
|
||||
});
|
||||
if (exErr) console.warn('[Excluir] exception insert falhou:', exErr?.message);
|
||||
}, { onConflict: 'recurrence_id,original_date' });
|
||||
if (exErr) console.warn('[Excluir] exception upsert falhou:', exErr?.message);
|
||||
}
|
||||
}
|
||||
const { error } = await supabase.from('agenda_eventos').delete().eq('id', ev.id);
|
||||
@@ -1058,6 +1066,158 @@ async function onDeleteEvento() {
|
||||
});
|
||||
}
|
||||
|
||||
// Excluir SÉRIE INTEIRA — hard delete (escolha do user em 19/05).
|
||||
// Deleta recurrence_rules (CASCADE leva exceptions + recurrence_rule_services),
|
||||
// agenda_eventos materializados (linha por linha pra disparar handlers/triggers),
|
||||
// e financial_records pendentes ligados. Bloqueia se houver QUALQUER record pago
|
||||
// (precisa estornar pelo Financeiro primeiro).
|
||||
async function onDeleteSeries() {
|
||||
const ev = eventoSelecionado.value;
|
||||
if (!ev || eventoBusy.value) return;
|
||||
const ruleId = ev.recurrence_id || ev.serie_id;
|
||||
if (!ruleId) {
|
||||
toast.add({ severity: 'warn', summary: 'Sem série', detail: 'Este evento não pertence a uma recorrência.', life: 3000 });
|
||||
return;
|
||||
}
|
||||
|
||||
// Conta eventos materializados + records vinculados pra montar mensagem
|
||||
// e detectar paid blockers ANTES de confirmar.
|
||||
let materializedCount = 0;
|
||||
let recordsPending = 0;
|
||||
let hasPaid = false;
|
||||
try {
|
||||
const { data: evts } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('id')
|
||||
.eq('recurrence_id', ruleId);
|
||||
materializedCount = (evts || []).length;
|
||||
const evtIds = (evts || []).map((e) => e.id);
|
||||
if (evtIds.length) {
|
||||
const { data: recs } = await supabase
|
||||
.from('financial_records')
|
||||
.select('id, status')
|
||||
.in('agenda_evento_id', evtIds)
|
||||
.is('deleted_at', null);
|
||||
for (const r of recs || []) {
|
||||
if (r.status === 'paid') hasPaid = true;
|
||||
else recordsPending++;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[onDeleteSeries] contagem falhou:', e?.message);
|
||||
}
|
||||
|
||||
if (hasPaid) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Série com pagamento confirmado',
|
||||
detail: 'Uma ou mais sessões desta série têm cobrança paga. Estorne primeiro pelo Financeiro antes de excluir a série.',
|
||||
life: 6000
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const msgParts = [];
|
||||
msgParts.push(`Esta ação remove **toda a série de recorrência**:`);
|
||||
if (materializedCount > 0) msgParts.push(`${materializedCount} sessão(ões) já materializada(s)`);
|
||||
if (recordsPending > 0) msgParts.push(`${recordsPending} cobrança(s) pendente(s)`);
|
||||
msgParts.push('e a própria regra. As ocorrências futuras param de aparecer na agenda. **A ação não pode ser desfeita.** Confirmar?');
|
||||
|
||||
confirm.require({
|
||||
header: 'Excluir série inteira',
|
||||
message: msgParts.join(' '),
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Sim, excluir série',
|
||||
rejectLabel: 'Manter',
|
||||
acceptClass: 'p-button-danger',
|
||||
accept: async () => {
|
||||
eventoBusy.value = true;
|
||||
try {
|
||||
// 1) Apaga financial_records pendentes vinculados a eventos
|
||||
// materializados desta série
|
||||
const { data: evts2 } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('id')
|
||||
.eq('recurrence_id', ruleId);
|
||||
const evtIds = (evts2 || []).map((e) => e.id);
|
||||
if (evtIds.length) {
|
||||
const { error: recErr } = await supabase
|
||||
.from('financial_records')
|
||||
.delete()
|
||||
.in('agenda_evento_id', evtIds);
|
||||
if (recErr) throw recErr;
|
||||
}
|
||||
// 2) Apaga eventos materializados
|
||||
if (evtIds.length) {
|
||||
const { error: evErr } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.delete()
|
||||
.in('id', evtIds);
|
||||
if (evErr) throw evErr;
|
||||
}
|
||||
// 3) Apaga a regra (CASCADE: exceptions + rule_services)
|
||||
const { error: ruleErr } = await supabase
|
||||
.from('recurrence_rules')
|
||||
.delete()
|
||||
.eq('id', ruleId);
|
||||
if (ruleErr) throw ruleErr;
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Série excluída',
|
||||
detail: `Regra + ${materializedCount} sessão(ões)${recordsPending > 0 ? ` + ${recordsPending} cobrança(s)` : ''} removida(s).`,
|
||||
life: 3500
|
||||
});
|
||||
M.refetch();
|
||||
refetchEventosHoje();
|
||||
fecharEvento();
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao excluir série.', life: 5000 });
|
||||
} finally {
|
||||
eventoBusy.value = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Atalho do popover: gera cobrança da sessão atual via gerarCobrancaManual
|
||||
// sem precisar abrir o AgendaEventDialog. Mesmo RPC do botão "Gerar
|
||||
// cobrança" do AgendaEventoFinanceiroPanel. Só vale pra sessão real
|
||||
// (eventoSelecionado.id existe + não é virtual). Apos sucesso refetch
|
||||
// pra badge $ aparecer (paymentState='pending').
|
||||
async function onGerarCobrancaQuick() {
|
||||
const ev = eventoSelecionado.value;
|
||||
if (!ev || eventoBusy.value) return;
|
||||
if (!ev.id || ev.is_occurrence) {
|
||||
toast.add({ severity: 'warn', summary: 'Não disponível', detail: 'Esta ocorrência ainda não está materializada.', life: 3500 });
|
||||
return;
|
||||
}
|
||||
eventoBusy.value = true;
|
||||
try {
|
||||
// gerarCobrancaManual le evento.price; passamos a row crua via _raw
|
||||
// que ja inclui price/insurance_value/billing_contract_id.
|
||||
const eventoRaw = ev._raw || ev;
|
||||
const result = await gerarCobrancaManual(eventoRaw);
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
const valor = eventoRaw.price ?? eventoRaw.insurance_value ?? 0;
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Cobrança gerada',
|
||||
detail: `R$ ${Number(valor).toFixed(2).replace('.', ',')} agendado para recebimento.`,
|
||||
life: 3000
|
||||
});
|
||||
M.refetch();
|
||||
refetchEventosHoje();
|
||||
// Fecha o popover apos sucesso pra impedir click duplicado
|
||||
// gerando outra fatura. User reabre se quiser ver estado novo.
|
||||
fecharEvento();
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao gerar cobrança.', life: 4000 });
|
||||
} finally {
|
||||
eventoBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onWhatsapp() {
|
||||
const ev = eventoSelecionado.value;
|
||||
if (!ev?.patient_id) {
|
||||
@@ -2060,6 +2220,8 @@ function onKeydown(e) {
|
||||
@remarcar="onRemarcar"
|
||||
@edit-sessao="onEditEvento"
|
||||
@delete-sessao="onDeleteEvento"
|
||||
@delete-series="onDeleteSeries"
|
||||
@gerar-cobranca="onGerarCobrancaQuick"
|
||||
@ver-lancamentos="onVerLancamentos"
|
||||
@antecipar-pagamento="onAnteciparPagamento"
|
||||
@edit-paciente="onEditPaciente"
|
||||
|
||||
@@ -152,6 +152,10 @@ function normalizeForMelissa(r, paymentStateMap = null) {
|
||||
recurrence_date: r.recurrence_date ?? r.original_date ?? null,
|
||||
paymentState,
|
||||
price: r.price != null ? Number(r.price) : null,
|
||||
// insurance_value: pra convênio, o valor cobrado mora aqui (não em
|
||||
// price). Popover e Resumo usam fallback `price ?? insurance_value`
|
||||
// pra mostrar o valor da cobrança independente do tipo.
|
||||
insurance_value: r.insurance_value != null ? Number(r.insurance_value) : null,
|
||||
_raw: r // ← consumido pelo MelissaLayout pra popular dialogEventRow
|
||||
};
|
||||
}
|
||||
@@ -459,10 +463,14 @@ export function useMelissaAgenda() {
|
||||
// resolver (default 'none').
|
||||
const realIds = (rows.value || []).map((r) => r.id).filter(Boolean);
|
||||
if (realIds.length) {
|
||||
// Filtra cancelados: cobrança cancelada não deve manter
|
||||
// paymentState='pending' (badge $ residual). Tratamos cancelled
|
||||
// como "sem cobrança ativa" → cai pro default 'none'.
|
||||
const { data: recs } = await supabase
|
||||
.from('financial_records')
|
||||
.select('agenda_evento_id, paid_at')
|
||||
.in('agenda_evento_id', realIds);
|
||||
.select('agenda_evento_id, paid_at, status')
|
||||
.in('agenda_evento_id', realIds)
|
||||
.neq('status', 'cancelled');
|
||||
const map = {};
|
||||
for (const id of realIds) map[id] = 'none';
|
||||
for (const rec of recs || []) {
|
||||
@@ -1551,6 +1559,53 @@ function _buildOnDialogSave(deps) {
|
||||
}
|
||||
}
|
||||
|
||||
// chargeMode='none' (C6 doc): materializa a 1ª ocorrência
|
||||
// sem criar financial_record. As demais ficam virtuais.
|
||||
// Honra: "1ª materializada com badge $ a cobrar; outras 3
|
||||
// virtuais expandidas em runtime, limpas até interação".
|
||||
// package/per_session ja materializam dentro dos proprios
|
||||
// helpers; package 'saldo' intencionalmente NÃO materializa
|
||||
// (doc C8 — modelo Cliniko, todas virtuais ate interacao).
|
||||
// IMPORTANTE: agendaRepository.createAgendaEvento dropa
|
||||
// 'paciente_id' (campo legado). USAR 'patient_id' (English),
|
||||
// que é o nome real da coluna em agenda_eventos.
|
||||
const _patientIdForFirst = normalized.patient_id ?? normalized.paciente_id ?? null;
|
||||
if (createdRule?.id && recChargeMode === 'none' && _patientIdForFirst) {
|
||||
try {
|
||||
const durMin = createdRule.duration_min || 50;
|
||||
const [hh, mm] = String(createdRule.start_time || '00:00:00').slice(0, 5).split(':').map(Number);
|
||||
const firstISO = createdRule.start_date;
|
||||
const startDt = new Date(`${firstISO}T00:00:00`);
|
||||
startDt.setHours(hh, mm, 0, 0);
|
||||
const endDt = new Date(startDt.getTime() + durMin * 60 * 1000);
|
||||
await create({
|
||||
owner_id: createdRule.owner_id,
|
||||
tenant_id: clinicId,
|
||||
terapeuta_id: createdRule.therapist_id ?? null,
|
||||
recurrence_id: createdRule.id,
|
||||
recurrence_date: firstISO,
|
||||
tipo: normalized.tipo || 'sessao',
|
||||
status: normalized.status || 'agendado',
|
||||
titulo: normalized.titulo || 'Sessão',
|
||||
inicio_em: startDt.toISOString(),
|
||||
fim_em: endDt.toISOString(),
|
||||
patient_id: _patientIdForFirst,
|
||||
determined_commitment_id: normalized.determined_commitment_id ?? null,
|
||||
modalidade: normalized.modalidade ?? 'presencial',
|
||||
observacoes: normalized.observacoes ?? null,
|
||||
extra_fields: normalized.extra_fields ?? null,
|
||||
price: normalized.price ?? null,
|
||||
insurance_plan_id: normalized.insurance_plan_id ?? null,
|
||||
insurance_guide_number: normalized.insurance_guide_number ?? null,
|
||||
insurance_value: normalized.insurance_value ?? null,
|
||||
insurance_plan_service_id: normalized.insurance_plan_service_id ?? null,
|
||||
visibility_scope: normalized.visibility_scope || 'public'
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[useMelissaAgenda] materializa 1ª (chargeMode=none) falhou:', e?.message);
|
||||
}
|
||||
}
|
||||
|
||||
const detail = recorrencia.qtdSessoes ? `${recorrencia.qtdSessoes} sessões criadas` : 'Série recorrente criada';
|
||||
toast.add({ severity: 'success', summary: 'Série criada', detail, life: 3000 });
|
||||
if (chargeInfo?.toast) toast.add(chargeInfo.toast);
|
||||
@@ -1790,7 +1845,15 @@ function _buildOnDialogSave(deps) {
|
||||
// status='paid' apenas quando markPaidNow && method !== 'link'.
|
||||
if (arg?.chargeMode === 'session' && createdEventId) {
|
||||
try {
|
||||
const amount = normalized.price ?? 0;
|
||||
// Convênio: valor vem em insurance_value (não price);
|
||||
// payment_method fixo em 'convenio' (não asaas/pix/etc) —
|
||||
// o plano paga a clínica no fechamento mensal, nunca via
|
||||
// gateway ou no caixa. markPaidNow ignorado pra convênio
|
||||
// (record sempre nasce pending até a baixa via Financeiro).
|
||||
const isConvenio = !!normalized.insurance_plan_id;
|
||||
const amount = isConvenio
|
||||
? (normalized.insurance_value ?? 0)
|
||||
: (normalized.price ?? 0);
|
||||
const dueDate = normalized.inicio_em
|
||||
? new Date(normalized.inicio_em).toISOString().slice(0, 10)
|
||||
: new Date().toISOString().slice(0, 10);
|
||||
@@ -1805,12 +1868,16 @@ function _buildOnDialogSave(deps) {
|
||||
if (cobErr) throw cobErr;
|
||||
|
||||
// Pós-RPC: ajusta payment_method (sempre) e status (só se
|
||||
// markPaidNow=true e método direto).
|
||||
// markPaidNow=true e método direto). Convênio força method
|
||||
// = 'convenio' e ignora markPaidNow.
|
||||
// convenio → payment_method='convenio', status=pending
|
||||
// method='link' → payment_method='asaas', status=pending
|
||||
// method=pix/etc + markPaidNow=false → payment_method=<>, status=pending
|
||||
// method=pix/etc + markPaidNow=true → payment_method=<>, status='paid', paid_at=now()
|
||||
const method = arg?.paymentMethod || 'link';
|
||||
const paidNow = arg?.markPaidNow === true && method !== 'link';
|
||||
const method = isConvenio
|
||||
? 'convenio'
|
||||
: (arg?.paymentMethod || 'link');
|
||||
const paidNow = !isConvenio && arg?.markPaidNow === true && method !== 'link';
|
||||
try {
|
||||
const { data: recRow } = await supabase
|
||||
.from('financial_records')
|
||||
@@ -1839,14 +1906,15 @@ function _buildOnDialogSave(deps) {
|
||||
pix: 'PIX',
|
||||
dinheiro: 'dinheiro',
|
||||
deposito: 'depósito',
|
||||
cartao_maquininha: 'cartão (maquininha)'
|
||||
cartao_maquininha: 'cartão (maquininha)',
|
||||
convenio: 'convênio'
|
||||
}[method] || null;
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: paidNow ? 'Cobrança paga' : 'Cobrança gerada',
|
||||
detail: paidNow
|
||||
? `R$ ${Number(amount).toFixed(2).replace('.', ',')} recebido via ${methodLabel}.`
|
||||
: `R$ ${Number(amount).toFixed(2).replace('.', ',')} com vencimento em ${dueDate.split('-').reverse().join('/')}.`,
|
||||
: `R$ ${Number(amount).toFixed(2).replace('.', ',')} com vencimento em ${dueDate.split('-').reverse().join('/')}${methodLabel ? ` (${methodLabel})` : ''}.`,
|
||||
life: 3500
|
||||
});
|
||||
} catch (e) {
|
||||
|
||||
Reference in New Issue
Block a user