29 Commits

Author SHA1 Message Date
Leonardo 3ce22dd236 wiki log: sessoes noturnas M1-M6 + Fase 2 + Asaas Fase A + CFP
Registra cronologia da leva noturna 20/05 evening -> 21/05 01:06:
Fase 0+0.5 sweep foundation, M1 Home/Components, M2 Pacientes batch,
M3+M4+M5+M6 foundation em batch, M5 quick wins, Fase 2 Graphify
hotspots, Asaas Gateway Tier 1 Fase A, Compliance CFP #5/#8/#9.
8 entradas no log.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:21:11 -03:00
Leonardo cd67f7e9f5 compliance CFP: #5 registro profissional + #9 especialidades
ROADMAP Fase 1.2 (Compliance basico BR). Item #5: profiles ganha
3 colunas (professional_registration_type/number/uf) com CHECK
constraint dos conselhos comuns (CRP, CRM, CRFa, CREFITO, CRESS,
CRN, RMS, outro). Item #9: catalogo public.specialties + join
M:N profile_specialties + RLS. Seed seed_050 popula 33
especialidades is_system=true (clinica, jurídica, neuropsicologia,
ABA, TCC, psicanalise etc). Service specialtiesService.js no
src/services pra consumo na UI.

Item #8 (nome social) ja estava integrado. #6 (consent forms UI)
e #7 (assinatura no portal) adiados — schemas document_templates
e document_signatures existem, falta workflow UI dedicado.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:21:03 -03:00
Leonardo de3898878a asaas: Tier 1 Fase A foundation — migrations + service + edge function stubs
DESIGN_ASAAS_GATEWAY.md documenta arquitetura. Schema novo: 2
migrations (tables + RLS) cobrindo asaas_customers + asaas_payments
+ asaas_webhook_events. Client service asaasGatewayService.js no
features/financeiro. 3 Edge Function stubs (create-payment-record,
cancel-payment, sync-payment) — webhook financial_records eh Fase B.

Bloqueadores Fase B (implementacao real): user precisa criar conta
Asaas, gerar API keys, configurar webhook, setar ENV vars no
Supabase. Decisao modelo de negocio (A/B/C) tambem pendente.
Stops marcados claramente no DESIGN.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:20:52 -03:00
Leonardo ee2967a075 M6: notices/conversations foundation — selects + repositories
Modulo 6 da Fase 1. noticesSelects.js extrai os 2 selects do
noticeService (GLOBAL_NOTICE_SELECT, NOTICE_DISMISSAL_SELECT) +
noticeService passa a usa-los (zero select inline). Conversations
ganha foundation: 3 services (_tenantGuards, conversationsSelects,
conversationsRepository). Channel factory (WhatsApp/SMS/Email) e
composables ficam pra sessao dedicada — escopo M6 era so destravar
o supabase.from() inline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:20:42 -03:00
Leonardo 0956e4facc M5: tenantship + admin members + accept_invite RPC
Modulo 5 da Fase 1 + quick wins fechados. features/tenantship/ com
2 services + 2 composables (members + invites). MembersPage.vue
nova em views/pages/admin/ + rota /admin/members em routes.clinic.
Migration 20260520000005 cria RPC accept_tenant_invite (SECURITY
DEFINER + lock FOR UPDATE) — tenantInvitesRepository.acceptInvite
agora chama RPC real (nao mais stub). SaasTenantFeaturesPage
refatorada pra usar novo tenantFeatureAdminService. SetupWizardPage
2648 linhas deferido pra sessao dedicada.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:20:33 -03:00
Leonardo fbfb95648e M4: financeiro foundation — services + composables paralelo
Modulo 4 da Fase 1. 9 arquivos novos em features/financeiro/:
4 services (_tenantGuards, financialSelects, financialRecords
Repository, financialExceptionsRepository, billingContractsRepository)
+ 4 composables (useFinancialRecords, useFinancialExceptions,
useBillingContracts, useBillingOrchestrator). Old composables ainda
em paralelo — Fase C (cutover) bloqueada pelas decisoes #2/#3/#6
de billing (memoria agenda_billing_decisoes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:20:23 -03:00
Leonardo 388e9a4186 M3: prontuario foundation — repositories + composables clinical_notes
Modulo 3 da Fase 1. 6 arquivos novos em features/patients/prontuario/:
services (_tenantGuards, clinicalNotesSelects, clinicalNotesRepository,
clinicalNoteTemplatesRepository) + composables (useClinicalNotes,
useClinicalNoteTemplates). Ativa quando migrations 0.5.B (clinical_notes
tables/rls/versioning + documents link) forem aplicadas no banco.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:20:15 -03:00
Leonardo 1c2a2b6e19 M2: patients — selects + repository + 8 composables refatorados
Modulo 2 da Fase 1 de padronizacao em batch unico. patientsSelects.js
nova com 11 constantes de select. patientsRepository.js estendido com
~15 funcoes novas (markIntakeConverted, list/get/update por
contexto, etc). 8 composables refatorados em paralelo (usePatients,
useDetail, Financial, Sessions, Messages, Documents, Recurrences,
SupportContacts) — zero supabase.from() em qualquer composable de
patients. _lastPatientId movido pra DENTRO das functions nos 3
composables que tinham. CadastrosRecebidosPage + MelissaCadastros
Recebidos pegam carona dos selects. Aguarda teste batch consolidado.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:20:08 -03:00
Leonardo 27467bbb68 M1: features/medicos + features/insurance + ComponentCadastroRapido refactor
Modulo 1 da Fase 1 de padronizacao. Novos features/medicos (services
+ composable useMedicos) e features/insurance (idem). 3 cadastros
rapidos (medicos, convenios, ComponentCadastroRapido + Insurance
PlanQuickCreateDialog) migrados pra usar os composables novos —
zero supabase.from() em UI components. TEST_ACCOUNTS extraido pra
src/config/devTestAccounts.js. Topbar ganhou switcher de layout
+ atalhos M1 via novo useTopbarDevMenuExtras. M1.6 MelissaLayout
90 imports deferida pra sessao dedicada (memoria padronizacao_sweep).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:19:57 -03:00
Leonardo f94a4ae97f padronizacao: foundation Fase 0+0.5 — blueprints + auditoria + clinical_notes
Pre-MVP: 3 blueprints canonicos (repository, composable, quick-create
overlay), AUDIT_BASELINE com 51 divergencias em 6 modulos, estrategia
PADRONIZACAO de 4 fases, DESIGN_BILLING_ORCHESTRATOR. Schema clinical
notes pronto pra Fase B (4 migrations + seed templates). AgendaEvent
Dialog.vue.bak deletado (lixo de refator anterior).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:19:45 -03:00
Leonardo 5b345c5598 relatorios: analise senior do modulo agenda pos C1-C13
Relatorio standalone HTML (com print CSS otimizado pra PDF export):
- 10 paginas estruturadas
- Sumario executivo + metricas + pontos fortes
- 10 codes smells / dividas tecnicas detalhadas
- 8 issues de UX
- 7 riscos arquiteturais
- 15 recomendacoes priorizadas (P0-P3) com esforco e impacto
- Roadmap proposto em 3 horizontes
- Apendices: 14 bugs do dia, pendencias, commits, status dos cenarios

Visao senior eng: arquitetura solida em conceito, divida tecnica
em execucao. Top 5 achados:
1. 3 hotspots >2.8k LOC cada (AgendaEventDialog 6k, MelissaLayout 4.3k)
2. Logica de status change triplicada (Melissa/Rail/Clinica)
3. billing_contracts.updated_at gotcha
4. Snapshot stale popover (mitigado mas estrutural)
5. Audit trail acumulando ruido

Recomendacao chave: extrair status change orchestrator pra composable
shared ANTES da replicacao Rail/Clinica. Senao replica os mesmos
14 bugs vezes 2.

Para PDF: abrir relatorios/RELATORIO-AGENDA-2026-05-20.html no
browser e Ctrl+P -> Salvar como PDF.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 16:22:25 -03:00
Leonardo 4da0bc2e11 HANDOFF + log: C12 deferred (UX iterar) · testando C13
C12 fluxo critico OK no DB (antecipar/revogar/re-antecipar/realizada
detecta paid). 5 bugs corrigidos no caminho: re-antecipar nao reusa
cancelled, popover watch sync com lookup virtual->materializada,
normalizeForMelissa expoe owner_id, etc.

User adiou C12 pra iterar UX depois (pos-Rail/Clinica). Salvo em
memoria project_c12_antecipar_iterar.

C13 prep: lock "edit cobrada" ja implementado na Fase 6 (commit
1feb711). User vai validar visualmente com Joao Almeida ou Andre.

14 commits no dia. Pendencias documentadas. Working tree limpo
exceto HANDOFF/log (este commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:34:58 -03:00
Leonardo f83315baba agenda: popover watch acompanha transicao virtual->materializada
Bug do "Usar sumiu apos revogar antecipacao": o watch sincronizava
eventoSelecionado por id, mas quando virtual era materializada
(antecipar/Usar/Realizada flow) o id mudava de rec::rule::date
pra uuid real. Watch nao achava match -> popover ficava preso na
versao virtual stale -> botoes refletiam estado antigo.

Fix: lookup em 2 etapas:
1) match por id (caso comum)
2) match por recurrence_id+recurrence_date quando nao acha (caso
   virtual->materializada). Pega a versao real correspondente
   aquela data.

Estado final do teste C12 do user: status=realizado, saldo 3/4,
1 pending + 5 cancelled (audit trail de varios ciclos antecipar/
revogar). Funcionalmente OK; com o fix, retestes ficam mais limpos.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:23:18 -03:00
Leonardo 7d2a405d05 agenda: normalizeForMelissa expoe owner_id/tenant_id/contract_id
Bug introduzido pelo watch sync do popover (commit b5e00a7).
Apos o sync com eventos computed, eventoSelecionado.value ficava
com apenas os campos do normalizeForMelissa return. owner_id,
tenant_id, terapeuta_id, billing_contract_id NAO estavam expostos
no normalize -> sumiam apos refresh. onAnteciparPagamento entao
mandava owner_id=null pro RPC create_financial_record_for_session
-> "null value in column owner_id violates not-null constraint".

Fix:
- normalizeForMelissa agora expoe owner_id, tenant_id,
  terapeuta_id, billing_contract_id explicitos no return
- onAnteciparPagamento ganhou fallback robusto: ev.owner_id ||
  ev._raw?.owner_id || M.ownerId.value, com throw explicito se
  nada disponivel (em vez de mandar null pro RPC)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:10:51 -03:00
Leonardo b5e00a7022 agenda: popover sincroniza com eventos + antecipar nao reusa cancelled
Dois bugs descobertos no ciclo C12 antecipar -> revogar -> reantecipar:

1) confirmAnteciparPagamento reusava record cancelled:
   Quando user revogava (vira cancelled) e antecipava de novo,
   o existRec query pegava o cancelled e UPDATE-ava pra paid no
   MESMO record id. Resultado: notes mantinham historico
   "Cancelada via reversao" + "Antecipacao revogada" + record
   reativo como paid, confuso pra audit trail. Fix: filtrar
   .neq('status', 'cancelled') na busca de existRec — agora a
   re-antecipacao via RPC cria record fresh.

2) Popover snapshot stale (pendencia documentada em
   project_melissa_popover_snapshot, antecipada pra agora):
   eventoSelecionado.value era snapshot do clique e nao acompanhava
   updates do _paymentStateMap pos M.refetch. User antecipava, o
   record paid era criado, mas o popover continuava com paymentState
   antigo -> botao continuava "Antecipar pagamento" em vez de
   alternar pra "Revogar pagamento". Fix: watch em M.eventos sincroniza
   eventoSelecionado com a versao fresh quando id bate. flush:'post'
   pra rodar apos o computed reagir.

Como o popover agora atualiza in-place, removido fecharEvento() de
confirmAnteciparPagamento e onRevogarAntecipacao — o user pode ver
o botao alternar live em vez de precisar reabrir o popover.

Cleanup do estado do Andre: deletado record orfa 3a4c79e0 pra reset
do teste C12.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:07:11 -03:00
Leonardo 272c804335 agenda: revogar antecipacao de pagamento
UX gap descoberto durante teste C12: apos antecipar pagamento,
nao havia caminho via popover pra desfazer caso user tenha
errado (paciente nao pagou, errou o valor, etc).

Implementacao:
- Botao "Antecipar pagamento" agora alterna pra "Revogar
  pagamento" (vermelho, --danger) quando isAntecipacaoAtiva
  (status=agendado + paymentState=paid)
- Handler onRevogarAntecipacao em MelissaLayout: ConfirmDialog
  vermelho + cancela record paid + nota de auditoria em notes
  ("[YYYY-MM-DD] Antecipacao revogada em ...") + refetch
- Apos revogar, botao volta pra "Antecipar pagamento" — user
  pode antecipar de novo com valor/metodo corretos

Limites: so disponivel em status='agendado'. Apos Realizada o
paid representa pagamento real da sessao realizada, nao
antecipacao — estorno deve ir pelo /financeiro.

Sobre "Usar" desaparecer apos antecipar (questao do user): comportamento
correto. "Usar" cria record+consome saldo — duplicaria com paid
existente. Apos antecipar, fluxo correto e clicar Realizada (que
detecta paid pre-existente via fix anterior 00c4168).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 14:28:04 -03:00
Leonardo 00c4168393 agenda: C12 prep — detectar paid pre-existente em pacote saldo realizada
Preparacao pra teste C12 (antecipar pagamento). Fluxo:
1. User clica "Antecipar pagamento" em virtual futura -> cria
   record paid R$ X sem consumir saldo
2. Depois marca a sessao como Realizada -> dialog deve detectar
   o paid + so consumir saldo (NAO criar record novo, evitar
   duplicidade)

Sem esse fix, marcar Realizada apos antecipar abriria o dialog
"Gerar cobranca?" com default true, gerando record novo duplicado.

Implementacao:
- _loadStatusChangeContext: carrega ctx.existingPaidRecord (qualquer
  paid linkado ao evento, n=1)
- Dialog: nova prop existingPaidRecord + computed showAlreadyPaid
  (substitui showCobrancaPacote quando paid existe)
- Template: bloco "Sessao ja paga via antecipacao" com info do
  pagamento + preview do consumo de saldo
- _applyStatusDecisions: novo branch 4-pre roda ANTES do generatePackageCharge:
  se realizado+pacote saldo+paid existe, roda tasks pendentes (1b
  amarra) + incrementa saldo sem criar record. Return cedo.

Backfill: Andre 10/06 voltou pra agendado + saldo 2/4 (estado limpo
pra testar C12 com a sessao 10/06 antecipando).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 12:28:19 -03:00
Leonardo 9ead3fdc42 HANDOFF + log: C11 fechado · 4/4 sub-testes OK · proximo C12
Cenario 11 completo. 5 bugs descobertos+corrigidos durante
a bateria (UI confusa, gotcha billing_contracts.updated_at,
reverse transitions, lock sessao encerrada, label/badge
pacote-aware). Reverse trava antecipada de pos-C13 pra ja
(user hit pra valer).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 12:23:51 -03:00
Leonardo 5965b05378 agenda: link universal pacote + refresh saldo no reverse
Dois bugs descobertos durante C11/C+D:

1) Faltou+multa SEM consumeSaldo nao amarrava billing_contract_id
no agenda_evento (so amarrava se consumeSaldo=true). Resultado:
sessao 27/05 do Andre faltou+multa-sem-consume ficou sem rastro
do contrato no DB. Reverse posterior nao detectaria saldoConsumed.
Fix: bloco 1b) universal — sempre amarra quando forward (realizado/
faltou/cancelado) + tem contract + eventoId. Cobre todos os
combos (multa-sem-consume, multa-com-consume, generatePackageCharge,
consumeSaldo solo).

2) Reverse decrementar saldo as vezes nao persistia. Suspeita: race
com ctx.billingContract.sessions_used stale do _loadStatusChangeContext
quando flows rapidos sequenciais (Realizada+gerar -> Agendada
imediato). Fix: refetch FRESH do billing_contracts.sessions_used
direto do DB ANTES de calcular newUsed. Mais robusto contra qualquer
race condition. Adicionado console.log pra futura debug.

Removida duplicidade do amarra-billing_contract_id no bloco
consumeSaldo (universal cobre).

Backfill Andre Green: 27/05 amarrado, saldo voltou pra 2/4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 12:21:04 -03:00
Leonardo 45984e885b agenda: label + badge cientes de pacote em sessoes state=none
Bug descoberto durante teste C11: sessao 27/05 do Andre Green
(materializada via Falta+reverse, pertence ao pacote saldo)
mostrava "A cobrar R$ 40,00" no popover mesmo sem fatura ativa.
Implicava que dava pra gerar cobranca avulsa solta — conflito
com o flow do pacote (Usar consume saldo).

Fix em paymentLabel: quando state='none' e ev.contract existe,
label muda conforme estilo:
- saldo: "Aguardando uso do pacote"
- upfront: "Coberta pelo pacote (upfront)"
Avulsa sem pacote continua mostrando "A cobrar R$ X".

Simetria em MelissaAgenda.vue badge gate: nao mostra badge $ amber
em sessao state=none com pacote amarrado (hasPacoteTied). Sem
isso, sessao agendada de pacote saldo no calendar ficava com
badge "cobranca pendente" enganoso.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 12:09:45 -03:00
Leonardo 3f3f2acc70 agenda: consumeSaldo agora amarra billing_contract_id no evento
Bug em cascata descoberto durante C11/B com Andre Green:
- User clicou Falta + Descontar (consumeSaldo) -> sessions_used: 1->2
- billing_contract_id do agenda_evento ficou NULL (omissao no flow)
- User clicou Agendada (reverse) -> detector saldoConsumed em
  _loadStatusChangeContext checa evRow.billing_contract_id, que esta
  NULL -> saldoConsumed=false -> bloco "Devolver saldo" NAO aparece
  no dialog -> saldo NAO devolvido
- Next Falta mostra "Descontar 2 para 3" em vez de "1 para 2"

Fix: bloco consumeSaldo agora tambem amarra billing_contract_id no
agenda_eventos. Replica o padrao que ja existe no generatePackageCharge
e no onUsarSessao. Sem isso, qualquer reverse pos-consumeSaldo nao
detecta o saldo consumido.

Backfill manual do Andre: sessions_used voltou pra 1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:50:26 -03:00
Leonardo 5684297243 agenda: reverse transition trava (Agendada apos artefatos)
User hit pra valer a pendencia documentada (reverter
realizado/faltou/cancelado pra agendado deixa records/saldo
orfaos). Decidido implementar trava AGORA em vez de pos-C13.

Quando user clica "Agendada" no popover/dialog em sessao que
tem artefatos pendentes (cobranca pending, multa, saldo consumido
em pacote saldo), abre o AgendaStatusChangeConfirmDialog com nova
variante "reverse":

1. Lista records pending vinculados (descricao + valor) com radio
   [Cancelar (recomendado) | Manter ativa]
2. Warning textual pra records PAID (estorno e manual pelo
   Financeiro — sem radio, so info)
3. Saldo consumido (pacote saldo): radio [Devolver 1 sessao | Manter]

No confirm:
- Cancela records pending escolhidos (status='cancelled' + notes
  de auditoria)
- Decrementa sessions_used + reativa contract se estava completed
- Desamarra billing_contract_id do evento se devolveu saldo
- Status muda pra agendado (ja foi aplicado pelo _applyStatusUpdateOnly)

Se nao tem artefato algum (sessao agendado -> agendado, ou
realizado sem records): aplica direto sem dialog (existing
behavior via _needsConfirmDialog).

_loadStatusChangeContext agora carrega reverseArtifacts (status
anterior, records ativos, saldoConsumed) quando novoStatus=agendado.

Memoria project_agenda_reverse_transitions atualizada — pendencia
fechada antes da hora.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:40:19 -03:00
Leonardo 16dfa02bd1 agenda pacote saldo: fix root cause + sequential awaits
ROOT CAUSE DESCOBERTO durante C11/A com Andre Green:
billing_contracts NAO tem coluna updated_at. UPDATEs em
_applyStatusDecisions passavam updated_at -> Postgres retornava
"column updated_at does not exist" -> Promise.allSettled engolia
como {status: 'rejected'} silencioso -> toast warn generico que
user nao percebia. Resultado: sessions_used nunca incrementava.

Bug existia em DOIS lugares:
1. consumeSaldo block (faltou/cancelado pacote saldo) - afetaria
   C11/B, C11/C, C11/D
2. generatePackageCharge block (realizado pacote saldo) - afetou
   C11/A

Em ambos: removido updated_at do patch (.update({...})).

ADICIONAL: generatePackageCharge refatorado pra usar AWAITS
SEQUENCIAIS (igual onUsarSessao do MelissaLayout que sempre
funcionou):
- 4a) UPDATE agenda_eventos.billing_contract_id (faltava!)
- 4b) RPC create_financial_record_for_session
- 4c) UPDATE billing_contracts.sessions_used + status=completed

Cada step com try/catch + console.error + toast distinto. Sem mais
falhas escondidas em Promise.allSettled paralelo.

Backfill manual do estado do Andre Green: evento 6e70476f agora
amarrado ao contract 691118da com sessions_used=1.

Memoria nova: project_billing_contracts_no_updated_at.md pra evitar
o gotcha no futuro.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:25:04 -03:00
Leonardo 079509e001 agenda: dialog pacote saldo realizada — 2 sub-questions claras
Antes (UX confusa): bloco "Gerar cobranca no pacote?" tinha so um
Select "Como cobrar?" com options mixadas:
- "Enviar link de pagamento (Asaas)"
- "Ja recebi - PIX"
- "Ja recebi - Dinheiro"
- etc

User selecionou "Ja recebi - PIX" pensando que era "cobrar via PIX"
durante teste C11/A com Andre Green. Resultado: fatura virou paid
sem o user ter recebido de verdade. Ambiguidade entre "como cobrar"
(header) e "ja recebi" (options).

Refactor: espelhar o padrao da avulsa (showRegistrarPagto):
1. Sub-question "A sessao ja foi paga?" radio Sim/Nao (default Nao)
2. Se Nao -> Select "Como vai cobrar?" [Apenas registrar pendente |
   Enviar link de pagamento (Asaas)]
3. Se Sim -> Select "Como recebeu?" [PIX | Dinheiro | Deposito |
   Maquininha] (sem prefixo "Ja recebi" — header ja deixa claro)

Defaults safer: markPaid=false em ambos contextos (avulsa e pacote)
pra evitar marcar paid sem querer. paymentMethod='pending' inicial.

Handler em useMelissaAgenda._applyStatusDecisions: pos-processamento
agora usa decision.markPaid explicito no caso pacote saldo:
- markPaid=true -> record vira paid + payment_method=X
- markPaid=false + paymentMethod='link' -> pending + payment_method='asaas'
- markPaid=false + paymentMethod='pending' -> pending sem metodo

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 11:11:28 -03:00
Leonardo 7dc7dcede0 wiki: log session C10 fechado completo
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:36:02 -03:00
Leonardo 1e74a115de HANDOFF: C10 fechado · 5/5 sub-testes OK · proximo C11
Cenario 10 (status change avulsa) completo:
- A: Realizada sem markPaid (record pending preservado)
- A2: Realizada + markPaid maquininha (paid + paid_at + payment_method)
- B: Faltou + multa fixed R$ 30 (original cancelled + nova multa)
- C: Cancelado >2h (original cancelled, sem multa)
- C2: Cancelado tardio <2h, full charge (original cancelled + nova taxa)

Bugs descobertos + corrigidos durante a bateria: cobranca dupla na
multa (cancela original agora), _reloadRange not defined no escopo
de _buildHandlers, badge $ amber em sessao encerrada, paymentLabel
usando ev.price em vez de paymentAmount pra pending, popover
permitindo emissao de fatura em sessao cancelada.

3 pendencias pos-C13 mapeadas em memoria + addendum HTML do doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 10:35:28 -03:00
Leonardo 753182cfad agenda: C10 pos-test fixes + lock sessao encerrada + addendum doc
Bugs descobertos durante testes C10/A2/B/C com user:

1) _reloadRange not defined: _buildHandlers nao destruturava
   _reloadRange do deps (passava mas nao desempacotava). Toast
   ReferenceError ao tentar reload pos-status change. Fix em
   useMelissaAgenda.js:_buildHandlers.

2) Badge $ amber em sessao cancelada: MelissaAgenda.vue badge gate
   ignorava status. Cancelado+state=none (records cancelled
   filtrados) ainda recebia badge "cobranca pendente". Fix: gate
   sessaoEncerrada (cancelado/faltou) -> sem badge nunca.

3) Botao "Gerar cobranca" em sessao encerrada: AgendaEventoFinanceiro
   Panel mostrava botao mesmo em cancelado/faltou -> user podia
   emitir fatura nova em sessao que nao aconteceu. Fix: v-if
   !isSessaoEncerrada + label muda pra "Sessao cancelada · sem
   cobranca ativa".

4) paymentLabel usava ev.price em vez de paymentAmount pra state
   'pending': caso multa R$ 30 mostrava R$ 150 (ev.price original).
   Fix: usar paymentAmount tambem em pending.

5) Lock total em sessao encerrada (cancelado/faltou):
   - "Editar sessao" SOME do popover
   - Realizada/Falta/Reagendar/Cancelar disabled com tooltip
   - Apenas "Agendada" continua funcional (caminho explicito de
     recuperacao). Single path de saida do estado encerrado.

Adicoes UX em AgendaStatusChangeConfirmDialog:
- Hint contextual sobre min_hours_notice explicando POR QUE multa
  veio (des)marcada por padrao: "Cancelou 18.5h antes da sessao.
  Regra: multa apenas quando cancelamento <2h -> sem multa por
  padrao." Terapeuta ve a razao e pode inverter conscientemente.

Adicoes UX em MelissaEventoPanel:
- Botao "Agendada" (variante --info azul cyan) no grupo status
  pra reset/recuperacao. CSS .evento-act--info hover + is-current.

Doc:
- Addendum C10 no topo de src/docs/agenda-compromisso-financeiro
  -cenarios.html capturando todas as divergencias/melhorias vs
  mockup original + 3 pendencias pos-C13 (reverse transitions,
  popover snapshot, A2 markPaid stale).

Pendencias salvas em memoria pra puxar pos-C13.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:59:05 -03:00
Leonardo 3caf5792f8 agenda popover: botao Agendada + fixes pos-C10/B
Adicoes (durante teste C10/A2):
- Botao "Agendada" no popover (pi-calendar, variante --info azul
  cyan) pra permitir reset de status realizado/faltou/cancelado
  voltando pra agendado sem precisar abrir o AgendaEventDialog.
  Wire-up: emit 'agendar' -> onAgendar -> updateEventoStatus.
- CSS .evento-act--info: hover + is-current com tom cyan
  (#38bdf8 do domainColors da agenda). Highlight generico
  rgba(255,255,255,0.12) era invisivel em light mode.

Bug fixes durante teste C10/B com Otto Rank:
- MelissaEventoPanel paymentLabel: usar paymentAmount tambem pra
  state='pending' (antes so 'paid' usava; pending caia em ev.price
  e mostrava R$ 150 original quando o pendente real era R$ 30 da
  multa).
- useMelissaAgenda onUpdateSeriesEvent: chamar _reloadRange() apos
  _applyStatusDecisions. Sem isso o paymentStateMap+amountMap nao
  re-populavam apos status change com multa -> FullCalendar e
  popover ficavam stale ate F5/troca de view.

Pendencia salva em memoria: travas em reverse transitions
(faltou->agendado deixa multa orfa). User hit pra valer com Otto
durante teste, R$ 30 limpo manualmente no DB. Implementar pos-C13.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 09:16:15 -03:00
Leonardo d6423da9b4 agenda: pre-C10 fix _applyStatusDecisions cancela pendingRecord
Bug: ao mudar status pra faltou/cancelado com multa configurada
em financial_exceptions, _applyStatusDecisions INSERIA o novo
record da multa MAS deixava o pendingRecord original em pending.
Resultado: cobranca dupla (R$ 200 original + R$ 30 multa = R$ 230).

Fix em useMelissaAgenda.js:1450-1505:
- applyFine agora carrega data da sessao na description ("Multa
  por falta - sessao dd/mm/aa") pro paciente identificar na fatura.
- Novo bloco 2b: cancela ctx.pendingRecord quando faltou/cancelado,
  com nota de auditoria appendada em notes ("[YYYY-MM-DD] Cancelada
  - substituida por multa de no-show" ou similar). Vale tanto pra
  caso com multa quanto sem (status mudado sem aplicar multa).

Fix dormente em useAgendaFinanceiro.js:59 ('fixed' -> 'fixed_fee')
- charge_mode no schema eh 'fixed_fee' mas calcChargeAmount usava
  'fixed' silenciosamente caia no fallback. Path nao exercitado na
  Melissa (usa _applyStatusDecisions, nao handleStatusChange), mas
  iria quebrar se algum dia fosse.

Pre-teste C10: financial_exceptions seedadas no DB para tenant
Bruno Terapeuta / owner Leonardo:
- patient_no_show: fixed_fee R$ 30
- patient_cancellation: full, min_hours_notice=2, default_consume_on_miss=true

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 08:27:16 -03:00
94 changed files with 11699 additions and 4399 deletions
+76 -12
View File
@@ -1,19 +1,83 @@
# HANDOFF — 2026-05-20 madrugada (C1-C9 ✅, próximo C10)
# HANDOFF — 2026-05-20 (C10 ✅ + C11 ✅ + C12 ⏳ deferido · testando C13)
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-C9 ✅**.
> Próximo: **Cenário 10** (status change avulsa — Joyce/Ana/Sándor:
> marcar como realizado/faltou/cancelado e ver consequências no
> financeiro via STATUS_TO_EXCEPTION + financial_exceptions).
> **🎯 SE A FORÇA CAIR / SESSÃO PERDER CONTEXTO:** C10 e C11 fechados.
> **C12 fluxo crítico OK no DB mas UX confusa** — adiado pra iterar
> pós-Rail/Clínica (memória project_c12_antecipar_iterar). Agora
> **testando C13** (edit cobrada — invariante imutabilidade SimplePractice).
> Implementação JÁ existe (Fase 6 do commit 1feb711 — Message com cadeado +
> AgendaEventoFinanceiroPanel embedded). Só validação visual + persistência.
> **🟢 WORKING TREE LIMPO** após commit/push do checkpoint pós-C8/C9.
> Per-session funcionando (12 events + 12 records gerados em batch).
> Financeiro com rowGroup por paciente (expand/collapse). Bubble-up
> @cobranca-atualizada → M.refetch faz o card da agenda atualizar
> badge/borda imediatamente após pagar.
> **🟢 14 COMMITS NO DIA**. C10 (5/5), C11 (4/4), C12 deferred (DB OK),
> reverse transition trava implementada, popover watch sync implementado.
> Pós-C13: replicar Rail (AgendaTerapeutaPage) + Clínica (AgendaClinicaPage)
> + iterar C12 UX + doc de ajuda (pendência separada).
### C13 — passos de teste (próximo)
Paciente: **João Almeida Martins** (sessão 20/05 9:00 realizada + paid R$ 40 maquininha) ou **André Green 20/05** (paid PIX).
Esperado ao abrir o AgendaEventDialog:
- Message azul com cadeado: "Cobrança de R$ X já emitida..."
- AgendaEventoFinanceiroPanel renderiza embaixo do Message
- Card "Aplicar alterações em" oculto (v-if="!occFinancialRecord")
- Só horário/observações editáveis; valor/serviços/tipo travados
### C11 sub-test results
| # | Teste | DB validado |
|---|---|---|
| 11A | Realizada + markPaid PIX | sessions_used 0→1, record paid R$ 40 PIX |
| 11B | Falta + Descontar saldo | sessions_used 1→2, sem multa |
| 11C | Falta + Multa SEM consumir | sessions_used stays 2, multa pending R$ 30 |
| 11D | Cancelado + default_consume_on_miss=true | sessions_used 2→3, sem multa (>2h) |
### Bugs descobertos + corrigidos durante C11
- UI "Como cobrar?" com options "Já recebi" misturadas → refatorado pra "Já recebi?" radio Sim/Não + select condicional
- `billing_contracts` sem coluna `updated_at` → UPDATE falhava silently em Promise.allSettled (root cause do saldo não incrementar). Trocado pra awaits sequenciais com error handling explícito
- Reverse transitions deixavam multa órfã → dialog reverse implementado com radio "cancelar pending" + "devolver saldo" + warning pra paid
- Botão "Gerar cobrança" em sessão encerrada → bloqueado
- Lock total em cancelado/faltou: Editar sessão some, status mudanças disabled exceto Agendada (recovery)
- Label "A cobrar R$ X" em pacote saldo state=none → "Aguardando uso do pacote"
- Badge $ amber em pacote saldo state=none → suprimido
- billing_contract_id não amarrado em alguns flows → link universal antes dos blocos forward
- Reverse saldo decrementar: refresh sessions_used FRESH do DB antes do UPDATE (anti-race)
### Pendências mapeadas pós-C13
- **Popover snapshot**: `eventoSelecionado.value = ev` é snapshot. Fix: guardar ev.id, derivar via computed
- ~~Reverse transitions~~ ✓ implementado ahead of schedule
- **Cleanup teste**: Otto sessão 5364f631 leftover (não-critical)
### C10 sub-test results
| # | Teste | DB validado | Notas |
|---|---|---|---|
| A | Realizada sem markPaid | ✅ status=realizado, record=pending | Bubble do C9 funcionou |
| A2 | Realizada + markPaid maquininha | ✅ status=realizado, record=paid, payment_method=cartao_maquininha, paid_at set | João Almeida |
| B | Faltou + multa R$ 30 (fixed_fee) | ✅ original cancelled + nova multa "Multa por falta · sessão dd/mm/aa" | Otto Rank |
| C | Cancelado >2h antecedência | ✅ original cancelled, sem multa | Otto / Karen |
| C2 | Cancelado tardio (<2h) full charge | ✅ original cancelled + nova "Taxa de cancelamento tardio · sessão dd/mm/aa" | Karen Horney |
### Pendências mapeadas durante C10 — pós-C13
- **Reverse transitions**: faltou/cancelado → agendado deixa multa órfã. Implementar confirm dialog oferecendo auto-cancelar multa.
- **Popover snapshot**: `eventoSelecionado.value = ev` é snapshot, não acompanha _paymentStateMap. Fix: guardar ev.id, derivar via computed.
- **Cleanup teste**: Otto sessão 5364f631 às 19:30 UTC tem record pending R$ 40 leftover do teste A original. Apagar quando convenient.
Memórias relevantes:
- `project_agenda_reverse_transitions.md`
- `project_melissa_popover_snapshot.md`
### Code-fix aplicado em 20/05 (pré-C10)
- **`useMelissaAgenda.js:1450-1505`** — `_applyStatusDecisions` agora cancela
o `ctx.pendingRecord` quando faltou/cancelado (com ou sem multa). Antes
inseria a multa mas DEIXAVA o original pending → cobrança dupla
(R$ 200 + R$ 30 = R$ 230). Audit trail vai em `notes` do record
cancelado, descrição da multa nova carrega data: "Multa por falta · sessão 20/05/26".
- **`useAgendaFinanceiro.js:59`** — fix dormente `'fixed'``'fixed_fee'`
(off-by-key contra schema; path nunca exercitado na Melissa, mas iria
quebrar se algum dia fosse).
### Financial exceptions seedadas (tenant Bruno Terapeuta / owner Leonardo)
- `patient_no_show``fixed_fee R$ 30`
- `patient_cancellation``full`, `min_hours_notice=2`, `default_consume_on_miss=true`
---
+156
View File
@@ -14,6 +14,130 @@ Chronological, append-only record of everything that's happened in this wiki.
---
## [2026-05-20 18:30] session | C12 deferred + C13 prep (lock ja existia em Fase 6)
Touched: none (codigo + HANDOFF; memoria project_c12_antecipar_iterar)
Detalhes:
C12 (antecipar pagamento) — DB OK + watch sync resolveu snapshot
stale, mas UX ficou confusa em ciclos antecipar/revogar/re-antecipar.
Adiado pra iterar pos-Rail/Clinica. 5 bugs adicionais corrigidos:
- Re-antecipar reusava record cancelled (notes confusas). Fix: filter
cancelled em existRec query
- Popover snapshot stale apos materializacao virtual->real. Fix:
watch em M.eventos com lookup por id + recurrence_id/date
- normalizeForMelissa nao expunha owner_id/tenant_id/billing_contract_id
-> RPC create_financial_record_for_session erro "null in owner_id".
Fix: expor explicit + fallback em handler
- onAnteciparPagamento fechava popover -> agora mantem aberto e watch
sincroniza
- Quick "Revogar pagamento" button alternando "Antecipar pagamento"
quando isAntecipacaoAtiva (paid + agendado)
C13 — prep:
- Lock "edit cobrada imutavel" JA esta implementado (Fase 6 do
commit 1feb711). Message azul com cadeado + AgendaEventoFinanceiro
Panel embedded quando occFinancialRecord existe. Card "Aplicar
alteracoes em" oculto pra simplificar.
- Pacientes pra testar: Joao Almeida (R$ 40 maquininha avulsa) ou
Andre Green 20/05 (R$ 40 PIX, pacote saldo)
- User vai testar; sem mudanca de codigo prevista. Validacao visual.
Total acumulado no dia: 14 commits, ~14 bugs corrigidos, 3 features
novas (Agendada button, reverse trava, revogar antecipacao + watch
sync popover).
## [2026-05-20 16:00] session | C11 OK (A/B/C/D) + reverse trava + 5 bugs achados
Touched: none (codigo + HANDOFF; memoria project_billing_contracts_no_updated_at)
Detalhes:
CENARIO 11 (Status change pacote saldo) - 4/4 passaram com Andre Green:
- 11A: realizada + markPaid PIX (saldo 0->1, record paid R$ 40)
- 11B: falta + descontar (saldo 1->2, sem multa)
- 11C: falta + multa SEM consumir (saldo stays 2, multa pending R$ 30)
- 11D: cancelado >2h + default_consume_on_miss=true (saldo 2->3, sem multa)
ROOT CAUSE descoberto: billing_contracts NAO tem coluna updated_at.
Passar esse field em UPDATE falhava silently em Promise.allSettled
(ja documentado em memoria). Refatorado pra awaits sequenciais com
error handling explicito.
DIALOG UX (refator): bloco "Cobranca no pacote" antes tinha select
"Como cobrar?" com options "Ja recebi - PIX/Dinheiro" misturadas.
Confuso. Agora tem 2 sub-questions: "Ja recebi?" radio + select
condicional (sem prefixo ambiguo).
REVERSE TRANSITION TRAVA (antecipado pos-C13 pra C11): quando user
clica Agendada em sessao com artefatos (cobranca pending, paid,
ou saldo consumido em pacote), dialog reverse abre mostrando:
- Lista records pending + radio Cancelar/Manter
- Warning textual pra paid (sem auto-estorno)
- Radio devolver saldo se consumido
- Default: cancel + devolver (recovery flow)
Outros fixes acumulados:
- consumeSaldo amarra billing_contract_id (era omissao)
- link universal pre-forward (antes era so em consumeSaldo/generatePackageCharge)
- reverse decrement saldo: refetch FRESH antes do UPDATE (anti-race)
- label pacote saldo state=none: "Aguardando uso do pacote"
- badge $ amber suprimido em pacote saldo state=none
- lock total em sessao encerrada (Editar some, status disabled excepto Agendada)
DOC: addendum no HTML cenarios atualizado anteriormente cobre tudo.
Memorias: project_billing_contracts_no_updated_at (novo gotcha).
PROXIMO: Cenario 12 (antecipar pagamento) ou Cenario 13 (edit
cobrada). Depois replicar em Rail + Clinica.
## [2026-05-20 14:00] session | C10 OK (A/A2/B/C/C2) + lock sessao encerrada + addendum doc
Touched: none (codigo + HANDOFF + addendum HTML; memorias project_agenda_reverse_transitions e project_melissa_popover_snapshot)
Detalhes:
CENARIO 10 (Status change avulsa) - 5/5 sub-testes passaram:
- A: realizado sem markPaid -> record pending preservado (João Almeida)
- A2: realizado + markPaid maquininha -> paid + paid_at + payment_method
(João Almeida; investigado false positive de "stale" - era confusao de
query, sempre passou)
- B: faltou + multa fixed R$ 30 -> original cancelled + nova multa com
description "Multa por falta · sessão dd/mm/aa" (Otto Rank)
- C: cancelado >2h antecedência -> original cancelled sem multa
(Otto/Karen). Hint contextual no dialog explica POR QUE multa veio
desmarcada.
- C2: cancelado tardio <2h full charge -> original cancelled + nova
"Taxa de cancelamento tardio" (Karen Horney)
BUGS DESCOBERTOS + CORRIGIDOS durante bateria (3 commits acumulados):
- Cobranca dupla na multa: _applyStatusDecisions INSERIA multa mas
deixava original pending. Fix: cancelar ctx.pendingRecord com nota
de auditoria em notes.
- _reloadRange not defined: _buildHandlers nao destruturava do deps.
- Badge $ amber em sessao cancelada: gate sessaoEncerrada agora cobre
status=cancelado/faltou em MelissaAgenda.vue.
- paymentLabel usava ev.price pra pending (R$ 150 enquanto multa real
era R$ 30). Fix: paymentAmount tambem em pending.
- Botao "Gerar cobranca" no popover + AgendaEventoFinanceiroPanel
permitia emitir fatura em sessao encerrada. Fix: gated por
isSessaoEncerrada.
- Lock total em cancelado/faltou: Editar sessao some, Realizada/Falta/
Reagendar/Cancelar disabled. So botao "Agendada" (novo, variante
--info cyan) continua funcional pra recuperacao explicita.
- Bug dormente: useAgendaFinanceiro.js comparava 'fixed' em vez de
'fixed_fee' do schema.
UX ADICIONS:
- Botao "Agendada" no popover (pi-calendar, --info cyan)
- Hint contextual sobre min_hours_notice no dialog ("Cancelou 18.5h
antes -> sem multa por padrao")
DOC:
- Addendum C10 no topo de src/docs/agenda-compromisso-financeiro
-cenarios.html capturando todas as divergencias + 3 pendencias.
PENDENCIAS POS-C13 (salvas em memoria):
- Reverse transitions com multa orfa (project_agenda_reverse_transitions)
- Popover Melissa snapshot stale (project_melissa_popover_snapshot)
PROXIMO: Cenario 11 (status change pacote saldo).
## [2026-05-20 06:00] session | C9 OK + rowGroup por paciente + bubble cobranca-atualizada
Touched: none (codigo + HANDOFF)
Detalhes:
@@ -1076,3 +1200,35 @@ Rail via AgendaTerapeutaPage, Clinica via AgendaClinicaPage com multi-owner);
agendaMappers.spec 40/40 passed. Pendente: rodar migration no banco local
+ validacao visual nos 3 layouts. Plano de 8 fases salvo em
[[agenda-compromisso-fluxo]]; pesquisa de mercado em [[agenda-billing-pesquisa-mercado]].
## [2026-05-20 evening] session | Fase 0+0.5 sweep de padronização pré-MVP
Touched: none (durable em development/02-auditoria/ + blueprints/ + memory)
Entregue: 3 blueprints (repository, composable, quick-create universal) + AUDIT_BASELINE.md (51 divergências em 6 módulos) + PADRONIZACAO.md (estratégia 4 fases) + DESIGN_BILLING_ORCHESTRATOR.md + 4 migrations + 1 seed do schema clínico (NÃO executadas) + scaffold features/tenantship/ (7 arquivos). Próximo: Fase 1 Módulo 1 (Home/Components).
## [2026-05-20 evening] session | M1 padronização Home/Components concluído
Touched: none (durable em development/02-auditoria/PADRONIZACAO.md + AUDIT_BASELINE.md + memória)
Módulo 1 da Fase 1 fechado: features/medicos/, features/insurance/, ComponentCadastroRapido refatorado (8 callers preservados), TEST_ACCOUNTS extraído, .bak deletado, topbar dev button ganhou switcher de layout + atalhos M1. M1.6 (MelissaLayout 90 imports) deferida pra sessão dedicada. Próximo: M2 Pacientes.
## [2026-05-20 evening] session | M2 Pacientes refatorado em batch
Touched: none (durable em development/02-auditoria/PADRONIZACAO.md + AUDIT_BASELINE.md + memória)
Módulo 2 da Fase 1 fechado sem pausas de teste (estratégia revisada). patientsSelects.js criado com 11 constantes. patientsRepository.js estendido com 15 funções novas. 8 composables refatorados em paralelo (usePatients, usePatientDetail, usePatientFinancial, usePatientSessions, usePatientMessages, usePatientDocuments, usePatientRecurrences, usePatientSupportContacts). Zero supabase.from() em qualquer composable de patients. _lastPatientId DENTRO da function nos 3 composables que tinham. 9 audit items resolvidos. Aguarda teste batch do user antes de seguir M3.
## [2026-05-20 evening] session | M3+M4+M5+M6 foundation em batch único
Touched: none (durable em development/02-auditoria/PADRONIZACAO.md + memória)
Sweep da Fase 1 completa em foundation. M3 (prontuário): 6 files em patients/prontuario/, ativa quando migrations 0.5.B rodarem. M4 (financeiro): 9 files em features/financeiro/, old composables em paralelo, Fase C bloqueada pelas decisoes #2/#3/#6. M5 (tenantship): MembersPage criada, rota TODO. M6 (notificacoes): noticesSelects + conversations foundation, channel factory deferido. Total ~21 files novos nesta sessao. Aguarda teste batch consolidado.
## [2026-05-20 evening] session | M5 quick wins fechados
Touched: none
Rota /admin/members registrada em routes.clinic.js. Migration 20260520000005_accept_tenant_invite_rpc.sql criada (SECURITY DEFINER + lock FOR UPDATE). tenantInvitesRepository.acceptInvite real (nao mais stub). SaasTenantFeaturesPage refatorada via novo tenantFeatureAdminService.js. SetupWizardPage 2648 linhas deferido pra sessao dedicada.
## [2026-05-21 morning] session | Fase 2 hotspots Graphify
Touched: none
convertToPatient de-dup: nova funcao markIntakeConverted no patientsRepository, 2 pages refatoradas. Supabase client triplo: finding defasado, so 1 instancia. 348 nos fracos: graphify update rodou pra refresh apos M1-M6+Fase2. Setup Wizard cohesion: parcial (SaasTenantFeaturesPage feito em M5 quick win); SetupWizardPage 2648 linhas adiado.
## [2026-05-21 morning] session | Fase 3 Asaas Gateway Tier 1 — Fase A foundation
Touched: none
DESIGN_ASAAS_GATEWAY.md completo. 7 arquivos novos: 2 migrations (tables+RLS) + client service + 3 Edge Function stubs. Webhook existente trata WhatsApp credits — extensao pra financial_records eh Fase B. Decisao modelo negocio (A/B/C) pendente. User precisa: conta Asaas, API keys, webhook config, ENV vars no Supabase. Stops bem marcados pra Fase B (implementacao real).
## [2026-05-21 morning] session | Fase 3 — Compliance CFP #5/#8/#9
Touched: none
2 migrations (profiles registration + specialties+joinM:N+RLS) + 1 seed (33 specialties) + 1 service (specialtiesService.js). #8 nome social ja estava integrado. #6 consent forms e #7 assinatura adiados — schemas (document_templates+document_signatures) existem, falta UI workflow.
+514
View File
@@ -0,0 +1,514 @@
# Composable Blueprint
> **Stack:** Vue 3 Composition API + Pinia (para state global) + Supabase via repository
> **Canônicos:** `src/features/agenda/composables/useAgendaEvents.js`, `useAgendaClinicEvents.js`, `useAgendaSettings.js`
> **Aplicável:** todo composable que orquestra estado reativo sobre uma repository
---
## 1. Princípio
Composable é **wrapper fino** sobre a repository. Responsabilidade:
- Manter **estado reativo** (data + loading + error)
- Chamar a repository (delegação 1:1)
- (Opcional) Cachear com stale-while-revalidate
- (Opcional) Compor outros composables
**Não faz:**
- Lógica de banco direta (vai no repository)
- Lógica de UI (vai no componente)
- Manipulação de DOM
- I/O direto fora do repository
> Regra de ouro: **se o composable tem `from('...')` do Supabase, ele virou repository disfarçado — refatorar.**
---
## 2. Estrutura de arquivos
```
src/features/<modulo>/composables/
├── use<Entity>.js # CRUD básico (thin wrapper)
├── use<Entity>Clinic.js # variant clinic-scoped (se aplicável)
├── use<Entity>Settings.js # config/preferences (com cache opt-in)
├── use<Entity>Lifecycle.js # orquestrador de estados (se domain complexo)
└── <entity>Helpers.js # funções puras auxiliares (não-composable)
```
**Convenção de nome:** sempre `use<Entity>...`. Funções helpers de domínio NÃO usam prefixo `use` — não são composables.
---
## 3. State shape canônico
Todo composable expõe **no mínimo** este shape:
```js
const rows = ref([]); // ou single ref dependendo do domínio
const loading = ref(false); // boolean
const error = ref(''); // string vazia, não null — facilita v-if
```
**Decisões importantes:**
| Refs | Tipo | Inicial | Por quê |
|---|---|---|---|
| `loading` | `boolean` | `false` | Padrão V3 — UI binda `:disabled="loading"` direto |
| `error` | `string` | `''` (vazio) | `v-if="error"` é falsy-friendly; sem null check |
| `rows`/data | `Array` ou objeto | `[]` ou `null` | Reset pra `[]` em erro de load — UI fica previsível |
**Anti-pattern:** misturar `error = ref(null)` num composable e `error = ref('')` em outro. Canonize `''` no projeto inteiro.
---
## 4. Tipos de composable (3 patterns)
### Tipo A — Thin wrapper (default) · referência: `useAgendaClinicEvents.js`
CRUD direto, sem cache, com loading/error em TODA operação:
```js
import { ref } from 'vue';
import { listX, createX, updateX, deleteX } from '@/features/<modulo>/services/<feature>Repository';
export function useX() {
const rows = ref([]);
const loading = ref(false);
const error = ref('');
async function loadRange({ startISO, endISO, ...scope } = {}) {
loading.value = true;
error.value = '';
try {
rows.value = await listX({ startISO, endISO, ...scope });
} catch (e) {
error.value = e?.message || 'Falha ao carregar.';
rows.value = [];
} finally {
loading.value = false;
}
}
async function create(payload, opts = {}) {
loading.value = true;
error.value = '';
try {
return await createX(payload, opts);
} catch (e) {
error.value = e?.message || 'Falha ao criar.';
throw e; // ← re-throw: composable repassa o erro pro componente decidir
} finally {
loading.value = false;
}
}
async function update(id, patch, opts = {}) { /* idem */ }
async function remove(id, opts = {}) { /* idem */ }
return { rows, loading, error, loadRange, create, update, remove };
}
```
**Por que re-throw nas mutações?** Componente precisa saber se o `await` falhou pra:
- Mostrar toast
- Não fechar modal
- Não navegar
- Manter form com dados
`error.value` é só pra estado reativo persistente. Mutação síncrona precisa de throw também.
### Tipo B — Thin wrapper "extra-leve" · referência: `useAgendaEvents.js`
Variant aceitável quando mutações **não precisam de loading**:
```js
async function create(payload) {
return createX(payload); // ← repassa erro nativamente; componente try/catch
}
```
**Quando usar:** UIs onde criar/editar tem feedback próprio (skeleton no item criado, optimistic UI, etc.). Default é o Tipo A.
### Tipo C — Cache com stale-while-revalidate · referência: `useAgendaSettings.js`
Para dados raros/pesados (settings, preferences, listas estáveis):
```js
import { ref } from 'vue';
import { getX } from '../services/<feature>Repository';
import { useMelissaCacheStore, MELISSA_CACHE_TTL } from '@/stores/melissaCacheStore';
export function useX(opts = {}) {
const useCache = !!opts.cache;
const cache = useCache ? useMelissaCacheStore() : null;
const data = ref(null);
const loading = ref(false);
const error = ref('');
async function _doFetch() {
const result = await getX();
data.value = result;
if (cache) {
const key = result?.owner_id || 'anon';
cache.set('xKey', result, key);
}
return result;
}
async function load() {
if (cache) {
const cached = cache.get('xKey', undefined, MELISSA_CACHE_TTL.xKey);
if (cached) {
data.value = cached;
_doFetch().catch((e) => console.warn('[useX] revalidate', e));
return;
}
}
loading.value = true;
error.value = '';
try {
await _doFetch();
} catch (e) {
error.value = e?.message || 'Falha ao carregar.';
data.value = null;
} finally {
loading.value = false;
}
}
return { data, loading, error, load };
}
```
**Decisões do Tipo C:**
- **`opts.cache` default `false`** — páginas de configuração que editam settings esperam mudança imediata após salvar, então cache opt-in.
- **Cache key inclui scope** (`owner_id`/`tenant_id`) — invalida automaticamente em troca de usuário/tenant.
- **TTL constants no store** — `MELISSA_CACHE_TTL.<feature>` (não hardcoded no composable).
- **Stale-while-revalidate:** retorna cached SE existe + dispara fetch em background (sem await).
- **Revalidate fail é warn**, não error — UI já tem dados válidos do cache.
---
## 5. Convenções de nomenclatura
### Funções
| Operação | Nome canônico | Variantes aceitas |
|---|---|---|
| Listar com filtro | `loadRange` / `loadMy<X>` | `load<Scope><Range>` |
| Criar | `create` | `create<Scope>` (se houver ambiguidade) |
| Atualizar | `update` | `update<Scope>` |
| Remover | `remove` | `remove<Scope>` (nunca `delete` — palavra reservada) |
| Recarregar | `refresh` | `reload` |
| Limpar estado | `reset` / `clear` | — |
**Scope sufixo** quando o composable serve múltiplos contextos: `loadMyRange` (terapeuta) vs `loadClinicRange` (admin).
### State refs
- `rows` — coleção principal (array)
- `record` — single (quando faz sentido)
- `data` — genérico (settings, config)
- `loading` — boolean único; se há múltiplos `loading` (load vs save), nomear: `loadingList`, `saving`
- `error` — string única; mesmo princípio: `loadError`, `saveError` se precisar
---
## 6. Anatomia padrão de uma operação `load*`
```js
async function loadXxx(args) {
// 1. Validação leve (early return, não throw)
if (!args?.required) return;
// 2. State flag
loading.value = true;
error.value = '';
try {
// 3. Delegate pra repository (UMA chamada — se múltiplas, Promise.all)
const result = await listX(args);
// 4. Mutate state
rows.value = result;
} catch (e) {
// 5. Erro humano + reset de data (UI fica previsível)
error.value = e?.message || 'Mensagem PT-BR genérica.';
rows.value = [];
} finally {
// 6. Sempre limpar loading
loading.value = false;
}
}
```
**Por que early-return em vez de throw na validação?** Composable é wrapper — chamadas inválidas (ex: `ownerId` ainda não chegou no mount) não devem quebrar UI. Throw fica pra repository.
---
## 7. Múltiplos fetches paralelos
Quando uma operação precisa de N queries:
```js
async function _doFetch() {
const [cfg, rules, profile] = await Promise.all([
getMyAgendaSettings(),
getMyWorkSchedule(),
getMyProfile()
]);
settings.value = cfg;
workRules.value = rules;
profile.value = profile;
}
```
**Regras:**
- `Promise.all` (não `Promise.allSettled`) — falha de qualquer query falha a operação inteira
- Exception: quando uma query é opcional/best-effort → `Promise.allSettled` + processa por result
- **Nunca** sequenciar fetches independentes (await + await + await)
---
## 8. Composição de composables
Composable pode usar outros composables, mas:
```js
// ✅ certo — composição estrutural
export function useAgendaEventLifecycle() {
const events = useAgendaEvents();
const billing = useAgendaFinanceiro();
const settings = useAgendaSettings({ cache: true });
async function realizar(eventId) {
// orquestra os 3
}
return { ...events, realizar, ... };
}
// ❌ errado — não compor pra economizar 1 linha
export function useOnlyToWrapList() {
const { rows, loadMyRange } = useAgendaEvents();
return { rows, loadMyRange }; // ← isso é um re-export inútil
}
```
**Regra:** compõe quando há **orquestração**. Se é só forward, importa direto.
---
## 9. Anti-patterns (NÃO fazer)
### ❌ Composable que tem `supabase.from('...')` direto
```js
// ❌ — violação de camadas
export function useFoo() {
async function load() {
const { data } = await supabase.from('foo').select('*');
}
}
```
✅ Move pra repository, composable só delega.
### ❌ `error` ora `null`, ora `''`, ora `Error`
Canonize `string` (default `''`). Errors do JS dão `e?.message || 'fallback PT-BR'`.
### ❌ Não resetar `rows` em erro de load
```js
// ❌
async function loadRange() {
try { rows.value = await listX(); } catch (e) { error.value = e.message; }
// rows.value mantém dados antigos = UI mostra coisa stale + alerta de erro
}
```
✅ Reset `rows.value = []` no catch — UI fica determinística.
### ❌ Não re-throw mutações
```js
// ❌
async function create(payload) {
try { return await createX(payload); }
catch (e) { error.value = e.message; }
// componente faz `await create()` e nunca sabe que falhou
}
```
✅ Re-throw após setar `error.value`.
### ❌ `Promise.all` quando uma falha é aceitável
Quando uma das queries pode falhar sem invalidar as outras, usar `Promise.allSettled`. Comum em listings que enriquece com lookups opcionais.
### ❌ State global em variável módulo
```js
// ❌ — vaza entre componentes que compartilham o composable
const rows = ref([]);
export function useFoo() {
return { rows };
}
```
✅ State sempre DENTRO da `function useFoo()`. Se precisar global, use Pinia store.
### ❌ Composable que faz `watch` no próprio state pra "side effect"
```js
// ❌
const rows = ref([]);
watch(rows, () => { /* save algo */ });
```
✅ Mover `watch` pro componente — composable não decide quando salvar.
**Exceção:** watch pra sincronizar com prop externa do composable (`watchEffect(() => loadRange(props.range))`) é OK.
### ❌ Composable retornando objeto enorme
Se o `return` tem 20+ chaves, o composable está fazendo coisa demais. Quebrar em N composables menores ou extrair Pinia store.
---
## 10. Cache store (Tipo C complementar)
Quando criar um composable Tipo C, garantir que existe entry em:
- `src/stores/melissaCacheStore.js``MELISSA_CACHE_TTL.<feature>` constante (TTL em ms)
- `.get(key, scope, ttl)` retorna valor ou null
- `.set(key, value, scope)` salva com timestamp
- Invalidação manual: `.invalidate('<feature>')`
**TTL guidelines:**
| Tipo de dado | TTL sugerido |
|---|---|
| Settings/preferences | 5 min |
| Listas estáveis (specialties, plans) | 30 min |
| Catálogo (services, pricing) | 10 min |
| Multi-tenant lookups | 5 min |
| Anything user-edited | NÃO cachear (Tipo A) |
---
## 11. Checklist de auditoria por módulo
Quando rodar `/audit-module <nome>`, validar cada composable:
- [ ] Não tem `supabase.from(...)` direto — só importa da repository
- [ ] State shape: `rows`/`data`, `loading: boolean`, `error: string`
- [ ] `error` é string, default `''`
- [ ] Reset de data em erro de load (`rows.value = []`)
- [ ] Mutações re-throw após setar error.value
- [ ] Nomenclatura: `loadRange`/`load<Scope>`, `create`, `update`, `remove`
- [ ] `remove` não `delete` (palavra reservada)
- [ ] Validação leve usa early-return (não throw)
- [ ] Múltiplos fetches em `Promise.all` (não sequencial)
- [ ] State DENTRO da `function use*()` (não em variável de módulo)
- [ ] Sem `watch` em própria state pra side effect (mover pro componente)
- [ ] Helpers de domínio em arquivo separado sem prefixo `use`
- [ ] Se cacheia (Tipo C): `opts.cache` opt-in, default `false`; TTL em `MELISSA_CACHE_TTL`; cache key inclui scope
- [ ] Return statement com chaves explícitas (não `return { ...state, ...actions }` opaco)
- [ ] Return ≤ 15 chaves (>15 = composable fazendo coisa demais)
Divergências viram items em `dev_auditoria_items` com:
- `categoria`: `padronizacao`
- `tag`: `padronizacao:<modulo>`
- `severidade`: alta se camada quebrada (composable com `from()`); média se viola convenção (error null vs ''); baixa se cosmético (nome de função)
---
## 12. Exemplo completo (template)
```js
/*
| Arquivo: src/features/patients/composables/usePatients.js
*/
import { ref } from 'vue';
import {
listPatients,
createPatient,
updatePatient,
deletePatient
} from '@/features/patients/services/patientsRepository';
export function usePatients() {
const rows = ref([]);
const loading = ref(false);
const error = ref('');
async function loadRange({ search, status, tenantId } = {}) {
loading.value = true;
error.value = '';
try {
rows.value = await listPatients({ search, status, tenantId });
} catch (e) {
error.value = e?.message || 'Falha ao carregar pacientes.';
rows.value = [];
} finally {
loading.value = false;
}
}
async function create(payload) {
loading.value = true;
error.value = '';
try {
return await createPatient(payload);
} catch (e) {
error.value = e?.message || 'Falha ao criar paciente.';
throw e;
} finally {
loading.value = false;
}
}
async function update(id, patch) {
loading.value = true;
error.value = '';
try {
return await updatePatient(id, patch);
} catch (e) {
error.value = e?.message || 'Falha ao atualizar paciente.';
throw e;
} finally {
loading.value = false;
}
}
async function remove(id) {
loading.value = true;
error.value = '';
try {
await deletePatient(id);
} catch (e) {
error.value = e?.message || 'Falha ao remover paciente.';
throw e;
} finally {
loading.value = false;
}
}
return { rows, loading, error, loadRange, create, update, remove };
}
```
---
## 13. Referências
- Canônicos: `src/features/agenda/composables/useAgendaEvents.js`, `useAgendaClinicEvents.js`, `useAgendaSettings.js`
- Repository pareado: `blueprints/repository-blueprint.md`
- Cache store: `src/stores/melissaCacheStore.js`
- Tracker: `dev_auditoria_items` com tag `padronizacao:<modulo>`
- Estratégia: `development/02-auditoria/PADRONIZACAO.md`
@@ -0,0 +1,431 @@
# Quick-Create Overlay Blueprint
> **Status:** Pattern **universal**. Promovido de agenda-only em 2026-05-20 após audit baseline (`development/02-auditoria/AUDIT_BASELINE.md`) identificar 3 candidates já em produção fora da agenda.
> **Stack:** Vue 3 + PrimeVue Dialog
> **Canônicos:**
> - `src/features/agenda/components/ServiceQuickCreateDialog.vue` (referência completa)
> - `src/features/agenda/components/InsurancePlanQuickCreateDialog.vue`
> - `src/features/agenda/components/InsurancePlanServiceQuickCreateDialog.vue`
> **Legacy a refatorar (supabase direto, sem repository):**
> - `src/components/CadastroRapidoMedico.vue` → migrar pra `features/medicos/components/` (módulo 1 da Fase 1)
> - `src/components/CadastroRapidoConvenio.vue` → migrar pra `features/insurance/components/`
> - `src/components/ComponentCadastroRapido.vue` → migrar pra path apropriado conforme dono da entidade
---
## 1. Princípio
**Problema:** usuário está num fluxo (ex: agendar uma sessão) e precisa de uma entidade dependente que ainda não existe (serviço, convênio, plano). Navegar pra outra página significa **perder o contexto** do form em progresso.
**Solução:** mini-dialog **por cima** do dialog/fluxo atual, com **campos mínimos** pra criar a entidade, e ao salvar **pré-seleciona** ela no select que disparou o quick-create.
**Regra absoluta:** criar dependência faltante em **qualquer fluxo** deve **abrir overlay POR CIMA, nunca navegar pra fora**. Aplicável em todo o sistema desde a promoção do blueprint (2026-05-20). Origem do pattern: agenda (memória `feedback_agenda_inline_quick_create`, agora generalizada).
---
## 2. Quando aplicar (vs alternativas)
| Situação | Solução |
|---|---|
| Fluxo crítico travado por dependência faltante (form em progresso) | **Quick-create overlay** ✅ |
| Cadastro completo, com todos os campos | Página dedicada `/entity/new` ou Dialog full |
| Apenas selecionar item existente | Select com busca; sem botão "+" |
| Onboarding ou setup wizard | Não — fluxo é a página inteira, não um overlay |
**Anti-uso:** quick-create NÃO é "shortcut pra criar do menu lateral". É **fallback contextual** quando o form atual depende de algo que falta. O parent **precisa estar pronto pra receber o evento `created`** e usar o ID.
---
## 3. Estrutura do componente `<Entity>QuickCreateDialog.vue`
```vue
<script setup>
import { ref, watch } from 'vue';
import { useToast } from 'primevue/usetoast';
// CANÔNICO: importar da repository do feature dono da entidade.
// LEGACY: 3 componentes em src/components/ usam supabase direto — refatorar quando módulo dono for tocado na Fase 1.
import { createX } from '@/features/<feature>/services/<feature>Repository';
const props = defineProps({
modelValue: { type: Boolean, default: false },
ownerId: { type: String, default: '' },
initialName: { type: String, default: '' } // pré-preenche do search atual do select
});
const emit = defineEmits(['update:modelValue', 'created']);
const toast = useToast();
const tenantStore = useTenantStore();
const visible = ref(props.modelValue);
watch(() => props.modelValue, (v) => { visible.value = v; });
watch(visible, (v) => emit('update:modelValue', v));
const form = ref({ /* só campos MÍNIMOS obrigatórios + 1-2 opcionais úteis */ });
const saving = ref(false);
// Resetar form toda vez que abre
watch(() => props.modelValue, (v) => {
if (v) form.value = { /* defaults + initialName */ };
});
const canSave = () => /* validação leve */;
async function onSave() {
if (!canSave()) return;
saving.value = true;
try {
// Sanitize (trim + maxlength slice + nullif vazio) ANTES de chamar repository
const payload = {
name: form.value.name.trim().slice(0, 120),
// ...resto sanitizado
};
// Repository injeta owner_id (uid logado) + tenant_id (store) + faz uniqueness check
// e throw em erro. Quick-create só decide o que mostrar ao usuário.
const data = await createX(payload);
toast.add({ severity: 'success', summary: '<Entity> criado', life: 2200 });
emit('created', data); // ← parent usa data.id pra pré-selecionar
visible.value = false;
} catch (e) {
// Repository pode throw com message conhecido (ex: "Nome em uso") — mostra como warn ou error
const isDup = /em uso|já existe|duplicate/i.test(e?.message || '');
toast.add({
severity: isDup ? 'warn' : 'error',
summary: isDup ? 'Nome em uso' : 'Falha ao criar',
detail: e?.message || 'Erro inesperado',
life: 4000
});
} finally {
saving.value = false;
}
}
</script>
<!--
LEGACY-NOTE (2026-05-20): os 3 quick-creates em src/components/ (CadastroRapidoMedico,
CadastroRapidoConvenio, ComponentCadastroRapido) ainda usam supabase direto. Padrão acima
é o CANÔNICO pós-promoção. Refator vai acontecer no módulo correspondente da Fase 1.
-->
<template>
<Dialog
v-model:visible="visible"
modal
:draggable="false"
:closable="!saving"
header="Novo <entity>"
class="w-[94vw] max-w-md"
>
<!-- Campos mínimos: 3-5 inputs, nada mais -->
<div class="flex flex-col gap-3 pt-1"> ... </div>
<template #footer>
<Button label="Cancelar" text :disabled="saving" @click="visible = false" />
<Button label="Salvar" :loading="saving" :disabled="!canSave()" @click="onSave" />
</template>
</Dialog>
</template>
```
---
## 4. Contrato canônico de props/emits
### Props (sempre)
| Prop | Tipo | Default | Função |
|---|---|---|---|
| `modelValue` | `Boolean` | `false` | Visibilidade do dialog. Two-way via `v-model`. |
| `ownerId` | `String` | `''` | Owner_id (terapeuta). Default: usuário logado. |
| `initialName` | `String` | `''` | Pré-preenche o campo nome com o search atual do select (UX win). |
### Props (opcionais por entidade)
- `parentId` (`String`) — quando a entidade tem hierarquia (ex: `plan_id` em `plan_service`)
- `defaultDurationMin` (`Number`) — quando faz sentido herdar valor do contexto
- Outras herdadas do contexto, **nunca** mais que 3 props extras (senão vira form pesado, não quick-create)
### Emits
| Evento | Payload | Quando |
|---|---|---|
| `update:modelValue` | `Boolean` | `v-model` two-way |
| `created` | `Object` (row inserida completa) | Após insert bem-sucedido |
**Nunca emitir** `cancelled`, `closed`, `error` — parent não precisa saber dessas distinções; `update:modelValue=false` cobre.
---
## 5. Integração no parent
### Slot do botão `+` ao lado do select
```vue
<div class="flex gap-2 items-center">
<Select v-model="selectedServiceId" :options="services" optionLabel="name" optionValue="id" class="flex-1" />
<Button
icon="pi pi-plus"
v-tooltip.top="'Cadastrar novo serviço'"
severity="secondary"
size="small"
@click="openServiceQuickCreate"
/>
</div>
```
### Lock do dialog parent
Parent **precisa** travar seu próprio `dismissableMask` e `closeOnEscape` enquanto qualquer quick-create child está aberto, senão clicar fora fecha tudo:
```vue
<Dialog
v-model:visible="parentVisible"
:dismissableMask="!anyChildDialogOpen"
:closeOnEscape="!anyChildDialogOpen"
...
>
```
```js
const serviceQuickCreateOpen = ref(false);
const insuranceQuickCreateOpen = ref(false);
const anyChildDialogOpen = computed(() =>
serviceQuickCreateOpen.value || insuranceQuickCreateOpen.value
);
```
### Renderização dos quick-creates DENTRO do parent
```vue
<!-- DENTRO do template do parent dialog, antes do </Dialog> -->
<ServiceQuickCreateDialog
v-model="serviceQuickCreateOpen"
:owner-id="ownerId"
:initial-name="serviceSearchText"
@created="onServiceCreated"
/>
```
### Handler `on<Entity>Created`
```js
function onServiceCreated(row) {
// 1. Inserir na lista local (sem re-fetch)
services.value = [row, ...services.value];
// 2. Pré-selecionar no select
selectedServiceId.value = row.id;
// 3. (Opcional) Focar o próximo campo
nextTick(() => priceInputRef.value?.focus());
}
```
### Handler `openXQuickCreate`
```js
function openServiceQuickCreate() {
serviceSearchText.value = currentSearchInSelect.value; // capture pra initialName
serviceQuickCreateOpen.value = true;
}
```
---
## 6. Convenções de UX
### Campos mínimos absolutos
Quick-create **não é cadastro completo**. Inclui só:
- 1 campo obrigatório principal (nome)
- 1-2 campos obrigatórios secundários (preço, duração)
- 1 campo opcional (descrição)
Resto (categorias, tags, configurações avançadas) edita depois em `/entity/:id`.
### Maxlength visível
```vue
<InputText v-model="form.name" maxlength="120" />
```
Slice no save: `.trim().slice(0, 120)` — defesa em profundidade.
### Botão "+" sempre `size="small"` `severity="secondary"`
Discrição visual — não compete com CTA do dialog parent.
### Toast em vez de inline error
Mini-dialog não tem espaço pra banner de erro. Toast no canto superior direito (padrão PrimeVue) basta.
### `autofocus` no primeiro input
```vue
<InputText autofocus v-model="form.name" />
```
Usuário já está em modo "digitar" — pular o clique no input.
### `:loading="saving"` no botão Salvar
Spinner + disabled simultâneo. PrimeVue já dá o efeito visual.
---
## 7. Anti-patterns (NÃO fazer)
### ❌ Navegar pra rota nova no botão "+"
```js
// ❌ — destrói o form em progresso
function openServiceQuickCreate() {
router.push('/saas/services/new');
}
```
✅ Abre o overlay.
### ❌ Quick-create que pede 10 campos
Se a entidade exige cadastro complexo (campos condicionais, validações cruzadas, upload de arquivo), **não cabe num quick-create**. Use página dedicada e aceite que o usuário perde contexto. Ou crie um wizard.
### ❌ Sem `dups check` antes do insert
```js
// ❌ — usuário clica 2x, cria duplicata silenciosa
await supabase.from('services').insert(payload).select().single();
```
`ilike` por `name` antes; aborta com warn toast.
### ❌ Não emitir o objeto completo no `created`
```js
// ❌
emit('created', { id: data.id }); // parent precisa de mais que id
// ❌ pior ainda
emit('created'); // parent não sabe o que foi criado
```
`emit('created', data)` — row completa do banco.
### ❌ Não capturar `initialName` do search atual
Quando usuário digita "Sessão 50min" no select e clica "+", o `initialName=` deve já vir preenchido. Senão usuário re-digita.
### ❌ Parent sem `anyChildDialogOpen` no lock
Sem o lock, clicar fora do quick-create child fecha o parent inteiro. Bug clássico.
### ❌ Re-fetch da lista após `created`
```js
// ❌ — round-trip desnecessário; o evento já trouxe o row
async function onServiceCreated() {
await loadServices();
}
```
✅ Inserir o `row` recebido direto na lista local; só re-fetch se houver lógica de ordenação complexa.
### ❌ Múltiplos quick-creates abertos ao mesmo tempo
Permitir abrir um quick-create de plano de saúde enquanto outro de serviço está aberto = stack visual confuso. Force fechar o atual antes de abrir o próximo, OU mantenha o lock no `anyChildDialogOpen` que cobre.
---
## 8. Sanitização (memória `feedback_sanitizacao`)
Toda entrada de quick-create:
```js
const name = form.value.name?.trim().slice(0, 120) || null;
const description = form.value.description?.trim().slice(0, 500) || null;
const price = form.value.price != null ? Number(form.value.price) : null;
```
Padrão: `trim()``slice(maxlength)``nullif vazio` → cast tipo.
Pro upload (não comum em quick-create, mas se houver): mime allowlist + size check antes de submitter.
---
## 9. Promotion History & Path Convention
### Histórico
- **2026-05-04** — Pattern nasceu em `features/agenda/` com 3 quick-creates (Service, InsurancePlan, InsurancePlanService). Documentado como **agenda-only** com promotion criteria explícito.
- **2026-05-20** — Audit baseline identificou 3 candidates já em produção fora da agenda: `CadastroRapidoMedico.vue`, `CadastroRapidoConvenio.vue`, `ComponentCadastroRapido.vue` (todos `supabase` direto, em `src/components/`). Promotion criteria atingida 3×. **Blueprint promovido pra universal.**
### Path convention pós-promoção
| Caso | Path | Exemplo |
|---|---|---|
| Entidade pertence a 1 feature claro | `src/features/<feature>/components/<Entity>QuickCreateDialog.vue` | `features/medicos/components/MedicoQuickCreateDialog.vue` |
| Entidade é cross-feature (raro) | `src/components/quick-create/<Entity>QuickCreateDialog.vue` | (nenhum hoje) |
**Anti-pattern:** quick-create morando em `src/components/` raiz sem subpasta — perde discoverability e mistura com componentes utilitários.
### Plano de migração dos 3 legacy
Cada refator acontece **quando o módulo dono for tocado na Fase 1**:
| Componente atual | Path destino | Quando | Fix obrigatório |
|---|---|---|---|
| `src/components/CadastroRapidoMedico.vue` | `src/features/medicos/components/MedicoQuickCreateDialog.vue` | Módulo 1 (Home/Components) — pode criar `features/medicos/` se ainda não existe | Migrar pra repository; usar `_tenantGuards` |
| `src/components/CadastroRapidoConvenio.vue` | `src/features/insurance/components/InsurancePlanQuickCreateDialog.vue` (consolidar com o existente na agenda?) | Módulo 1 | Idem; **verificar se duplica `features/agenda/components/InsurancePlanQuickCreateDialog.vue`** |
| `src/components/ComponentCadastroRapido.vue` | depende do que cria | Módulo 1 | Idem |
### Boilerplate DRY (futuro, não-prioritário)
Quando houver 5+ quick-creates seguindo o pattern, considerar:
- `useQuickCreateLock()` composable que encapsula `anyChildDialogOpen` (DRY entre parent dialogs com 2+ children)
- `<BaseQuickCreateDialog>` wrapper component com slots `#fields`, `#footer-extra` e props padrão
**Não fazer agora** — 6 instâncias ainda é pouco pra inflar abstração. Pattern atual (cada quick-create standalone) é fácil de entender e copiar.
---
## 10. Checklist de auditoria
Aplica-se a **todo quick-create do sistema** pós-promoção (2026-05-20):
- [ ] Path correto (feature folder se entidade pertence a 1 feature; `src/components/quick-create/` se cross-feature)
- [ ] Nome do arquivo: `<Entity>QuickCreateDialog.vue` (PascalCase)
- [ ] Props canônicas: `modelValue`, `ownerId`, `initialName`
- [ ] Emits canônicos: `update:modelValue`, `created`
- [ ] `Dialog` com `modal`, `:draggable="false"`, `:closable="!saving"`
- [ ] Form reset quando abre (`watch modelValue`)
- [ ] Sanitização: `trim() + slice(maxlength) + nullif` ANTES de chamar repository
- [ ] **Insert via repository** (não supabase direto) — repository injeta `owner_id`+`tenant_id` e faz uniqueness check
- [ ] Toast feedback em success/warn/error (warn quando erro for "nome em uso", error caso contrário)
- [ ] Emit `created` com row completo (não só id)
- [ ] Parent: `anyChildDialogOpen` computed lock
- [ ] Parent: `dismissableMask` e `closeOnEscape` bindados ao lock
- [ ] Parent: handler `on<Entity>Created` insere row na lista local e pré-seleciona
- [ ] Parent: `initialName` capturado do search atual do select
- [ ] Botão "+": `size="small"` `severity="secondary"` `v-tooltip`
- [ ] `autofocus` no primeiro input
- [ ] `:loading="saving"` + `:disabled="!canSave()"` no Salvar
- [ ] Máximo 3-5 inputs no form (senão não é quick-create — vira página dedicada)
Divergências viram items em `dev_auditoria_items` com:
- `categoria`: `padronizacao`
- `tag`: `padronizacao:<modulo>` (módulo dono da entidade)
- `severidade`: **alta** se usa supabase direto em vez de repository, ou viola lock (vaza dismiss); **média** se viola contrato (emits/props); **baixa** se cosmético
---
## 11. Referências
- Canônicos: `src/features/agenda/components/ServiceQuickCreateDialog.vue`, `InsurancePlanQuickCreateDialog.vue`, `InsurancePlanServiceQuickCreateDialog.vue`
- Parent integrador: `src/features/agenda/components/AgendaEventDialog.vue` (linhas ~3081-3107, ~3170, ~3274, ~3307)
- Legacy a refatorar: `src/components/CadastroRapidoMedico.vue`, `CadastroRapidoConvenio.vue`, `ComponentCadastroRapido.vue`
- Dialog base: `blueprints/dialog-blueprint.md`
- Repository pareado: `blueprints/repository-blueprint.md`
- Audit baseline: `development/02-auditoria/AUDIT_BASELINE.md` (3 candidates descobertos em 2026-05-20)
- Memória: `feedback_agenda_inline_quick_create.md` (superseded — pattern agora universal), `feedback_sanitizacao.md`
- Estratégia: `development/02-auditoria/PADRONIZACAO.md`
+379
View File
@@ -0,0 +1,379 @@
# Repository Blueprint
> **Stack:** Supabase JS client + Vue 3 (Pinia stores)
> **Canônico:** `src/features/agenda/services/` (validado em C1-C13 + análise sênior 2026-05-20)
> **Aplicável:** todo módulo com acesso a tabela `*` com `tenant_id`
---
## 1. Princípio
Camada **thin** entre Supabase e composables. **Funções puras** + **tenant guards** + **SELECT canônico**. Sem classes, sem state, sem singletons. Idempotente, testável, descartável.
Composable orquestra estado e cache. **Repository só fala com o banco.**
---
## 2. Estrutura de arquivos
```
src/features/<modulo>/services/
├── _tenantGuards.js # SHARED entre repositories do feature
├── <feature>Selects.js # SELECT canônico + helpers de flatten
├── <feature>Repository.js # CRUD escopo terapeuta (owner_id = uid)
└── <feature>ClinicRepository.js # CRUD escopo clínica (se aplicável)
```
**Regra do `_tenantGuards.js`:** se o feature tem 2+ repositories (terapeuta + clínica), os guards saem pra arquivo compartilhado. Se só tem 1, pode ficar no topo do próprio repo.
---
## 3. Tenant guards canônicos
Copiar **literal** de `src/features/agenda/services/_tenantGuards.js`:
```js
import { supabase } from '@/lib/supabase/client';
export function assertTenantId(tenantId) {
if (!tenantId || tenantId === 'null' || tenantId === 'undefined') {
throw new Error('Tenant ativo inválido. Selecione a clínica/tenant antes de operar.');
}
}
export async function getUid() {
const { data, error } = await supabase.auth.getUser();
if (error) throw error;
const uid = data?.user?.id;
if (!uid) throw new Error('Usuário não autenticado.');
return uid;
}
export function assertIsoRange(startISO, endISO) {
if (!startISO || !endISO) throw new Error('Intervalo inválido (startISO/endISO).');
}
export function sanitizeOwnerIds(ownerIds) {
return (ownerIds || []).filter((id) => typeof id === 'string' && id && id !== 'null' && id !== 'undefined');
}
```
**Por quê string `'null'`/`'undefined'`?** Vindo de URL params/localStorage stringificado, esses casos aparecem como string literal. Defesa em profundidade.
---
## 4. SELECT canônico
**Extrair pra constante exportada.** Inline SELECT em 3 lugares = divergência sutil (FKs explícitas em uns, não em outros) = bug.
```js
/**
* Select canônico de <tabela> com joins.
*
* FKs explícitas (obrigatórias quando há múltiplas colunas apontando pra mesma tabela):
* - <tabela>_<col>_fkey
*/
export const <FEATURE>_SELECT = `
id, owner_id, tenant_id, ...,
patients!<tabela>_<col>_fkey (
id, nome_completo, avatar_url, status
)
`.trim();
```
E o **flatten helper** ao lado:
```js
/**
* Achata o aninhamento de patients dentro da row.
* Mantém ambas formas (flat + nested) pra compat com call sites variados.
*/
export function flatten<Feature>Row(r) {
if (!r) return r;
const patient = r.patients || null;
return {
...r,
paciente_nome: patient?.nome_completo || r.paciente_nome || '',
paciente_avatar: patient?.avatar_url || r.paciente_avatar || '',
paciente_status: patient?.status || r.paciente_status || ''
};
}
```
---
## 5. Convenções de assinatura
### Funções puras exportadas
```js
// ✅ certo
export async function listMyEvents({ startISO, endISO, ownerId, tenantId } = {}) { ... }
// ❌ errado — classe com state
class AgendaRepository {
async list() { ... }
}
```
### Args nomeados (destructure)
Posicionais quebram com refator. Default `= {}` evita TypeError se chamarem sem args.
### `tenantId` opcional → resolve via store
Helper local no repository:
```js
import { useTenantStore } from '@/stores/tenantStore';
import { assertTenantId } from './_tenantGuards';
function resolveTenantId(tenantIdArg) {
const tenantStore = useTenantStore();
const tenantId = tenantIdArg || tenantStore.activeTenantId;
assertTenantId(tenantId);
return tenantId;
}
```
Por que opcional? Composable pode passar `tenantId` explícito (testes, multi-tenant ops). Default chega via store.
### Errors throw, nunca silent
```js
const { data, error } = await supabase.from('...').select(...);
if (error) throw error; // ✅
// ❌ if (error) return null;
// ❌ if (error) console.error(error);
```
Composable decide se faz `try/catch` + toast.
### Ranges half-open
```js
// ✅ certo — half-open
.gte('inicio_em', startISO).lt('inicio_em', endISO)
// ❌ errado — fechado, gera off-by-one no último ms
.gte('inicio_em', startISO).lte('inicio_em', endISO)
```
### Strip campos legados antes de insert/update
```js
// eslint-disable-next-line no-unused-vars
const { paciente_id: _dropped, ...safePayload } = payload;
```
Quando há migração de coluna em andamento ou campo virtual no UI.
---
## 6. Operações CRUD — pattern
### Create (owner-scoped)
```js
export async function create<Feature>(payload) {
if (!payload) throw new Error('Payload vazio.');
const uid = await getUid();
const tid = resolveTenantId();
const { paciente_id: _dropped, ...rest } = payload;
const insertPayload = { ...rest, tenant_id: tid, owner_id: uid };
const { data, error } = await supabase
.from('<tabela>')
.insert([insertPayload])
.select(<FEATURE>_SELECT)
.single();
if (error) throw error;
return flatten<Feature>Row(data);
}
```
**Sempre:**
- `tenant_id` injetado do store (não aceita do payload)
- `owner_id` injetado do uid logado (ignora do payload — clinic-scoped variant pode aceitar explícito)
- `.select(...)` + `.single()` retorna o registro completo
### Update
```js
export async function update<Feature>(id, patch, { tenantId } = {}) {
if (!id) throw new Error('ID inválido.');
if (!patch) throw new Error('Patch vazio.');
const tid = resolveTenantId(tenantId);
const { paciente_id: _dropped, ...safePatch } = patch;
const { data, error } = await supabase
.from('<tabela>')
.update(safePatch)
.eq('id', id)
.eq('tenant_id', tid) // ← defesa em profundidade — RLS reforça no banco
.select(<FEATURE>_SELECT)
.single();
if (error) throw error;
return flatten<Feature>Row(data);
}
```
### Delete
```js
export async function delete<Feature>(id, { tenantId } = {}) {
if (!id) throw new Error('ID inválido.');
const tid = resolveTenantId(tenantId);
const { error } = await supabase
.from('<tabela>')
.delete()
.eq('id', id)
.eq('tenant_id', tid);
if (error) throw error;
return true;
}
```
### List (range query)
```js
export async function list<Feature>({ startISO, endISO, ownerId, tenantId } = {}) {
assertIsoRange(startISO, endISO);
const uid = ownerId || (await getUid());
const tid = resolveTenantId(tenantId);
const { data, error } = await supabase
.from('<tabela>')
.select(<FEATURE>_SELECT)
.eq('tenant_id', tid)
.eq('owner_id', uid)
.gte('inicio_em', startISO)
.lt('inicio_em', endISO)
.order('inicio_em', { ascending: true });
if (error) throw error;
return (data || []).map(flatten<Feature>Row);
}
```
### Clinic-scoped variant (admin/secretaria)
Diferenças em relação ao owner-scoped:
- `tenantId` **obrigatório explícito** (sem default via store — admin pode operar em qualquer tenant onde tem permissão)
- `ownerIds` é array (multi-terapeuta no mosaico) → `sanitizeOwnerIds` antes do `.in(...)`
- Permite definir `owner_id` no create (admin cria pra qualquer terapeuta do tenant)
- Sem `excludeMirror` automático — depende do uso
Referência: `src/features/agenda/services/agendaClinicRepository.js`
---
## 7. Anti-patterns (NÃO fazer)
### ❌ Inline SELECT espalhado
```js
// ❌ em useFoo.js
const { data } = await supabase.from('events').select('id, owner_id, patient_id, ...');
// ❌ em fooRepository.js
const { data } = await supabase.from('events').select('id, owner_id, ...'); // ← divergente
```
✅ Extrair pra `<feature>Selects.js`.
### ❌ `useTenantStore()` em vários arquivos
```js
// ❌ em 5 arquivos diferentes
const tenantStore = useTenantStore();
const tid = tenantStore.activeTenantId;
if (!tid) throw new Error('...');
```
`resolveTenantId(tenantIdArg)` no topo do repo.
### ❌ Aceitar `owner_id` do payload em create owner-scoped
```js
// ❌ permite usuário criar evento "de outro terapeuta"
await supabase.from('events').insert({ ...payload, tenant_id: tid });
```
✅ Sempre injetar `owner_id` do uid logado (sobrescreve qualquer valor do payload).
### ❌ `delete()` sem `.eq('tenant_id', tid)`
```js
// ❌ RLS deveria pegar, mas defesa em profundidade
await supabase.from('events').delete().eq('id', id);
```
✅ Sempre filtra `.eq('tenant_id', tid)` mesmo com RLS ativo.
### ❌ Return null em erro
```js
// ❌
if (error) {
console.error(error);
return null;
}
```
`throw error`. Composable decide o que fazer.
### ❌ Range fechado
```js
// ❌ — `2026-05-20` no `endISO` faz aparecer o dia inteiro do 20
.gte('inicio_em', startISO).lte('inicio_em', endISO)
```
✅ Half-open: `.gte(...).lt(...)`. Caller passa `endISO` como o início do próximo bucket.
### ❌ `paciente_id` (ou outro campo legado) chegando ao banco
A migração já dropou colunas legadas. Strip no `safePayload` evita 400 silencioso.
---
## 8. Checklist de auditoria por módulo
Quando rodar `/audit-module <nome>`, validar:
- [ ] `services/_tenantGuards.js` existe (ou inline se 1 repo só)
- [ ] `services/<feature>Selects.js` existe e exporta `<FEATURE>_SELECT`
- [ ] `services/<feature>Repository.js` é pure functions (sem classe/state)
- [ ] `resolveTenantId(tenantIdArg)` local — não `useTenantStore()` espalhado
- [ ] Toda operação injeta `tenant_id` no insert/update
- [ ] Create owner-scoped injeta `owner_id` do uid logado (ignora do payload)
- [ ] Update/delete filtram `.eq('id').eq('tenant_id', tid)` — defesa em profundidade
- [ ] FKs explícitas nos joins (`<tabela>!<fk_name>`)
- [ ] Errors `throw`, nunca silent
- [ ] Ranges half-open (`gte + lt`)
- [ ] Strip de campos legados em insert/update
- [ ] Clinic-scoped variant (se existe) sem default via store, tenantId obrigatório
- [ ] `flatten<Feature>Row` definido se há joins aninhados
Divergências viram items em `dev_auditoria_items` com:
- `categoria`: `padronizacao`
- `tag`: `padronizacao:<modulo>`
- `severidade`: alta se viola segurança (tenant leak), média se viola convenção, baixa se cosmético
- `arquivo`: path do arquivo
- `solucao`: referência ao item do checklist
---
## 9. Referências
- Canônico: `src/features/agenda/services/`
- Variant clinic: `src/features/agenda/services/agendaClinicRepository.js`
- Tracker: `dev_auditoria_items` com tag `padronizacao:<modulo>`
- Decisões macro: `development/02-auditoria/PADRONIZACAO.md`
@@ -0,0 +1,165 @@
-- ============================================================================
-- Cria tabelas do prontuário clínico
-- ----------------------------------------------------------------------------
-- Núcleo do prontuário: notas clínicas (anamnese, evolução, plano), com
-- versionamento (audit trail) e templates (SOAP/DAP/BIRP/livre).
--
-- Decisões (sessão de modelagem 2026-05-20):
-- • Tabela única `clinical_notes` discriminada por `note_type` (não 1 tabela
-- por tipo). Templates customizáveis exigem flexibilidade.
-- • `content_text` (livre) + `content_structured` (jsonb) coexistem na mesma
-- row — UI prioriza conforme template; busca/edit rápido sempre tem text.
-- • Versionamento via snapshot completo (não diff) em `clinical_note_versions`
-- — restore trivial e audit visualization friendly. Trigger de versionamento
-- criado em migration separada.
-- • Instrumentos de avaliação (GAD-7, PHQ-9, etc) ficam pra Fase 2.
-- • RLS: owner-only (terapeuta responsável). Sem clinic-wide read — CFP exige
-- sigilo entre profissionais. Policies em migration separada.
-- ============================================================================
BEGIN;
-- ──────────────────────────────────────────────────────────────────────────
-- 1. clinical_notes — núcleo do prontuário
-- ──────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.clinical_notes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
owner_id uuid NOT NULL, -- terapeuta responsável
patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE RESTRICT,
session_event_id uuid REFERENCES public.agenda_eventos(id) ON DELETE SET NULL,
note_type text NOT NULL,
template_id uuid, -- FK adicionada após criar templates
title text,
content_text text,
content_structured jsonb,
pinned boolean DEFAULT false NOT NULL,
is_draft boolean DEFAULT false NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
created_by uuid NOT NULL,
updated_by uuid,
deleted_at timestamp with time zone,
deleted_by uuid,
CONSTRAINT clinical_notes_note_type_check CHECK (note_type IN (
'anamnese',
'evolucao_sessao',
'plano_terapeutico',
'observacao_livre',
'resumo_caso'
)),
CONSTRAINT clinical_notes_content_present_check CHECK (
content_text IS NOT NULL OR content_structured IS NOT NULL
)
);
COMMENT ON TABLE public.clinical_notes IS
'Notas clínicas do prontuário (anamnese, evolução de sessão, plano, observações). Owner-only via RLS — CFP exige sigilo.';
COMMENT ON COLUMN public.clinical_notes.session_event_id IS
'Sessão associada (quando aplicável). Anamnese/plano/resumo podem ter NULL.';
COMMENT ON COLUMN public.clinical_notes.content_text IS
'Conteúdo em texto livre (sempre disponível pra busca/edit rápido).';
COMMENT ON COLUMN public.clinical_notes.content_structured IS
'Conteúdo em formato estruturado quando há template ativo (jsonb dos campos preenchidos).';
CREATE INDEX IF NOT EXISTS idx_clinical_notes_patient_recent
ON public.clinical_notes (tenant_id, patient_id, created_at DESC)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_clinical_notes_owner
ON public.clinical_notes (owner_id)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_clinical_notes_session
ON public.clinical_notes (session_event_id)
WHERE session_event_id IS NOT NULL AND deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_clinical_notes_type
ON public.clinical_notes (tenant_id, patient_id, note_type)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_clinical_notes_pinned
ON public.clinical_notes (tenant_id, patient_id)
WHERE pinned = true AND deleted_at IS NULL;
-- ──────────────────────────────────────────────────────────────────────────
-- 2. clinical_note_versions — audit trail (snapshot completo)
-- ──────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.clinical_note_versions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
note_id uuid NOT NULL REFERENCES public.clinical_notes(id) ON DELETE CASCADE,
tenant_id uuid NOT NULL,
version_number integer NOT NULL,
title text,
content_text text,
content_structured jsonb,
change_reason text, -- 'criacao' | 'edicao' | livre
created_at timestamp with time zone DEFAULT now() NOT NULL,
created_by uuid NOT NULL,
CONSTRAINT clinical_note_versions_unique UNIQUE (note_id, version_number)
);
COMMENT ON TABLE public.clinical_note_versions IS
'Snapshot completo de cada versão de clinical_notes. Criado via trigger AFTER INSERT OR UPDATE.';
CREATE INDEX IF NOT EXISTS idx_clinical_note_versions_recent
ON public.clinical_note_versions (note_id, version_number DESC);
CREATE INDEX IF NOT EXISTS idx_clinical_note_versions_audit
ON public.clinical_note_versions (created_by, created_at DESC);
-- ──────────────────────────────────────────────────────────────────────────
-- 3. clinical_note_templates — templates SOAP/DAP/BIRP/anamnese padrão
-- ──────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.clinical_note_templates (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid, -- NULL = template global do sistema
owner_id uuid, -- NULL = template do tenant inteiro
key text NOT NULL, -- 'soap', 'dap', 'birp', 'anamnese_padrao', ...
name text NOT NULL,
note_type text NOT NULL,
description text,
structure jsonb NOT NULL, -- [{key, label, type, required, hint}]
is_system boolean DEFAULT false NOT NULL,
is_global boolean DEFAULT false NOT NULL,
active boolean DEFAULT true NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT clinical_note_templates_note_type_check CHECK (note_type IN (
'anamnese',
'evolucao_sessao',
'plano_terapeutico',
'observacao_livre',
'resumo_caso'
)),
CONSTRAINT clinical_note_templates_scope_check CHECK (
-- Sistema: ambos NULL e is_system=true
-- Tenant-wide: tenant_id presente, owner_id NULL
-- Owner: ambos presentes
(is_system = true AND tenant_id IS NULL AND owner_id IS NULL)
OR (is_system = false AND tenant_id IS NOT NULL)
)
);
COMMENT ON TABLE public.clinical_note_templates IS
'Templates de notas clínicas. Escopo: sistema (is_system, sem tenant), tenant-wide (tenant_id sem owner), owner (ambos).';
CREATE INDEX IF NOT EXISTS idx_clinical_note_templates_active
ON public.clinical_note_templates (note_type)
WHERE active = true;
CREATE INDEX IF NOT EXISTS idx_clinical_note_templates_tenant
ON public.clinical_note_templates (tenant_id, note_type)
WHERE tenant_id IS NOT NULL AND active = true;
CREATE INDEX IF NOT EXISTS idx_clinical_note_templates_owner
ON public.clinical_note_templates (owner_id, note_type)
WHERE owner_id IS NOT NULL AND active = true;
-- ──────────────────────────────────────────────────────────────────────────
-- 4. FK de clinical_notes.template_id (criada agora que templates existe)
-- ──────────────────────────────────────────────────────────────────────────
ALTER TABLE public.clinical_notes
ADD CONSTRAINT clinical_notes_template_fkey
FOREIGN KEY (template_id)
REFERENCES public.clinical_note_templates(id)
ON DELETE SET NULL;
COMMIT;
@@ -0,0 +1,111 @@
-- ============================================================================
-- RLS policies do prontuário clínico
-- ----------------------------------------------------------------------------
-- Padrão MAIS RESTRITIVO que agenda — CFP exige sigilo profissional entre
-- terapeutas do mesmo tenant. Default: APENAS o owner (terapeuta responsável)
-- lê e escreve. Sem clinic-wide read.
--
-- Compartilhamento com supervisor / outro terapeuta vai requerer policy
-- específica baseada em tabela `clinical_note_shares` (Fase 2).
--
-- Templates seguem regra mais aberta:
-- • Sistema (is_system): todos authenticated leem
-- • Tenant-wide (tenant_id): membros do tenant leem; tenant_admin edita
-- • Owner: só o owner lê/edita
-- ============================================================================
BEGIN;
-- ──────────────────────────────────────────────────────────────────────────
-- clinical_notes — owner only
-- ──────────────────────────────────────────────────────────────────────────
ALTER TABLE public.clinical_notes ENABLE ROW LEVEL SECURITY;
CREATE POLICY clinical_notes_owner_select
ON public.clinical_notes FOR SELECT TO authenticated
USING (owner_id = auth.uid() AND deleted_at IS NULL);
CREATE POLICY clinical_notes_owner_insert
ON public.clinical_notes FOR INSERT TO authenticated
WITH CHECK (
owner_id = auth.uid()
AND public.is_tenant_member(tenant_id)
);
CREATE POLICY clinical_notes_owner_update
ON public.clinical_notes FOR UPDATE TO authenticated
USING (owner_id = auth.uid())
WITH CHECK (owner_id = auth.uid());
-- DELETE só por soft-delete (UPDATE deleted_at). Hard delete bloqueado em RLS.
-- Backup/admin pode dropar via psql -U supabase_admin se preciso.
CREATE POLICY clinical_notes_no_hard_delete
ON public.clinical_notes FOR DELETE TO authenticated
USING (false);
-- ──────────────────────────────────────────────────────────────────────────
-- clinical_note_versions — read-only pelo owner da nota
-- ──────────────────────────────────────────────────────────────────────────
ALTER TABLE public.clinical_note_versions ENABLE ROW LEVEL SECURITY;
CREATE POLICY clinical_note_versions_owner_select
ON public.clinical_note_versions FOR SELECT TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.clinical_notes cn
WHERE cn.id = clinical_note_versions.note_id
AND cn.owner_id = auth.uid()
)
);
-- INSERT só via trigger (SECURITY DEFINER). Sem policy de UPDATE/DELETE —
-- versões são imutáveis. Trigger usa role bypass.
CREATE POLICY clinical_note_versions_no_write
ON public.clinical_note_versions FOR INSERT TO authenticated
WITH CHECK (false);
CREATE POLICY clinical_note_versions_no_update
ON public.clinical_note_versions FOR UPDATE TO authenticated
USING (false);
CREATE POLICY clinical_note_versions_no_delete
ON public.clinical_note_versions FOR DELETE TO authenticated
USING (false);
-- ──────────────────────────────────────────────────────────────────────────
-- clinical_note_templates — escopo escalonado
-- ──────────────────────────────────────────────────────────────────────────
ALTER TABLE public.clinical_note_templates ENABLE ROW LEVEL SECURITY;
-- SELECT: sistema (qualquer authenticated) + tenant-wide (membros) + owner (próprio)
CREATE POLICY clinical_note_templates_select
ON public.clinical_note_templates FOR SELECT TO authenticated
USING (
active = true
AND (
is_system = true
OR (tenant_id IS NOT NULL AND public.is_tenant_member(tenant_id))
)
);
-- INSERT/UPDATE/DELETE: só owner ou tenant_admin do tenant
-- Templates do sistema (is_system) nunca alteráveis via UI — só via seed/migration.
CREATE POLICY clinical_note_templates_owner_write
ON public.clinical_note_templates TO authenticated
USING (
is_system = false
AND (
owner_id = auth.uid()
OR (owner_id IS NULL AND public.is_tenant_admin(tenant_id))
)
)
WITH CHECK (
is_system = false
AND (
owner_id = auth.uid()
OR (owner_id IS NULL AND public.is_tenant_admin(tenant_id))
)
);
COMMIT;
@@ -0,0 +1,117 @@
-- ============================================================================
-- Trigger de versionamento automático de clinical_notes
-- ----------------------------------------------------------------------------
-- A cada INSERT ou UPDATE relevante em clinical_notes, cria snapshot completo
-- em clinical_note_versions. Função é SECURITY DEFINER pra bypassar a RLS
-- (que bloqueia INSERT direto em clinical_note_versions).
--
-- Versionamento dispara em:
-- • INSERT — registra criação (version_number = 1)
-- • UPDATE em content_text, content_structured ou title — registra edição
--
-- Mudanças em pinned/is_draft NÃO disparam versionamento (mudança de UI/state,
-- não de conteúdo).
-- ============================================================================
BEGIN;
CREATE OR REPLACE FUNCTION public.fn_clinical_note_version()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
next_version integer;
reason text;
BEGIN
SELECT COALESCE(MAX(version_number), 0) + 1
INTO next_version
FROM public.clinical_note_versions
WHERE note_id = NEW.id;
IF TG_OP = 'INSERT' THEN
reason := 'criacao';
ELSIF TG_OP = 'UPDATE' THEN
IF NEW.deleted_at IS NOT NULL AND OLD.deleted_at IS NULL THEN
reason := 'soft_delete';
ELSIF NEW.deleted_at IS NULL AND OLD.deleted_at IS NOT NULL THEN
reason := 'restore';
ELSE
reason := 'edicao';
END IF;
ELSE
reason := 'desconhecido';
END IF;
INSERT INTO public.clinical_note_versions (
note_id,
tenant_id,
version_number,
title,
content_text,
content_structured,
change_reason,
created_at,
created_by
) VALUES (
NEW.id,
NEW.tenant_id,
next_version,
NEW.title,
NEW.content_text,
NEW.content_structured,
reason,
now(),
COALESCE(NEW.updated_by, NEW.created_by)
);
RETURN NEW;
END;
$$;
COMMENT ON FUNCTION public.fn_clinical_note_version() IS
'Snapshot completo de clinical_notes a cada INSERT/UPDATE relevante. SECURITY DEFINER bypassa RLS pra escrever em clinical_note_versions (que bloqueia INSERT direto).';
CREATE TRIGGER trg_clinical_notes_version_insert
AFTER INSERT ON public.clinical_notes
FOR EACH ROW
EXECUTE FUNCTION public.fn_clinical_note_version();
CREATE TRIGGER trg_clinical_notes_version_update
AFTER UPDATE OF content_text, content_structured, title, deleted_at
ON public.clinical_notes
FOR EACH ROW
WHEN (
OLD.content_text IS DISTINCT FROM NEW.content_text
OR OLD.content_structured IS DISTINCT FROM NEW.content_structured
OR OLD.title IS DISTINCT FROM NEW.title
OR OLD.deleted_at IS DISTINCT FROM NEW.deleted_at
)
EXECUTE FUNCTION public.fn_clinical_note_version();
-- ──────────────────────────────────────────────────────────────────────────
-- Trigger para updated_at automático
-- ──────────────────────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION public.fn_clinical_notes_updated_at()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_clinical_notes_updated_at
BEFORE UPDATE ON public.clinical_notes
FOR EACH ROW
EXECUTE FUNCTION public.fn_clinical_notes_updated_at();
CREATE TRIGGER trg_clinical_note_templates_updated_at
BEFORE UPDATE ON public.clinical_note_templates
FOR EACH ROW
EXECUTE FUNCTION public.fn_clinical_notes_updated_at();
COMMIT;
@@ -0,0 +1,46 @@
-- ============================================================================
-- Liga documents a clinical_notes (preenche FK órfã)
-- ----------------------------------------------------------------------------
-- A coluna `documents.session_note_id` existia desde antes apontando pra uma
-- tabela `session_notes` que nunca foi criada. Agora que `clinical_notes`
-- existe e abrange anamnese/evolução/plano (não só sessão), renomeia pra
-- `clinical_note_id` e adiciona FK constraint.
--
-- PRÉ-CHECK: a query abaixo deve retornar 0 antes de rodar esta migration.
-- SELECT count(*) FROM public.documents WHERE session_note_id IS NOT NULL;
-- Se houver dados, eles são órfãos (referenciam tabela inexistente) — limpar
-- antes de adicionar a FK constraint, ou ela falha.
-- ============================================================================
BEGIN;
-- 1. Limpa eventuais órfãos (FK nunca foi enforced, mas valor pode ter sido
-- setado por código no front antes da migration). Defesa em profundidade.
UPDATE public.documents
SET session_note_id = NULL
WHERE session_note_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM public.clinical_notes cn
WHERE cn.id = documents.session_note_id
);
-- 2. Rename
ALTER TABLE public.documents
RENAME COLUMN session_note_id TO clinical_note_id;
-- 3. FK constraint
ALTER TABLE public.documents
ADD CONSTRAINT documents_clinical_note_fkey
FOREIGN KEY (clinical_note_id)
REFERENCES public.clinical_notes(id)
ON DELETE SET NULL;
-- 4. Index pra reverse lookup (documentos de uma nota)
CREATE INDEX IF NOT EXISTS idx_documents_clinical_note
ON public.documents (clinical_note_id)
WHERE clinical_note_id IS NOT NULL AND deleted_at IS NULL;
COMMENT ON COLUMN public.documents.clinical_note_id IS
'Vínculo opcional a uma nota clínica (anexar PDF a anamnese/evolução). Renomeado de session_note_id em 2026-05-20.';
COMMIT;
@@ -0,0 +1,95 @@
-- ============================================================================
-- RPC accept_tenant_invite — destrava o fluxo de aceitar convite
-- ----------------------------------------------------------------------------
-- Recebe o token UUID do invite. Em uma transação (SECURITY DEFINER):
-- 1. Lê invite ATIVO (não accepted, não revoked, não expired)
-- 2. INSERT em tenant_members com role do invite + user_id = auth.uid()
-- 3. UPDATE invite com accepted_at + accepted_by
--
-- Retorna jsonb { ok, tenant_id, role } em sucesso ou throw com mensagem PT-BR.
--
-- Chamada pelo features/tenantship/services/tenantInvitesRepository.acceptInvite().
-- Stub anterior tava jogando erro PT-BR explicando isso. Agora funciona.
-- ============================================================================
BEGIN;
CREATE OR REPLACE FUNCTION public.accept_tenant_invite(p_token uuid)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_uid uuid;
v_invite record;
v_existing_member record;
BEGIN
-- Quem está aceitando — auth.uid() pega do JWT
v_uid := auth.uid();
IF v_uid IS NULL THEN
RAISE EXCEPTION 'Sessão inválida (sem user autenticado).';
END IF;
-- 1. Lê invite ativo. Lock via FOR UPDATE pra evitar race.
SELECT id, tenant_id, email, role, accepted_at, revoked_at, expires_at
INTO v_invite
FROM public.tenant_invites
WHERE token = p_token
FOR UPDATE;
IF NOT FOUND THEN
RAISE EXCEPTION 'Convite não encontrado. Verifique o link.';
END IF;
IF v_invite.revoked_at IS NOT NULL THEN
RAISE EXCEPTION 'Convite revogado pelo administrador.';
END IF;
IF v_invite.accepted_at IS NOT NULL THEN
RAISE EXCEPTION 'Convite já foi aceito anteriormente.';
END IF;
IF v_invite.expires_at IS NOT NULL AND v_invite.expires_at < now() THEN
RAISE EXCEPTION 'Convite expirado. Peça um novo ao administrador.';
END IF;
-- 2. Idempotência: se já é membro do tenant, só marca invite aceito.
SELECT id, role, status
INTO v_existing_member
FROM public.tenant_members
WHERE tenant_id = v_invite.tenant_id
AND user_id = v_uid
LIMIT 1;
IF v_existing_member.id IS NULL THEN
INSERT INTO public.tenant_members (tenant_id, user_id, role, status)
VALUES (v_invite.tenant_id, v_uid, v_invite.role, 'active');
ELSIF v_existing_member.status <> 'active' THEN
UPDATE public.tenant_members
SET status = 'active', role = v_invite.role
WHERE id = v_existing_member.id;
END IF;
-- (se já está ativo, deixa como tá — convite aceito não rebaixa)
-- 3. Marca invite como aceito
UPDATE public.tenant_invites
SET accepted_at = now(), accepted_by = v_uid
WHERE id = v_invite.id;
RETURN jsonb_build_object(
'ok', true,
'tenant_id', v_invite.tenant_id,
'role', v_invite.role
);
END;
$$;
COMMENT ON FUNCTION public.accept_tenant_invite(uuid) IS
'Aceita convite de membership. SECURITY DEFINER pra criar tenant_members em nome do user logado. Lock FOR UPDATE no invite previne race condition.';
-- Permite que qualquer authenticated chame (precisa do token UUID válido pra entrar).
REVOKE ALL ON FUNCTION public.accept_tenant_invite(uuid) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION public.accept_tenant_invite(uuid) TO authenticated;
COMMIT;
@@ -0,0 +1,138 @@
-- ============================================================================
-- Asaas Gateway — Tier 1 (cobrança de paciente) — schema foundation
-- ----------------------------------------------------------------------------
-- Cria 3 tabelas novas + adiciona 4 colunas em payment_settings.
-- Schema preparado pra Fase 3 do ROADMAP (gateway de pagamento).
--
-- ⚠️ Não habilita o gateway sozinho. Requer:
-- - Edge Functions deployadas
-- - API keys configuradas em payment_settings
-- - Webhook setado no dashboard Asaas
--
-- Ver: development/02-auditoria/DESIGN_ASAAS_GATEWAY.md
-- ============================================================================
BEGIN;
-- ──────────────────────────────────────────────────────────────────────────
-- 1. asaas_customers — mapping patient ↔ Asaas customer
-- ──────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.asaas_customers (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
patient_id uuid NOT NULL REFERENCES public.patients(id) ON DELETE CASCADE,
asaas_customer_id text NOT NULL,
environment text NOT NULL DEFAULT 'sandbox' CHECK (environment IN ('sandbox', 'prod')),
-- dados cacheados (sincronizados quando atualizar patient)
name text NOT NULL,
email text,
cpf_cnpj text,
phone text,
address jsonb,
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NOT NULL,
deleted_at timestamptz,
CONSTRAINT asaas_customers_unique_per_env UNIQUE (tenant_id, patient_id, environment)
);
COMMENT ON TABLE public.asaas_customers IS
'Mapping de pacientes para Asaas customers (1:1 por environment). Cacheado pra evitar re-criação a cada cobrança.';
CREATE INDEX IF NOT EXISTS idx_asaas_customers_lookup
ON public.asaas_customers (tenant_id, patient_id)
WHERE deleted_at IS NULL;
-- ──────────────────────────────────────────────────────────────────────────
-- 2. asaas_payments — 1 row por cobrança gerada
-- ──────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.asaas_payments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
financial_record_id uuid NOT NULL REFERENCES public.financial_records(id) ON DELETE CASCADE,
asaas_customer_id uuid REFERENCES public.asaas_customers(id),
asaas_payment_id text NOT NULL,
asaas_invoice_id text,
environment text NOT NULL DEFAULT 'sandbox' CHECK (environment IN ('sandbox', 'prod')),
billing_type text NOT NULL CHECK (billing_type IN ('PIX', 'BOLETO', 'CREDIT_CARD', 'UNDEFINED')),
status text NOT NULL,
value numeric(10, 2) NOT NULL,
net_value numeric(10, 2),
due_date date NOT NULL,
payment_date timestamptz,
invoice_url text,
payment_url text,
bank_slip_url text,
pix_qr_code text,
pix_copy_paste text,
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NOT NULL,
cancelled_at timestamptz,
CONSTRAINT asaas_payments_unique_per_env UNIQUE (asaas_payment_id, environment)
);
COMMENT ON TABLE public.asaas_payments IS
'Cobranças geradas no Asaas. Status raw do Asaas; mapeamento pra financial_records.status acontece no JS.';
CREATE INDEX IF NOT EXISTS idx_asaas_payments_record
ON public.asaas_payments (financial_record_id);
CREATE INDEX IF NOT EXISTS idx_asaas_payments_lookup
ON public.asaas_payments (tenant_id, status, due_date)
WHERE cancelled_at IS NULL;
-- ──────────────────────────────────────────────────────────────────────────
-- 3. asaas_webhook_events — idempotência + audit
-- ──────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS public.asaas_webhook_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
event_id text,
event_type text NOT NULL,
asaas_payment_id text,
payload jsonb NOT NULL,
processed_at timestamptz,
processing_error text,
received_at timestamptz DEFAULT now() NOT NULL
);
COMMENT ON TABLE public.asaas_webhook_events IS
'Audit + idempotência de webhooks Asaas. event_id usado pra dedupe (Asaas faz retry).';
-- event_id UNIQUE quando preenchido (Asaas nem sempre manda)
CREATE UNIQUE INDEX IF NOT EXISTS idx_asaas_webhook_events_event_id
ON public.asaas_webhook_events (event_id)
WHERE event_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_asaas_webhook_events_payment
ON public.asaas_webhook_events (asaas_payment_id);
CREATE INDEX IF NOT EXISTS idx_asaas_webhook_events_unprocessed
ON public.asaas_webhook_events (received_at)
WHERE processed_at IS NULL;
-- ──────────────────────────────────────────────────────────────────────────
-- 4. payment_settings — colunas pra config Asaas por tenant
-- ──────────────────────────────────────────────────────────────────────────
-- API keys plaintext nesta migration. Em produção, mover pra pgsodium/Vault.
ALTER TABLE public.payment_settings
ADD COLUMN IF NOT EXISTS asaas_api_key_sandbox text,
ADD COLUMN IF NOT EXISTS asaas_api_key_prod text,
ADD COLUMN IF NOT EXISTS asaas_environment text DEFAULT 'sandbox'
CHECK (asaas_environment IN ('sandbox', 'prod')),
ADD COLUMN IF NOT EXISTS asaas_webhook_token text,
ADD COLUMN IF NOT EXISTS asaas_enabled boolean DEFAULT false NOT NULL;
COMMENT ON COLUMN public.payment_settings.asaas_api_key_sandbox IS
'API key Asaas SANDBOX. PLAINTEXT por enquanto — migrar pra Vault em prod.';
COMMENT ON COLUMN public.payment_settings.asaas_api_key_prod IS
'API key Asaas PRODUÇÃO. PLAINTEXT por enquanto — migrar pra Vault em prod.';
COMMENT ON COLUMN public.payment_settings.asaas_environment IS
'Qual key usar: sandbox (testes) ou prod (real). Default sandbox por segurança.';
COMMENT ON COLUMN public.payment_settings.asaas_webhook_token IS
'Token customizado pra webhook receiver validar. Setar mesmo valor no dashboard Asaas.';
COMMENT ON COLUMN public.payment_settings.asaas_enabled IS
'Flag que controla se gateway Asaas está habilitado pro tenant. Default false (opt-in).';
COMMIT;
@@ -0,0 +1,72 @@
-- ============================================================================
-- Asaas Gateway — RLS policies
-- ----------------------------------------------------------------------------
-- Owner-scoped: cada terapeuta vê só os customers/payments do seu tenant.
-- INSERT/UPDATE bloqueado client-side — só Edge Functions (service role)
-- podem escrever. Browser só lê (pra exibir QR code, status, etc).
--
-- API keys em payment_settings: já tem RLS (não duplica).
-- ============================================================================
BEGIN;
-- ──────────────────────────────────────────────────────────────────────────
-- asaas_customers
-- ──────────────────────────────────────────────────────────────────────────
ALTER TABLE public.asaas_customers ENABLE ROW LEVEL SECURITY;
CREATE POLICY asaas_customers_member_select
ON public.asaas_customers FOR SELECT TO authenticated
USING (public.is_tenant_member(tenant_id));
-- INSERT/UPDATE/DELETE bloqueados — Edge Functions usam service_role que bypassa RLS
CREATE POLICY asaas_customers_no_client_write
ON public.asaas_customers FOR INSERT TO authenticated
WITH CHECK (false);
CREATE POLICY asaas_customers_no_client_update
ON public.asaas_customers FOR UPDATE TO authenticated
USING (false);
CREATE POLICY asaas_customers_no_client_delete
ON public.asaas_customers FOR DELETE TO authenticated
USING (false);
-- ──────────────────────────────────────────────────────────────────────────
-- asaas_payments
-- ──────────────────────────────────────────────────────────────────────────
ALTER TABLE public.asaas_payments ENABLE ROW LEVEL SECURITY;
CREATE POLICY asaas_payments_member_select
ON public.asaas_payments FOR SELECT TO authenticated
USING (public.is_tenant_member(tenant_id));
CREATE POLICY asaas_payments_no_client_write
ON public.asaas_payments FOR INSERT TO authenticated
WITH CHECK (false);
CREATE POLICY asaas_payments_no_client_update
ON public.asaas_payments FOR UPDATE TO authenticated
USING (false);
CREATE POLICY asaas_payments_no_client_delete
ON public.asaas_payments FOR DELETE TO authenticated
USING (false);
-- ──────────────────────────────────────────────────────────────────────────
-- asaas_webhook_events
-- ──────────────────────────────────────────────────────────────────────────
-- Audit table — saas_admin lê pra debug. Members não veem.
ALTER TABLE public.asaas_webhook_events ENABLE ROW LEVEL SECURITY;
CREATE POLICY asaas_webhook_events_saas_admin_select
ON public.asaas_webhook_events FOR SELECT TO authenticated
USING (public.is_saas_admin());
CREATE POLICY asaas_webhook_events_no_client_write
ON public.asaas_webhook_events FOR INSERT TO authenticated
WITH CHECK (false);
CREATE POLICY asaas_webhook_events_no_update
ON public.asaas_webhook_events FOR UPDATE TO authenticated
USING (false);
CREATE POLICY asaas_webhook_events_no_delete
ON public.asaas_webhook_events FOR DELETE TO authenticated
USING (false);
COMMIT;
@@ -0,0 +1,71 @@
-- ============================================================================
-- Compliance CFP — Tipo de registro profissional (ROADMAP item #5)
-- ----------------------------------------------------------------------------
-- Adiciona campos de registro profissional ao perfil. Necessário pra emissão
-- de recibos/laudos válidos (CFP exige tipo, número e UF do conselho).
--
-- Conselhos comuns no Brasil:
-- CRP — Psicólogo
-- CRM — Médico
-- CRFa — Fonoaudiólogo
-- CREFITO — Fisioterapeuta / Terapeuta Ocupacional
-- CRESS — Assistente Social
-- CRN — Nutricionista
-- RMS — Residência Multiprofissional (Saúde)
-- outro — Catch-all (campo livre na UI)
-- ============================================================================
BEGIN;
ALTER TABLE public.profiles
ADD COLUMN IF NOT EXISTS professional_registration_type text,
ADD COLUMN IF NOT EXISTS professional_registration_number text,
ADD COLUMN IF NOT EXISTS professional_registration_uf text;
-- CHECK não pode ser ADD IF NOT EXISTS — guard com DO block
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'profiles_registration_type_check'
) THEN
ALTER TABLE public.profiles
ADD CONSTRAINT profiles_registration_type_check CHECK (
professional_registration_type IS NULL
OR professional_registration_type = ANY (ARRAY[
'CRP',
'CRM',
'CRFa',
'CREFITO',
'CRESS',
'CRN',
'RMS',
'outro'
])
);
END IF;
END $$;
-- UF check (regex pra 2 chars uppercase ou NULL)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'profiles_registration_uf_check'
) THEN
ALTER TABLE public.profiles
ADD CONSTRAINT profiles_registration_uf_check CHECK (
professional_registration_uf IS NULL
OR professional_registration_uf ~ '^[A-Z]{2}$'
);
END IF;
END $$;
COMMENT ON COLUMN public.profiles.professional_registration_type IS
'Tipo de registro profissional. Obrigatório pra emitir recibos/laudos. ROADMAP item #5.';
COMMENT ON COLUMN public.profiles.professional_registration_number IS
'Número do registro (ex: 06/12345 ou 123456). Formato livre — UI ajuda com mask se relevante.';
COMMENT ON COLUMN public.profiles.professional_registration_uf IS
'UF do conselho (2 chars uppercase). Alguns conselhos exigem regionalização (CRP 06/SP, CRP 03/BA).';
COMMIT;
@@ -0,0 +1,79 @@
-- ============================================================================
-- Compliance CFP — Especialidades do profissional (ROADMAP item #9)
-- ----------------------------------------------------------------------------
-- Catálogo de especialidades/abordagens + join many-to-many com profiles.
-- Profissional pode ter múltiplas especialidades (clínica + jurídica, etc).
-- ============================================================================
BEGIN;
CREATE TABLE IF NOT EXISTS public.specialties (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
key text UNIQUE NOT NULL,
name text NOT NULL,
category text,
is_system boolean DEFAULT false NOT NULL,
active boolean DEFAULT true NOT NULL,
created_at timestamptz DEFAULT now() NOT NULL
);
COMMENT ON TABLE public.specialties IS
'Catálogo global de especialidades/abordagens psicológicas (ROADMAP item #9). is_system=true pra entries seedadas.';
CREATE INDEX IF NOT EXISTS idx_specialties_active ON public.specialties (active, category, name);
CREATE TABLE IF NOT EXISTS public.profile_specialties (
profile_id uuid NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
specialty_id uuid NOT NULL REFERENCES public.specialties(id) ON DELETE RESTRICT,
other_label text,
created_at timestamptz DEFAULT now() NOT NULL,
PRIMARY KEY (profile_id, specialty_id)
);
COMMENT ON TABLE public.profile_specialties IS
'M:N entre profile e specialty. other_label preenchido só quando specialty.key=outra (custom user-defined).';
CREATE INDEX IF NOT EXISTS idx_profile_specialties_profile ON public.profile_specialties (profile_id);
-- ──────────────────────────────────────────────────────────────────────────
-- RLS
-- ──────────────────────────────────────────────────────────────────────────
ALTER TABLE public.specialties ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.profile_specialties ENABLE ROW LEVEL SECURITY;
-- specialties: read-only pra todos authenticated (catálogo público); só saas_admin escreve
CREATE POLICY specialties_authenticated_read
ON public.specialties FOR SELECT TO authenticated
USING (active = true);
CREATE POLICY specialties_saas_admin_write
ON public.specialties TO authenticated
USING (public.is_saas_admin())
WITH CHECK (public.is_saas_admin());
-- profile_specialties: cada user gerencia o próprio
CREATE POLICY profile_specialties_owner_select
ON public.profile_specialties FOR SELECT TO authenticated
USING (profile_id = auth.uid());
CREATE POLICY profile_specialties_owner_insert
ON public.profile_specialties FOR INSERT TO authenticated
WITH CHECK (profile_id = auth.uid());
CREATE POLICY profile_specialties_owner_delete
ON public.profile_specialties FOR DELETE TO authenticated
USING (profile_id = auth.uid());
-- Tenant_admin pode VER specialties dos membros (pra cards públicos / perfil clínica)
CREATE POLICY profile_specialties_tenant_admin_read
ON public.profile_specialties FOR SELECT TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.tenant_members tm
WHERE tm.user_id = profile_specialties.profile_id
AND public.is_tenant_admin(tm.tenant_id)
)
);
COMMIT;
@@ -0,0 +1,151 @@
-- ============================================================================
-- Seed dos templates do sistema de prontuário clínico
-- ----------------------------------------------------------------------------
-- Templates is_system=true, sem tenant_id, sem owner_id.
-- Cobrem os 4 tipos mais comuns de nota clínica em psicologia:
-- • Anamnese padrão CFP-style
-- • Evolução: SOAP / DAP / BIRP
-- • Plano terapêutico padrão
--
-- structure jsonb segue schema:
-- [
-- { key, label, type, required?, hint?, options? },
-- ...
-- ]
-- type: 'text' | 'textarea' | 'select' | 'date' | 'multiselect'
-- ============================================================================
BEGIN;
-- ──────────────────────────────────────────────────────────────────────────
-- 1. Anamnese padrão (CFP)
-- ──────────────────────────────────────────────────────────────────────────
INSERT INTO public.clinical_note_templates (
key, name, note_type, description, structure, is_system, is_global, active
) VALUES (
'anamnese_padrao',
'Anamnese Padrão',
'anamnese',
'Estrutura padrão de anamnese clínica em psicologia. Pode ser preenchida em 1-3 sessões iniciais.',
'[
{"key": "queixa_principal", "label": "Queixa principal", "type": "textarea", "required": true, "hint": "O que trouxe o paciente à terapia"},
{"key": "historia_queixa", "label": "História da queixa", "type": "textarea", "hint": "Quando começou, evolução, fatores agravantes/atenuantes"},
{"key": "historia_vida", "label": "História de vida", "type": "textarea", "hint": "Infância, adolescência, eventos marcantes"},
{"key": "antecedentes_psicologicos", "label": "Antecedentes psicológicos", "type": "textarea", "hint": "Tratamentos anteriores, medicações, internações"},
{"key": "antecedentes_medicos", "label": "Antecedentes médicos", "type": "textarea", "hint": "Doenças, cirurgias, medicações em uso"},
{"key": "antecedentes_familiares", "label": "Antecedentes familiares", "type": "textarea", "hint": "Histórico familiar de transtornos psicológicos/psiquiátricos"},
{"key": "vida_atual_relacionamentos", "label": "Relacionamentos atuais", "type": "textarea"},
{"key": "vida_atual_trabalho_estudo", "label": "Trabalho / estudo atual", "type": "textarea"},
{"key": "hipoteses_iniciais", "label": "Hipóteses iniciais", "type": "textarea", "hint": "Hipóteses do terapeuta — não compartilhar com paciente"},
{"key": "plano_inicial", "label": "Plano terapêutico inicial", "type": "textarea"}
]'::jsonb,
true,
true,
true
);
-- ──────────────────────────────────────────────────────────────────────────
-- 2. Evolução SOAP (Subjective, Objective, Assessment, Plan)
-- ──────────────────────────────────────────────────────────────────────────
INSERT INTO public.clinical_note_templates (
key, name, note_type, description, structure, is_system, is_global, active
) VALUES (
'soap',
'Evolução SOAP',
'evolucao_sessao',
'Padrão internacional: Subjetivo (relato do paciente), Objetivo (observações), Avaliação (análise), Plano (próximos passos).',
'[
{"key": "subjetivo", "label": "S — Subjetivo", "type": "textarea", "required": true, "hint": "O que o paciente relatou; humor; queixas verbalizadas"},
{"key": "objetivo", "label": "O — Objetivo", "type": "textarea", "hint": "Observações do terapeuta: comportamento, afeto, aparência, postura"},
{"key": "avaliacao", "label": "A — Avaliação", "type": "textarea", "required": true, "hint": "Análise clínica, hipóteses, evolução"},
{"key": "plano", "label": "P — Plano", "type": "textarea", "required": true, "hint": "Intervenções planejadas, tarefas, foco da próxima sessão"}
]'::jsonb,
true,
true,
true
);
-- ──────────────────────────────────────────────────────────────────────────
-- 3. Evolução DAP (Data, Assessment, Plan)
-- ──────────────────────────────────────────────────────────────────────────
INSERT INTO public.clinical_note_templates (
key, name, note_type, description, structure, is_system, is_global, active
) VALUES (
'dap',
'Evolução DAP',
'evolucao_sessao',
'Mais conciso que SOAP: Dados (relato + observações), Avaliação, Plano.',
'[
{"key": "dados", "label": "D — Dados", "type": "textarea", "required": true, "hint": "Relato + observações em texto único"},
{"key": "avaliacao", "label": "A — Avaliação", "type": "textarea", "required": true},
{"key": "plano", "label": "P — Plano", "type": "textarea", "required": true}
]'::jsonb,
true,
true,
true
);
-- ──────────────────────────────────────────────────────────────────────────
-- 4. Evolução BIRP (Behavior, Intervention, Response, Plan)
-- ──────────────────────────────────────────────────────────────────────────
INSERT INTO public.clinical_note_templates (
key, name, note_type, description, structure, is_system, is_global, active
) VALUES (
'birp',
'Evolução BIRP',
'evolucao_sessao',
'Foco em intervenção: Comportamento observado, Intervenção aplicada, Resposta do paciente, Plano.',
'[
{"key": "behavior", "label": "B — Comportamento", "type": "textarea", "required": true, "hint": "Comportamento/queixa observada na sessão"},
{"key": "intervention", "label": "I — Intervenção", "type": "textarea", "required": true, "hint": "Técnicas ou abordagens aplicadas pelo terapeuta"},
{"key": "response", "label": "R — Resposta", "type": "textarea", "required": true, "hint": "Como o paciente respondeu à intervenção"},
{"key": "plano", "label": "P — Plano", "type": "textarea", "required": true}
]'::jsonb,
true,
true,
true
);
-- ──────────────────────────────────────────────────────────────────────────
-- 5. Evolução livre (CFP-style — texto único)
-- ──────────────────────────────────────────────────────────────────────────
INSERT INTO public.clinical_note_templates (
key, name, note_type, description, structure, is_system, is_global, active
) VALUES (
'evolucao_livre',
'Evolução Livre',
'evolucao_sessao',
'Texto único, sem estrutura — pra quem prefere prosa contínua estilo CFP tradicional.',
'[
{"key": "evolucao", "label": "Evolução", "type": "textarea", "required": true, "hint": "Texto único descrevendo a sessão"}
]'::jsonb,
true,
true,
true
);
-- ──────────────────────────────────────────────────────────────────────────
-- 6. Plano terapêutico padrão
-- ──────────────────────────────────────────────────────────────────────────
INSERT INTO public.clinical_note_templates (
key, name, note_type, description, structure, is_system, is_global, active
) VALUES (
'plano_terapeutico_padrao',
'Plano Terapêutico Padrão',
'plano_terapeutico',
'Estrutura básica de plano: objetivos, estratégia, recursos, prazo estimado.',
'[
{"key": "objetivos_gerais", "label": "Objetivos gerais", "type": "textarea", "required": true, "hint": "O que o paciente quer alcançar"},
{"key": "objetivos_especificos", "label": "Objetivos específicos / metas", "type": "textarea", "hint": "Metas mensuráveis"},
{"key": "estrategia_terapeutica", "label": "Estratégia terapêutica", "type": "textarea", "required": true, "hint": "Abordagem teórica, técnicas previstas"},
{"key": "recursos_indicados", "label": "Recursos / intervenções indicadas", "type": "textarea"},
{"key": "duracao_estimada", "label": "Duração estimada", "type": "text", "hint": "Ex: 6 meses, indeterminado"},
{"key": "criterios_alta", "label": "Critérios de alta", "type": "textarea"},
{"key": "encaminhamentos", "label": "Encaminhamentos paralelos", "type": "textarea", "hint": "Psiquiatria, médico, outras especialidades"}
]'::jsonb,
true,
true,
true
);
COMMIT;
@@ -0,0 +1,57 @@
-- ============================================================================
-- Seed: Especialidades do sistema (ROADMAP item #9)
-- ----------------------------------------------------------------------------
-- Lista canônica de especialidades + abordagens psicológicas no Brasil.
-- is_system=true; usuário escolhe múltiplas; 'outra' permite custom via
-- profile_specialties.other_label.
-- ============================================================================
BEGIN;
INSERT INTO public.specialties (key, name, category, is_system, active) VALUES
-- Especialidades CFP (psicologia)
('psicologia_clinica', 'Psicologia Clínica', 'psicologia', true, true),
('psicologia_hospitalar', 'Psicologia Hospitalar', 'psicologia', true, true),
('neuropsicologia', 'Neuropsicologia', 'psicologia', true, true),
('psicologia_organizacional', 'Psicologia Organizacional e do Trabalho', 'psicologia', true, true),
('psicologia_escolar', 'Psicologia Escolar e Educacional', 'psicologia', true, true),
('psicologia_juridica', 'Psicologia Jurídica', 'psicologia', true, true),
('psicologia_esporte', 'Psicologia do Esporte', 'psicologia', true, true),
('psicologia_social', 'Psicologia Social', 'psicologia', true, true),
('psicologia_transito', 'Psicologia do Trânsito', 'psicologia', true, true),
-- Abordagens teóricas
('psicanalise', 'Psicanálise', 'abordagem', true, true),
('tcc', 'Terapia Cognitivo-Comportamental (TCC)', 'abordagem', true, true),
('psicodrama', 'Psicodrama', 'abordagem', true, true),
('gestalt_terapia', 'Gestalt-terapia', 'abordagem', true, true),
('analise_comportamento', 'Análise do Comportamento (ABA)', 'abordagem', true, true),
('humanista', 'Abordagem Humanista (Rogers)', 'abordagem', true, true),
('sistemica_familiar', 'Terapia Sistêmica Familiar', 'abordagem', true, true),
('logoterapia', 'Logoterapia (Frankl)', 'abordagem', true, true),
('analitica_jung', 'Psicologia Analítica (Jung)', 'abordagem', true, true),
-- Públicos
('infantil', 'Atendimento Infantil', 'publico', true, true),
('adolescentes', 'Atendimento de Adolescentes', 'publico', true, true),
('casais', 'Terapia de Casal', 'publico', true, true),
('familia', 'Terapia Familiar', 'publico', true, true),
('grupos', 'Atendimento de Grupos', 'publico', true, true),
('idosos', 'Atendimento de Idosos / Gerontologia', 'publico', true, true),
('lgbtqia', 'Atendimento LGBTQIA+', 'publico', true, true),
-- Temas
('ansiedade', 'Transtornos de Ansiedade', 'tema', true, true),
('depressao', 'Depressão', 'tema', true, true),
('tdah', 'TDAH', 'tema', true, true),
('autismo', 'Transtorno do Espectro Autista', 'tema', true, true),
('luto', 'Luto e Perdas', 'tema', true, true),
('dependencia_quimica', 'Dependência Química', 'tema', true, true),
('transtornos_alimentares', 'Transtornos Alimentares', 'tema', true, true),
('trauma', 'Trauma e Estresse Pós-Traumático', 'tema', true, true),
-- Catch-all
('outra', 'Outra', 'outro', true, true)
ON CONFLICT (key) DO NOTHING;
COMMIT;
+302
View File
@@ -0,0 +1,302 @@
# Audit Baseline — 6 Módulos vs Blueprints
> **Data:** 2026-05-20
> **Método:** 6 agentes Explore em paralelo, cada um auditou 1 módulo contra os 3 blueprints (repository, composable, quick-create overlay)
> **Saída:** mapa exato do trabalho da Fase 1 da Padronização Sweep
---
## Sumário Executivo
| # | Módulo | Estado | Alta | Média | Baixa | Bloqueador |
|---|---|---|---|---|---|---|
| 1 | Home / Components | Parcial | 3 | 2 | 2 | — |
| 2 | Pacientes | Parcial | 4 | 6 | 2 | — |
| 3 | Prontuário | **Embrionário** | 3 | 3 | 0 | Schema clínico ausente |
| 4 | Financeiro | **Órfão** | 6 | 3 | 1 | Overlap com agenda + double-billing risk |
| 5 | Multi-tenant | Parcial | 2 | 3 | 2 | **Convites/membership inexistem** |
| 6 | Notificações | **Embrionário** | 5 | 3 | 1 | 3 canais fragmentados, SMS envio só stub |
**Totais:** 23 alta · 20 média · 8 baixa = **51 divergências catalogadas** + 4 gaps estruturais.
---
## Surpresas (descobertas que mudam o plano)
### 🚨 1. Convites/membership de tenant — gap apenas no front (CORRIGIDO 2026-05-20)
Agente Multi-tenant disse: **não existe** repository/composable pra `sendInvite(tenantId, email)`, `acceptInvite(inviteId)`, `listTenantMembers(tenantId)`. **Correção:** a tabela `public.tenant_invites` JÁ EXISTE no schema (`tenants_multi_tenant.sql:100`) com campos completos (id, tenant_id, email, role CHECK ['therapist','secretary'], token, invited_by, expires_at default 7d, accepted_at/by, revoked_at/by). Falta APENAS UI + composables/services no front.
Recomendação: criar `features/tenantship/` com services + composables + página `/admin/members` usando a tabela existente. Sem migration de schema necessária. Reduz escopo de 0.5.D.
### 🚨 2. Lógica de billing duplicada agenda ↔ financeiro — risco de double-billing
`useAgendaFinanceiro.gerarCobrancaManual()` (composables raiz) e `useFinancialRecords.createRecord()` (composables raiz) **chamam a mesma RPC** `create_financial_record_for_session`. Sem coordenação = race condition silenciosa.
`useAgendaFinanceiro.handleStatusChange()` ainda relê `financial_records` direto via `.select('id').eq('agenda_evento_id', ...)` — query que deveria viver só no useFinancialRecords.
Recomendação: consolidar em 1 composable orquestrador, ou separar responsabilidades com coordenação explícita via fila.
### 🚨 3. Quick-create overlay — promotion criteria atingida ANTES da hora
Blueprint documentei como "agenda-only, promover quando aparecer 2º caso de uso". Agente Home descobriu **3 quick-create candidatos JÁ em produção**, fora da agenda:
- `src/components/CadastroRapidoMedico.vue` (supabase direto)
- `src/components/CadastroRapidoConvenio.vue` (supabase direto)
- `src/components/ComponentCadastroRapido.vue` (genérico, supabase direto)
São o 2º, 3º e 4º casos. **Promover agora** muda o blueprint de "agenda-only" pra universal, e dá fix em 3 componentes ao mesmo tempo.
### 🚨 4. Prontuário sem schema clínico
Agente Prontuário: o "Prontuário" hoje é shell de abas que reusa `usePatientSessions`. Schema vazio pra anamnese, evolução clínica, plano terapêutico. **Não dá pra padronizar antes de modelar.**
Recomendação: adicionar etapa "modelagem schema clínico" como pré-requisito pro módulo 3 da Fase 1.
### 🟡 5. `error = ref(null)` vs `ref('')` confirmado como divergência sistêmica
Aparece em pacientes, financeiro, alguns lugares de notificações. Confirma a canonicalização do composable blueprint (`''` default). Fix mecânico, fácil de aplicar.
### 🟡 6. Setup Wizard tem 8 queries supabase inline
Já estava no `project_graphify_findings_20260504` ("Setup Wizard cohesion 0.05"). Agora quantificado: 8 queries (linhas 419, 429, 446, 595, 626, 656, 681, 706). Fix: criar `setupRepository.js` + `useSetupWizard.js`.
---
## Cross-cutting patterns (não específicos a 1 módulo)
| Pattern | Onde aparece | Severidade | Fix |
|---|---|---|---|
| `error = ref(null)` | Pacientes, Financeiro, partes de Notificações | Média | Mecânico: `ref('')` |
| `supabase.from(...)` em composable | Pacientes (4 composables), Financeiro (2), Notificações (3+), Multi-tenant (1) | Alta | Extrair pra repository |
| SELECT inline em vez de constante | Pacientes (3), Financeiro, Notificações | Média | Extrair pra `<feature>Selects.js` |
| UPDATE/DELETE sem `.eq('tenant_id', tid)` | Pacientes (2), Financeiro (3) | Alta | Defesa em profundidade |
| `getUid()` / `useTenantStore()` duplicados | Múltiplos composables | Baixa | Helper compartilhado |
| State em variável módulo (vaza entre instâncias) | `usePatientFinancial._lastPatientId` | Alta | Mover DENTRO da `function use*()` |
| `_tenantGuards.js` ausente em todo módulo não-agenda | Todos | Média | Replicar pattern |
---
## Detalhamento por Módulo
### 1. Home / Components base
**Estado:** Parcial. Falta camada de repository. 3 quick-creates espalhados fazem supabase direto.
**Arquivos-chave:**
- `src/views/pages/HomeCards.vue` — roteador de perfis + RPC de auditoria interna
- `src/layout/melissa/MelissaLayout.vue` — orquestrador (~90 imports, monolítico)
- `src/components/CadastroRapidoMedico.vue` — quick-create supabase direto
- `src/components/CadastroRapidoConvenio.vue` — quick-create supabase direto
- `src/components/ComponentCadastroRapido.vue` — quick-create genérico supabase direto
**Divergências:**
| Sev | Tipo | Local | Problema | Fix |
|---|---|---|---|---|
| ~~Alta~~ ✅ | quick-create | `CadastroRapidoMedico.vue:150` | ~~`supabase.from()` direto sem repository~~ | **RESOLVIDO 2026-05-20 (M1.1):** `features/medicos/services/medicosRepository.js` criado + componente refatorado pra usar `useMedicos` composable |
| ~~Alta~~ ✅ | quick-create | `CadastroRapidoConvenio.vue:98` | ~~`supabase.from()` inline~~ | **RESOLVIDO 2026-05-20 (M1.2):** `features/insurance/services/insurancePlansRepository.js` criado + componente usa `useInsurancePlans` composable. Bônus: agenda `InsurancePlanQuickCreateDialog.vue` também migrado. |
| ~~Alta~~ ✅ | quick-create | `ComponentCadastroRapido.vue:263` | ~~`insert()` sem validação `tenant_id`+`owner_id`~~ | **RESOLVIDO 2026-05-20 (M1.3):** componente usa `usePatients.create()`; tenant resolvido via `getMyActiveMember()` (helper novo em tenantship); repository injeta `owner_id = auth.uid()` sempre, ignora payload. |
| ~~Média~~ ✅ | composable | `CadastroRapidoMedico.vue:49-58` | ~~`getTenantId()` via fallback query em vez de store~~ | **RESOLVIDO 2026-05-20 (M1.1):** removido — repository usa `resolveTenantId()` canônico |
| ~~Média~~ ✅ | composable | `CadastroRapidoConvenio.vue:94-100` | ~~`loadPlans()` sem `.eq('tenant_id', tid)`~~ | **RESOLVIDO 2026-05-20 (M1.2):** repository agora filtra por tenant_id + owner_id |
| ~~Baixa~~ ✅ | outro | `HomeCards.vue:23-33` | ~~`TEST_ACCOUNTS` hardcoded~~ | **RESOLVIDO 2026-05-20 (M1.4):** extraído pra `src/config/devTestAccounts.js` |
| Baixa | outro | `MelissaLayout.vue:1-150` | 90+ imports, monolítico | Refactor Fase 2 (M1.6 — sessão dedicada) |
### 2. Pacientes
**Estado:** Parcial. `patientsRepository` é o ÚNICO repo padronizado fora da agenda (8/10 conformidade). Composables têm violações de camada.
**Arquivos-chave:**
- `src/features/patients/services/patientsRepository.js` — referência parcial
- `src/features/patients/composables/usePatients.js` — thin wrapper (falha em error type)
- `src/features/patients/composables/usePatientDetail.js` — supabase direto
- `src/features/patients/composables/usePatientFinancial.js` — supabase direto + estado módulo
- `src/features/patients/composables/usePatientSessions.js` — supabase direto
- `src/components/ui/PatientCreatePopover.vue` — padrão OK (não é quick-create overlay)
**Divergências:**
| Sev | Tipo | Local | Problema | Fix |
|---|---|---|---|---|
| ~~Alta~~ ✅ | repo | `patientsRepository.js:64` | ~~`createPatient` aceita `owner_id` do payload~~ | **RESOLVIDO 2026-05-20 (spillover M1.3):** repository sempre injeta `owner_id = await getUid()`; strip `owner_id` do payload via destructure. |
| ~~Alta~~ ✅ | composable | `usePatientDetail.js:13-40` | ~~supabase direto em 4 funções~~ | **RESOLVIDO 2026-05-20 (M2.2):** 4 funções migradas pra patientsRepository |
| ~~Alta~~ ✅ | composable | `usePatientFinancial.js:21,156-164` | ~~`_lastPatientId` em variável módulo + supabase direto~~ | **RESOLVIDO 2026-05-20 (M2.3):** state movido DENTRO da function; mutations via repository |
| ~~Alta~~ ✅ | composable | `usePatientSessions.js:33-44, 127-182` | ~~supabase direto em 2 mutations~~ | **RESOLVIDO 2026-05-20 (M2.4):** list+create+updateStatus via repository (com helper findSessionByRecurrence pra materialização) |
| ~~Média~~ ✅ | composable | `usePatients.js:22` | ~~`error = ref(null)` viola canon~~ | **RESOLVIDO 2026-05-20 (spillover M1.3):** `error = ref('')` canon do composable-blueprint. |
| ~~Média~~ ✅ | composable | `usePatientDetail.js:69` | ~~Funções internas retornam `null` silencioso em erro~~ | **RESOLVIDO 2026-05-20 (M2.2):** repository functions throw em vez de return null |
| Média | composable | `usePatientFinancial.js:149-191, usePatientSessions.js:140-182` | Mutations retornam `{ok, data?, error?}` em vez de throw | Padrão preservado por compat com callers; fix posterior em sessão dedicada |
| ~~Média~~ ✅ | composable | `usePatientRecurrences.js:34` | ~~`.select('*')` inline~~ | **RESOLVIDO 2026-05-20 (M2.1):** `PATIENT_RECURRENCE_RULES_SELECT` em patientsSelects.js |
| ~~Média~~ ✅ | composable | `usePatientMessages.js:29, usePatientDocuments.js:30, usePatientSessions.js:38` | ~~SELECT inline sem constante~~ | **RESOLVIDO 2026-05-20 (M2.1):** 5 constantes em patientsSelects.js |
| ~~Média~~ ✅ | composable | `usePatientFinancial.js:127-130, usePatientSessions.js:211-274` | ~~Mutações sem `.eq('tenant_id', tid)`~~ | **RESOLVIDO 2026-05-20 (M2.3+M2.4):** todas mutations no repository usam `.eq('tenant_id', tid)` |
| ~~Baixa~~ ✅ | composable | `usePatients.js:45` | ~~`remove` não re-throw~~ | **RESOLVIDO 2026-05-20 (M2.6):** Tipo A canônico completo |
| Baixa | composable | `usePatientSessions.js:67` | Filtro de virtual occurrences encapsulado no composable | Documentar como legado pré-refactor |
### 3. Prontuário/Evolução
**Estado:** **Embrionário.** Mal-existe. Aba "Prontuário evolutivo" é placeholder vazio.
**Arquivos-chave:**
- `src/features/patients/prontuario/PatientProntuario.vue` (188 KB) — shell de abas
- `src/features/patients/prontuario/PatientConversationsTab.vue` (11.8 KB) — timeline supabase direto
- `usePatientSessions.js` reusado (não é prontuário-específico)
**Divergências:**
| Sev | Tipo | Local | Problema | Fix |
|---|---|---|---|---|
| Alta | composable | `PatientProntuario.vue:29` | supabase direto pra `conversation_messages`, `agenda_eventos`, `financial_records`, `documents`, `patient_groups`, `patient_tags` | Extrair `usePatientConversations`, `usePatientFinancial`, `usePatientDocuments` |
| Alta | repo | (não existe) | Nenhum repository pras tabelas do prontuário | Criar `patientFinancialRepository.js`, `patientDocumentsRepository.js` |
| Alta | composable | `PatientConversationsTab.vue:8` | Query direto a `conversation_messages` | Mover pra repository |
| Média | gap | `PatientProntuario.vue:1950` | Aba "Prontuário" é placeholder vazio — schema clínico não modelado | **Decidir modelo: `patient_notes`? `clinic_sessions`? `patient_clinical_notes`?** |
| Média | composable | `usePatientSessions.js:38` | Queries inline a `agenda_eventos`, `recurrence` | Mover pra `patientSessionsRepository.js` |
| Média | repo | `PatientProntuario.vue:381-384` | `updateSessionStatus` mutação inline em componente | Mover pra repository |
**Gaps estruturais:**
1. Repository layer ausente (3 repositories a criar)
2. Composable layer incompleto (3 composables a criar)
3. **Schema clínico inexistente** — anamnese, evolução, plano terapêutico não modelados
4. PatientProntuario.vue é monolítico (188 KB) — refactor candidate
### 4. Financeiro
**Estado:** **Órfão.** Módulo existe mas sem camada repository; composables raiz fazem supabase direto.
**Arquivos-chave:**
- `src/features/financeiro/pages/FinanceiroPage.vue` — supabase direto inline
- `src/features/financeiro/pages/FinanceiroDashboardPage.vue` — RPC direto inline
- `src/composables/useFinancialRecords.js` — composable raiz com supabase inline (sem repository)
- `src/composables/useAgendaFinanceiro.js` — orquestrador agenda-financeiro com lógica duplicada
**Divergências:**
| Sev | Tipo | Local | Problema | Fix |
|---|---|---|---|---|
| Alta | repo | `useFinancialRecords.js:294` | UPDATE sem `.eq('tenant_id', tid)` | Defesa em profundidade |
| Alta | repo | `useAgendaFinanceiro.js:194, 215` | UPDATE sem `.eq('tenant_id', tid)` em 2 pontos | Idem |
| Alta | composable | `useFinancialRecords.js:58` | `error = ref(null)` | `ref('')` |
| Alta | camada | `useFinancialRecords.js` (todo) | supabase direto viola blueprint | Extrair pra `financeiro/services/financialRecordsRepository.js` |
| Alta | camada | `useAgendaFinanceiro.js:191, 205, 209` | supabase direto | Mover pra repository ou RPC wrapper |
| Alta | overlap | `useAgendaFinanceiro.js:114-151` + `useFinancialRecords.js:157-189` | **Lógica duplicada de criação de cobrança** — ambos chamam mesma RPC | Consolidar em 1 composable orquestrador |
| Média | convenção | `FinanceiroPage.vue:22-51` | supabase direto em componente | Mover pra composable |
| Média | convenção | `FinanceiroDashboardPage.vue:68, 78, 144` | RPC inline em componente | Criar `useFinancialDashboard` |
| Média | SELECT | `useFinancialRecords.js:40-51` | BASE_SELECT constante OK, mas sem `flatten<Feature>Row` | Adicionar helper se joins aninhados |
| Baixa | cosmético | `FinanceiroPage.vue:27-40` | Formatadores BRL/Date duplicados na dashboard | Extrair pra `financeiro/utils/formatters.js` |
**Overlap crítico com agenda:**
- `useAgendaFinanceiro.gerarCobrancaManual()` vs `useFinancialRecords.createRecord()` chamam mesma RPC — **risco double-billing em race condition**
- `useAgendaFinanceiro.handleStatusChange()` relê `financial_records` (linhas 191, 205) — query que pertence a `useFinancialRecords`
- Ambos importam `useTenantStore` + `getUid()` inline (duplicação)
### 5. Multi-tenant
**Estado:** Parcial. Stores OK. **Gap crítico: convites/membership inexistem.** SetupWizard e SaasTenantFeaturesPage com queries inline.
**Arquivos-chave:**
- `src/stores/tenantStore.js` — Pinia store + memberships read-only via RPC
- `src/stores/tenantFeaturesStore.js` — computed store + TTL cache + RPC
- `src/stores/entitlementsStore.js` — view-based (`v_tenant_entitlements`, `v_user_entitlements`)
- `src/features/setup/SetupWizardPage.vue` — 8 queries supabase inline
- `src/views/pages/saas/SaasTenantFeaturesPage.vue` — 4 queries inline
- `src/features/clinic/components/ModuleRow.vue` — dumb component OK
**Divergências:**
| Sev | Tipo | Local | Problema | Fix |
|---|---|---|---|---|
| ~~Alta~~ ✅ | composable | `SaasTenantFeaturesPage.vue:124-129` | ~~4 queries supabase direto~~ | **RESOLVIDO 2026-05-20 (M5 quick win):** extraído pra `src/services/tenantFeatureAdminService.js`. |
| Alta | repository | `SetupWizardPage.vue:419, 429, 446, 595, 626, 656, 681, 706` | 8 supabase queries inline em página | Criar `setupRepository.js` + `useSetupWizard.js` |
| Média | store | `tenantFeaturesStore.js:134` | `fetchForTenant` faz `from('tenant_features')` direto | Wrapper em `tenantFeaturesRepository.js` |
| Média | store | `entitlementsStore.js:136, 177` | Queries em views direto | Aceitar como read-only com comentário |
| Média | convention | `SaasTenantFeaturesPage.vue:33-53` | Error pattern inconsistente | Usar toast |
| Baixa | naming | `tenantFeaturesStore.js:52` | `loadedForTenantId` vs `tenantId` ambíguo | Renomear |
| Baixa | cosmetic | `SetupWizardPage.vue:60` | `isClinicRole` via string matching | Usar `useRoleGuard` |
**Gap crítico — convites/membership:**
Grep por `tenant_members`, `tenant_invite`, `convite`, `invitation` retornou **zero** em `features/`. Não existe:
- Repository para `sendInvite(tenantId, email)`
- Repository para `acceptInvite(inviteId)`
- Repository para `listTenantMembers(tenantId)`
- Composable wrapper
- Página `/admin/members` pra gestão
**Recomendação:** criar `features/tenantship/` (ou `features/team/`) completo. Bloqueador de MVP.
### 6. Notificações
**Estado:** **Embrionário.** Fragmentado em 3+ canais (WhatsApp Evolution, WhatsApp Twilio, SMS Twilio, in-app, notices globais), sem padronização.
**Arquivos-chave:**
- `src/features/notices/noticeService.js` — supabase direto sem repository
- `src/features/conversations/CRMConversasPage.vue` — página complexa, lógica não extraída
- `src/composables/useConversations.js` — query + business logic + supabase direto
- `src/composables/useNotifications.js` — toast + realtime + polling
- `src/stores/notificationStore.js` — in-app puro (OK)
- `src/stores/conversationDrawerStore.js` — mistura send WhatsApp/SMS + templates
- `src/stores/twilioWhatsappStore.js` — estado Twilio subcontas
- `src/views/pages/notifications/SmsChannelSetupPage.vue` — credenciais via supabase
- `src/views/pages/therapist/NotificationsHistoryPage.vue` — sync com store
**Divergências:**
| Sev | Tipo | Local | Problema | Fix |
|---|---|---|---|---|
| Alta | repo | `noticeService.js:28-44` | SELECT inline | Criar `notices/noticeSelects.js` |
| Alta | composable | `useConversations.js:85-91` | supabase direto, sem repository | Criar `conversations/services/conversationsRepository.js` |
| Alta | repo | `useConversations.js:229-244` | SELECT inline em `loadThreadMessages()` | Extrair pra repository |
| Alta | gap | `conversationDrawerStore.js:339-346` | Edge function invoke direto sem fallback/retry | Criar `sendMessageService.js` com error handling |
| Alta | canais | `conversationDrawerStore.js:327-377` | Lógica de envio WhatsApp (Evolution + Twilio) sem abstração | Factory por canal |
| Média | error | `useNotifications.js:117-145` | Realtime/polling sem try/catch | Wrap |
| Média | repo | `conversationDrawerStore.js:414-449` | `loadTemplates()` sem `.eq('tenant_id', ...)` no 2º select | Adicionar guard |
| Média | naming | `SmsChannelSetupPage.vue:84-102` | Query sem SELECT canônico | Extrair |
| Baixa | cosmético | `useConversations.js:165-184` | Channel filter hardcoded ['whatsapp','sms','email'] | Exportar `CHANNEL_TYPES` const |
**Canais identificados:**
1. WhatsApp (Evolution API) — Parcial
2. WhatsApp (Twilio) — Parcial
3. SMS (Twilio) — **Stub** (só setup, sem envio)
4. In-app (browser notifications) — Funcional
5. Global Notices — Funcional
**Gaps estruturais:**
- Repositórios inexistem (conversas, mensagens, canais)
- `_tenantGuards.js` ausente
- SELECT canônico fragmentado
- Composables fat (`useConversations` faz 3 coisas)
- SMS envio não implementado (só credenciais)
---
## Próximos passos
### Ajustes ao plano original
**Fase 0** — concluída. Audit baseline pronto.
**Fase 1** — sequenciamento revisado considerando as 4 surpresas:
| Ordem | Módulo | Pré-requisito | Observação |
|---|---|---|---|
| 1 | **Home/Components** | — | Inclui promover quick-create blueprint (3 candidates já existem) + criar `medicos/` e `insurance/` features |
| 1.5 | **Quick-create blueprint promotion** | — | Mover blueprint de "agenda-only" pra universal; refatorar 3 CadastroRapido components em paralelo |
| 2 | **Pacientes** | — | `patientsRepository` já parcial; fix 4 composables com supabase direto |
| 3 | **Prontuário (parcial)** | **Decisão de schema clínico** | Sem schema, só dá pra criar repositories pras tabelas existentes (financial_records, documents) |
| 4 | **Financeiro** | Decisão sobre overlap com agenda | Resolver double-billing risk ANTES de refactor |
| 5 | **Multi-tenant + Convites** | — | Criar `tenantship/` feature inteiro (gap crítico) |
| 6 | **Notificações** | — | Pesado: 3 canais, abstração por factory |
### Decisões pendentes (precisa de você)
1. **Quick-create blueprint:** promover pra universal agora ou manter agenda-only? (recomendo promover — promotion criteria atingida)
2. **Schema clínico do prontuário:** modelar agora (bloqueador) ou empurrar pra Fase 1 estendida?
3. **Overlap billing agenda↔financeiro:** consolidar em 1 composable OU separar com coordenação via fila? (recomendo consolidar)
4. **Convites/membership:** criar feature `tenantship/` separada OU absorver em `clinic/`? (recomendo separada — semântica diferente)
5. **`dev_auditoria_items` no banco:** popular agora os 51 itens via SQL OU UI uma a uma? (recomendo SQL batch insert — mais rápido pra começar Fase 1)
---
## Referências
- Blueprints: `blueprints/repository-blueprint.md`, `composable-blueprint.md`, `quick-create-overlay-blueprint.md`
- Estratégia: `development/02-auditoria/PADRONIZACAO.md`
- Memória: `project_padronizacao_sweep.md`, `project_graphify_findings_20260504.md`
@@ -0,0 +1,325 @@
# Design — Asaas Gateway (Tier 1: Cobrança de Paciente · PIX + Boleto)
> **Data:** 2026-05-21
> **Tipo:** Design doc + foundation (sem credenciais reais ainda)
> **Resolve:** Item #1-#3 da Fase 1 do ROADMAP (Monetização)
> **Decisões confirmadas:** Tier 1 primeiro (paciente paga terapeuta) · PIX + Boleto · Approach foundation com stops
---
## 1. Estado atual
### 1.1 O que JÁ EXISTE no projeto
- **Edge Function `asaas-webhook`** (`supabase/functions/asaas-webhook/index.ts`) — porém só lida com `whatsapp_credit_purchases`. Token `ASAAS_WEBHOOK_TOKEN` validado.
- **Edge Function `create-whatsapp-credit-charge`** — cria cobrança Asaas para créditos WhatsApp. Pattern de chamada à API Asaas estabelecido.
- **Tabela `whatsapp_credit_purchases`** com coluna `asaas_payment_id`. Modelo: 1 purchase ↔ 1 Asaas payment.
- **Coluna `financial_records.payment_link`** (migration 20260514000001) — espera o URL Asaas quando integração existir.
- **Tabela `payment_settings`** com pix_chave, deposito_*, etc — config manual de pagamento por owner (NÃO é Asaas).
- **Asaas mencionado em 9 arquivos client** — todos relacionados a WhatsApp credits ou docs.
### 1.2 O que FALTA pra patient billing
- Schema: tabelas `asaas_customers` (mapping patient → Asaas customer) e `asaas_payments` (1 row por cobrança gerada)
- Schema: ENCRYPTED storage da API key Asaas por tenant (se modelo B — ver §3)
- Edge Function `asaas-create-customer-patient` — upsert customer no Asaas
- Edge Function `asaas-create-payment-record` — gera cobrança a partir de financial_record
- Edge Function `asaas-cancel-payment` — cancela
- Edge Function `asaas-webhook` ESTENDIDA — handler pra eventos de financial_records (atualmente só whatsapp_credit_purchases)
- Cliente JS: `asaasGatewayService.js` em `features/financeiro/services/`
- UI: botão "Gerar cobrança Asaas" no record do `financial_records` (não escopo desta sessão)
---
## 2. Arquitetura
### 2.1 Camadas
```
┌─────────────────────────────────────────────────┐
│ Browser (Vue) │
│ ├ asaasGatewayService.js │
│ │ • createPaymentForRecord(recordId, opts) │ ← invoca Edge Function via supabase.functions.invoke
│ │ • cancelPayment(asaasPaymentId) │
│ │ • getPaymentInfo(asaasPaymentId) │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ Supabase Edge Functions (Deno) │
│ ├ asaas-create-customer-patient │
│ │ • Recebe patient_id │
│ │ • Upsert asaas_customers (cache) │
│ │ • Chama Asaas POST /customers │
│ ├ asaas-create-payment-record │
│ │ • Recebe financial_record_id + method │
│ │ • Garante customer existe (cascade) │
│ │ • Chama Asaas POST /payments │
│ │ • Salva asaas_payments + update financial_records.payment_link │
│ ├ asaas-cancel-payment │
│ │ • Asaas DELETE /payments/:id │
│ └ asaas-webhook (EXTENDER) │
│ • Adiciona handler pra events linkados a │
│ financial_records (não só credits) │
└─────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────┐
│ Asaas REST API (sandbox/prod) │
└─────────────────────────────────────────────────┘
```
### 2.2 Por que Edge Functions e não client-side?
**Crítico:** API key do Asaas NUNCA pode chegar ao browser. Browser vê script + DevTools = key vaza = qualquer pessoa cria cobrança em nome da plataforma/tenant. Edge Functions rodam server-side, key fica em env vars do Supabase.
---
## 3. Modelo de negócio (DECISÃO PENDENTE)
**Quem detém a conta Asaas que recebe o dinheiro?**
### Opção A — Plataforma (marketplace)
- 1 conta Asaas global da plataforma AgenciaPsi
- Plataforma recebe TUDO + repassa pra terapeutas (split payment ou reconciliação manual)
- Asaas tem feature de SubAccounts/Split — pode ser configurado
- **Pros:** simples, 1 chave ENV no Supabase
- **Cons:** plataforma fica como intermediadora financeira (regulatório + impostos + compliance)
### Opção B — Tenant-level (recommended pra MVP solo-therapist)
- Cada tenant tem SUA conta Asaas
- API key encrypted em `payment_settings.asaas_api_key` (Supabase Vault ou pgsodium)
- Terapeuta recebe direto na própria conta
- Edge Function lê chave do tenant requisitante
- **Pros:** sem complexidade regulatória pra plataforma
- **Cons:** terapeuta precisa configurar Asaas próprio (UX onboarding)
### Opção C — Híbrido
- Plataforma cobra mensalidade SaaS via SUA Asaas (Tier 2 — fora desta sessão)
- Terapeuta cobra paciente via SUA Asaas (Tier 1)
- 2 contas em mundos separados
**Recomendação desta sessão:** **Opção B (Tenant-level)** pra Tier 1. Tier 2 (SaaS subscriptions) decide depois — pode ser A com mesma infra.
---
## 4. Schema additions
### 4.1 Tabela `asaas_customers`
Mapping patient ↔ Asaas customer. Cacheado (não recriar a cada cobrança).
```sql
CREATE TABLE public.asaas_customers (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
patient_id uuid NOT NULL REFERENCES patients(id) ON DELETE CASCADE,
asaas_customer_id text NOT NULL,
-- ambiente: sandbox ou prod (mesmo patient pode ter ambos)
environment text NOT NULL DEFAULT 'prod' CHECK (environment IN ('sandbox', 'prod')),
-- dados cacheados (sincronizados quando atualizar)
name text NOT NULL,
email text,
cpf_cnpj text,
phone text,
address jsonb,
-- audit
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NOT NULL,
deleted_at timestamptz,
UNIQUE (tenant_id, patient_id, environment)
);
CREATE INDEX idx_asaas_customers_lookup
ON public.asaas_customers (tenant_id, patient_id)
WHERE deleted_at IS NULL;
```
### 4.2 Tabela `asaas_payments`
1 row por cobrança Asaas gerada. Link com financial_record.
```sql
CREATE TABLE public.asaas_payments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id uuid NOT NULL,
financial_record_id uuid NOT NULL REFERENCES financial_records(id) ON DELETE CASCADE,
asaas_customer_id uuid REFERENCES asaas_customers(id),
asaas_payment_id text NOT NULL,
asaas_invoice_id text,
environment text NOT NULL DEFAULT 'prod' CHECK (environment IN ('sandbox', 'prod')),
billing_type text NOT NULL CHECK (billing_type IN ('PIX', 'BOLETO', 'CREDIT_CARD', 'UNDEFINED')),
status text NOT NULL, -- raw Asaas status; mapeamento pra financial_records.status no JS
value numeric(10, 2) NOT NULL,
net_value numeric(10, 2),
due_date date NOT NULL,
payment_date timestamptz,
invoice_url text,
payment_url text, -- URL pra paciente abrir e pagar
bank_slip_url text, -- PDF boleto
pix_qr_code text, -- base64 do QR
pix_copy_paste text, -- payload PIX
-- audit
created_at timestamptz DEFAULT now() NOT NULL,
updated_at timestamptz DEFAULT now() NOT NULL,
cancelled_at timestamptz,
UNIQUE (asaas_payment_id, environment)
);
CREATE INDEX idx_asaas_payments_record
ON public.asaas_payments (financial_record_id);
CREATE INDEX idx_asaas_payments_lookup
ON public.asaas_payments (tenant_id, status, due_date);
```
### 4.3 Tabela `asaas_webhook_events`
Idempotência + audit. Cada webhook recebido vai aqui ANTES de processar.
```sql
CREATE TABLE public.asaas_webhook_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
event_id text, -- id que Asaas mandar (se mandar)
event_type text NOT NULL, -- PAYMENT_RECEIVED, PAYMENT_OVERDUE, etc
asaas_payment_id text, -- pra linkar com asaas_payments
payload jsonb NOT NULL, -- raw event pra debug
processed_at timestamptz, -- quando processou; NULL = pendente/falha
processing_error text,
received_at timestamptz DEFAULT now() NOT NULL,
UNIQUE (event_id) DEFERRABLE INITIALLY DEFERRED
);
CREATE INDEX idx_asaas_webhook_events_payment
ON public.asaas_webhook_events (asaas_payment_id);
```
### 4.4 Coluna ENCRYPTED na `payment_settings`
Tenant-level API key (Opção B). Usa pgsodium ou Supabase Vault.
```sql
-- Sandbox e prod separados — terapeuta começa em sandbox, migra pra prod quando OK.
ALTER TABLE public.payment_settings
ADD COLUMN IF NOT EXISTS asaas_api_key_sandbox text, -- prefixado com "$pgsodium-encrypted$" via trigger
ADD COLUMN IF NOT EXISTS asaas_api_key_prod text,
ADD COLUMN IF NOT EXISTS asaas_environment text DEFAULT 'sandbox' CHECK (asaas_environment IN ('sandbox', 'prod')),
ADD COLUMN IF NOT EXISTS asaas_webhook_token text;
```
**⚠️ Atenção:** API keys EM TEXTO NA TABELA é vulnerabilidade séria. Em produção precisa Supabase Vault (pgsodium) ou KMS externo. Pra MVP sandbox dá pra deixar plaintext + RLS restritiva, mas tem que documentar como dívida.
---
## 5. Status mapping Asaas → financial_records
| Asaas | financial_records.status |
|---|---|
| PENDING | pending |
| RECEIVED / CONFIRMED | paid |
| RECEIVED_IN_CASH | paid (manual) |
| OVERDUE | overdue |
| REFUNDED / CHARGEBACK_REQUESTED | refunded |
| DELETED / CHARGEBACK_DISPUTE | cancelled |
---
## 6. Flow completo (happy path) — Tier 1 PIX
1. Terapeuta cria sessão na agenda → trigger gera `financial_records` row (status=pending, payment_method=null, payment_link=null)
2. Terapeuta vê record na UI e clica **"Gerar cobrança Asaas (PIX)"**
3. Cliente JS chama `asaasGatewayService.createPaymentForRecord(recordId, { method: 'PIX' })`
4. Service invoca Edge Function `asaas-create-payment-record`
5. Edge Function:
- Lê financial_record + patient + tenant_settings (API key)
- Garante asaas_customers row (chama asaas-create-customer-patient se não existe)
- POST `https://sandbox.asaas.com/api/v3/payments` com `customer`, `value`, `dueDate`, `billingType=PIX`, `externalReference=<financial_record_id>`
- Asaas retorna `id`, `invoiceUrl`, e (pra PIX) `id` de QR code
- Edge Function chama POST `/payments/:id/pixQrCode` pra pegar QR base64
- INSERT em `asaas_payments` com toda metadata
- UPDATE `financial_records.payment_link = invoiceUrl, payment_method = 'pix_asaas'`
6. Service retorna `{ paymentUrl, qrCode, copyPaste }` pro cliente
7. UI mostra QR code + link pra paciente
8. Paciente paga via PIX
9. Asaas dispara webhook PAYMENT_RECEIVED → Edge Function `asaas-webhook`:
- INSERT em `asaas_webhook_events` (idempotência via event_id)
- Busca `asaas_payment` por `asaas_payment_id`
- Se status=='paid' já: skip
- UPDATE `asaas_payments.status='RECEIVED'`, `payment_date=now()`
- UPDATE `financial_records.status='paid'`, `paid_at=now()`
- Marca event como `processed_at=now()`
---
## 7. Decisões de implementação
| Decisão | Confirmada |
|---|---|
| Tier 1 (paciente paga terapeuta) primeiro | ✅ |
| PIX + boleto primeiro (cartão depois) | ✅ |
| Modelo tenant-level (Opção B) | ⚠️ PROPOSTO — confirme antes de implementar |
| Sandbox first, prod depois | ⚠️ default — confirme |
| Storage de API key plaintext em `payment_settings` (com RLS) pra MVP | ⚠️ DÍVIDA conhecida — vault depois |
| `externalReference` no Asaas = financial_records.id | ⚠️ PROPOSTO — facilita reconciliação |
| Webhook compartilha mesma Edge Function (`asaas-webhook` estendida) | ⚠️ PROPOSTO — evita duplicar token validation |
---
## 8. Checklist do que VOCÊ precisa fornecer
Antes da Fase B (implementação real):
- [ ] Criar conta Asaas (https://asaas.com)
- [ ] Habilitar PIX (gera chave PIX automática) + Boleto na conta
- [ ] Pegar API key de SANDBOX (Configurações → Integrações)
- [ ] Configurar webhook no Asaas: `https://<seu-projeto>.supabase.co/functions/v1/asaas-webhook` + token
- [ ] Setar ENV vars no Supabase (Dashboard → Edge Functions → Secrets):
- `ASAAS_API_URL_SANDBOX=https://sandbox.asaas.com/api/v3`
- `ASAAS_API_URL_PROD=https://api.asaas.com/v3`
- `ASAAS_WEBHOOK_TOKEN=<token-aleatorio>` (já existe? checar)
- [ ] Decidir modelo de negócio (Opção A/B/C — §3) — recomendo **B** pra MVP solo
---
## 9. Phasing — entrega faseada
### Fase A — Foundation (esta sessão)
- [x] Design doc (este arquivo)
- [ ] Migration de schema (asaas_customers + asaas_payments + asaas_webhook_events + colunas em payment_settings)
- [ ] Client service `asaasGatewayService.js` (skeleton)
- [ ] Edge Function stubs (3 novas + nota sobre estender webhook existente)
- [ ] README/Checklist no service file
### Fase B — Implementação real (próxima sessão, requer credenciais + decisões §7)
- [ ] Edge Functions: chamadas reais ao Asaas
- [ ] Webhook extension: handler pra `financial_records`
- [ ] UI: botão "Gerar cobrança Asaas" no card do financial_record
- [ ] UI: dialog mostrando QR code PIX + link boleto
### Fase C — Onboarding (após B testar)
- [ ] Página de config Asaas no `/configuracoes/financeiro`
- [ ] Wizard pra terapeuta inserir API key + testar conexão
- [ ] Migration de sandbox→prod com confirm
### Fase D — Avançado (futuro)
- [ ] Cartão on file (Asaas tokenizado)
- [ ] Auto-billing recorrente (sessão realizada → gera Asaas automático)
- [ ] Split payment se Opção A
- [ ] Cobrança SaaS (Tier 2)
---
## 10. Riscos conhecidos
1. **API key vazada** — se plaintext em `payment_settings`, qualquer breach da DB compromete. **Mitigação:** RLS restritiva + Vault em produção.
2. **Duplicate billing** — webhook dispara 2× (retry Asaas). **Mitigação:** `asaas_webhook_events.event_id` UNIQUE + check de status atual antes de mutar.
3. **Cancelamento race** — paciente paga enquanto terapeuta cancela. **Mitigação:** UPDATE `financial_records` só se `status='pending'` (CAS).
4. **Reconciliação manual** — se webhook falha 3× e dá up, paciente pagou mas record fica pending. **Mitigação:** Edge Function `asaas-sync-payments` (manual trigger) que consulta `/payments` por externalReference e força update.
5. **CPF/CNPJ obrigatório no Asaas** — paciente sem CPF não pode receber cobrança. **Mitigação:** validação client-side antes de chamar service.
---
## 11. Referências
- Asaas API docs: https://docs.asaas.com/
- Existing webhook pattern: `supabase/functions/asaas-webhook/index.ts`
- Migration `financial_records.payment_link`: `20260514000001_financial_records_payment_link.sql`
- Memory: `project_agenda_billing_decisoes` (decisões #1, #4, #5, #7, #8 confirmadas; #2/#3/#6 pendentes)
- ROADMAP: `development/04-roadmap/ROADMAP.md` Fase 1.1 (#1-#4 Monetização)
@@ -0,0 +1,480 @@
# Design — useBillingOrchestrator
> **Data:** 2026-05-20
> **Tipo:** Design doc (sem código). Implementação fica pra Módulo 4 (Financeiro) da Fase 1.
> **Resolve:** decisão 7 do PADRONIZACAO.md — overlap billing agenda ↔ financeiro com risco de double-billing.
---
## 1. Problema atual
### 1.1 Três caminhos pra criar cobrança
Cobrança de sessão hoje pode ser criada por **3 lugares diferentes**:
| # | Caminho | Arquivo | Quando |
|---|---|---|---|
| A | Botão "Gerar cobrança" manual | `useAgendaFinanceiro.gerarCobrancaManual()` (linha 114) | User clica explicitamente em sessão sem cobrança |
| B | Mudança de status na agenda | `useAgendaFinanceiro.handleStatusChange()` (linha 163) | User troca status (agendado→faltou, etc) |
| C | Decisões aplicadas no Melissa | `useMelissaAgenda._applyStatusDecisions()` (linha 1450) | User confirma transição de status no fluxo Melissa |
**Os 3 chamam a mesma RPC** `create_financial_record_for_session`. Sem coordenação central. Resultado: race condition silenciosa possível.
### 1.2 UPDATEs diretos espalhados
`handleStatusChange` também faz UPDATE/SELECT em `financial_records` direto (linhas 191, 194, 205, 208) — queries que **pertencem ao useFinancialRecords** mas são duplicadas aqui pra evitar import circular.
### 1.3 State em variável de módulo (vaza)
`useAgendaFinanceiro.js:38`:
```js
const _exceptionsCache = new Map(); // ← módulo-level, vaza entre instâncias
```
Quando user troca de tenant, cache não invalida automaticamente. Memória `useAgendaFinanceiro.invalidateExceptionsCache()` precisa ser chamada manualmente em vários lugares.
### 1.4 Cenários de double-billing concretos
1. **Race manual + status:** user clica "Gerar cobrança" + muda status pra "faltou" em < 200ms. Path A insere registro pending; Path B detecta sessão sem `billed` (já que ainda não chegou) e cria outro registro pela exceção.
2. **Realizado vindo de faltou paid:** sessão estava `faltou` com multa paid. User volta pra `agendado``realizado`. Path B/C podem regerar cobrança em cima da multa paid existente (memória `project_rpc_idempotency_cancelled` foi um fix relacionado mas não cobre todo o problema).
3. **Pacote saldo + adicional:** sessão de pacote `billing_contract_id` setado bloqueia Path A (linha 116). Mas Path B/C podem **não checar** esse campo em alguma branch — risco de cobrança individual em sessão de pacote.
---
## 2. Goals & Non-goals
### Goals
1. **Single entry point** pra qualquer mudança de billing relacionada a evento da agenda.
2. **Idempotência garantida** — chamar 2× a mesma intenção produz o mesmo resultado.
3. **State machine explícito** de transições de status com consequências financeiras claras.
4. **Reverse transitions** tratadas (realizado→agendado, faltou→agendado, cancelado→agendado).
5. **Orchestrador NUNCA toca supabase direto** — só via repository e composable de financeiro.
6. **Cache de regras de exceção** vive na instância do composable, não em módulo.
### Non-goals (fora deste escopo)
1. Implementação — só design. Código vem na Fase 1 Módulo 4.
2. Refator de `useFinancialRecords` em si (extrair pra repository) — vai junto no Módulo 4.
3. Gateway de pagamento (Asaas) — Fase 3 do ROADMAP.
4. Repasse a terapeutas — `therapist_payouts` separado.
5. UI/UX de confirmação de reverse transitions — já mapeado em memória `project_agenda_reverse_transitions`, implementação no Módulo 4.
---
## 3. State machine de transições
### 3.1 Status válidos
Enum `status_evento_agenda` (do schema): `agendado | realizado | faltou | cancelado | remarcar`
### 3.2 Matriz `from → to`
| | →agendado | →realizado | →faltou | →cancelado | →remarcar |
|---|---|---|---|---|---|
| **agendado→** | — | ✅ FORWARD | ✅ FORWARD | ✅ FORWARD | ✅ FORWARD |
| **realizado→** | ⚠️ REVERSE | — | ⚠️ REVERSE | ⚠️ REVERSE | ❌ inválida |
| **faltou→** | ⚠️ REVERSE | ⚠️ CROSS | — | ⚠️ CROSS | ❌ inválida |
| **cancelado→** | ⚠️ REVERSE | ⚠️ CROSS | ⚠️ CROSS | — | ❌ inválida |
| **remarcar→** | ✅ FORWARD | ✅ FORWARD | ✅ FORWARD | ✅ FORWARD | — |
### 3.3 Tabela de consequências financeiras
| Transição | Ação financeira default | Decisão do user (override) |
|---|---|---|
| `agendado→realizado` | Criar pending (se ainda não billed) com `amount = event.price` | Marcar como já recebido (forma de pagamento) |
| `agendado→faltou` | Consultar `financial_exceptions[patient_no_show]` → criar multa OR cancelar existente | Consumir saldo de pacote (se aplicável) |
| `agendado→cancelado` | Consultar `financial_exceptions[patient_cancellation]` + `min_hours_notice` → criar taxa de cancelamento tardio OR cancelar existente | — |
| `realizado→agendado` | **REVERSE:** se record `paid` existe → confirm dialog (refund_paid). Se `pending` → soft-cancel. Se `paid+package`: refund + devolver saldo | Reverter manualmente sem auto |
| `realizado→faltou` | **CROSS:** reverter realizado + aplicar regra de no-show. Se já paid → manter pago e converter em multa | — |
| `faltou→agendado` | **REVERSE:** cancelar multa pending. Se multa paid → confirm dialog (refund) | — |
| `cancelado→agendado` | **REVERSE:** cancelar taxa de cancelamento (se houver) | — |
| `*→remarcar` | Manter cobrança existente, atualizar `due_date` quando reagendar | — |
### 3.4 Pacote (billing_contract_id presente)
**Sobre qualquer transição:** se `event.billing_contract_id` não-nulo, **não criar nem cancelar `financial_records` individual**. Em vez disso:
- `agendado→realizado`: incrementa `billing_contracts.sessions_used`
- `agendado→faltou` ou `agendado→cancelado` com `default_consume_on_miss=true`: incrementa `sessions_used`
- `realizado→agendado`: decrementa `sessions_used` (refresh FRESH do DB antes, memória `project_agenda_reverse_transitions`)
Memória relevante: `project_cross_week_propagation` — bulk-load tem que rodar mesmo sem reais na view + query records cross-week por recurrence_id.
---
## 4. API shape
### 4.1 Signature do composable
```js
export function useBillingOrchestrator() {
// ─── State ──────────────────────────────────────────────────
const loading = ref(false); // operação async em andamento
const error = ref(''); // string vazia default (canon do composable-blueprint)
// ─── Public actions ─────────────────────────────────────────
/**
* Orquestra mudança de status de um evento + consequências financeiras.
* Single entry point — substitui os 3 caminhos atuais.
*
* @param {Object} params
* @param {Object} params.event - row de agenda_eventos completa
* @param {string} params.fromStatus
* @param {string} params.toStatus
* @param {Object} [params.decisions] - overrides do user (ver decisões pendentes seção 5)
* @returns {Promise<{ok, actions: Array<BillingAction>, error?}>}
*/
async function applyStatusChange(params) { ... }
/**
* Gera cobrança manual pra evento sem cobrança ainda. Idempotente.
* Bloqueia se billing_contract_id presente.
*/
async function generateChargeForEvent(event, options = {}) { ... }
/**
* Lista records financeiros vinculados ao evento.
*/
async function fetchRecordsForEvent(eventId) { ... }
/**
* Cancela TODOS os records pending/overdue de um evento (soft).
* Use APENAS em reverse transitions confirmadas pelo user.
*/
async function cancelRecordsForEvent(eventId, reason) { ... }
/**
* Lê regra de exceção financeira com cache local (instância).
*/
async function getExceptionRule(tenantId, exceptionType) { ... }
function invalidateRules() { ... } // chama em troca de tenant
return {
loading, error,
applyStatusChange, generateChargeForEvent,
fetchRecordsForEvent, cancelRecordsForEvent,
getExceptionRule, invalidateRules
};
}
```
### 4.2 Tipos relevantes
```js
/** @typedef {Object} BillingAction
* Resultado de uma operação. Compõe o array `actions` retornado por applyStatusChange.
* @property {string} type - 'created' | 'updated' | 'cancelled' | 'paid' | 'package_consumed' | 'package_returned' | 'noop'
* @property {string} [recordId]
* @property {number} [amount]
* @property {string} [reason]
*/
/** @typedef {Object} BillingDecisions
* Overrides explícitos do user. Quando ausente, orchestrator decide via regras.
* @property {boolean} [consumePackageSession] - faltou/cancelado: consumir saldo de pacote
* @property {'auto'|'always'|'never'} [applyNoShowFee] - aplicar multa em faltou
* @property {'cancel_pending'|'refund_paid'|'manual'} [reverseCleanup] - reverse: como tratar records existentes
*/
```
### 4.3 Exemplo de uso (caller)
```js
// Em AgendaEventDialog.vue (ou onde quer que aplique status change)
import { useBillingOrchestrator } from '@/features/financeiro/composables/useBillingOrchestrator';
const billing = useBillingOrchestrator();
async function onStatusChange(novoStatus, decisoes) {
const result = await billing.applyStatusChange({
event: eventoAtual.value,
fromStatus: eventoAtual.value.status,
toStatus: novoStatus,
decisions: decisoes // pode ser undefined — orchestrator usa regras default
});
if (!result.ok) {
toast.add({ severity: 'error', summary: 'Falha', detail: result.error });
return;
}
// result.actions é narrativa do que aconteceu — use pra UI feedback
for (const action of result.actions) {
if (action.type === 'created') showCreatedToast(action.amount);
else if (action.type === 'cancelled') showCancelledToast();
// ...
}
}
```
---
## 5. Arquitetura interna
### 5.1 Dependências (camadas)
```
useBillingOrchestrator
├──> useFinancialRecords (thin wrapper)
│ │
│ └──> financialRecordsRepository
│ │
│ └──> supabase (RPC + tabela)
├──> useBillingContracts (composable a criar — pacotes)
│ │
│ └──> billingContractsRepository
├──> useFinancialExceptions (composable — regras de exceção)
│ │
│ └──> financialExceptionsRepository
└──> useAgendaEvents (THIN — só pra propagação reativa, não pra writes)
└──> agendaRepository (já existe)
```
**Regra absoluta:** orchestrator não importa `supabase` diretamente. Só dos composables/repositories acima.
### 5.2 Internals
```js
// PRIVATE — não exportado
const _rulesCache = new Map(); // ← agora DENTRO da function, vive com a instância
async function _resolveBillingState(eventId) {
// Snapshot completo: records[], contract?, exceptionRule?
// Pra decidir transição sem race conditions.
const records = await financialRecords.fetchByEvent(eventId);
const packageInfo = event.billing_contract_id
? await billingContracts.fetch(event.billing_contract_id)
: null;
return { records, packageInfo };
}
async function _runTransition(event, fromStatus, toStatus, decisions, state) {
const key = `${fromStatus}→${toStatus}`;
const handler = TRANSITION_HANDLERS[key];
if (!handler) {
throw new Error(`Transição inválida: ${key}`);
}
return handler({ event, decisions, state });
}
const TRANSITION_HANDLERS = {
'agendado→realizado': _handleRealizado,
'agendado→faltou': _handleFaltou,
// ... 1 handler por transição válida
};
async function _handleRealizado({ event, decisions, state }) {
if (event.billing_contract_id) {
return _consumePackageSession(event);
}
// Sessão avulsa — criar pending se não tem record ativo
const hasActive = state.records.some(r => ['pending','overdue','paid'].includes(r.status));
if (hasActive) {
return [{ type: 'noop', reason: 'Record já existe' }];
}
const record = await financialRecords.create({
patient_id: event.patient_id,
agenda_evento_id: event.id,
amount: event.price,
due_date: _eventDateISO(event)
});
return [{ type: 'created', recordId: record.id, amount: event.price }];
}
async function _handleFaltou({ event, decisions, state }) {
if (event.billing_contract_id) {
return decisions?.consumePackageSession ?? state.exceptionRule?.default_consume_on_miss
? _consumePackageSession(event)
: [{ type: 'noop' }];
}
const rule = await getExceptionRule(event.tenant_id, 'patient_no_show');
if (!rule || rule.charge_mode === 'none') {
return _cancelExistingPending(state.records);
}
// ... lógica completa
}
```
### 5.3 Idempotência — como o orchestrator garante
Antes de criar record:
```js
// 1. Snapshot da state ANTES da decisão (já feito em _resolveBillingState)
// 2. Verificar se record ativo já existe pro evento+intenção
const existingActive = state.records.find(r =>
['pending', 'overdue', 'paid'].includes(r.status)
);
if (existingActive) {
// Decidir: noop, update, ou criar segundo (raro — multa em cima de sessão paid)
}
// 3. Locks otimistas via UPDATE com .eq('status', expectedStatus)
// Se conflito, refresh + re-decide
```
Antes de update/cancel:
```js
// Sempre filtra .eq('tenant_id', tid) defesa em profundidade
// (corrige divergências do audit baseline)
```
Para mudanças de pacote:
```js
// REFRESH FRESH do banco antes do UPDATE (memória project_agenda_reverse_transitions)
const currentContract = await billingContracts.fetch(id);
await billingContracts.update(id, {
sessions_used: currentContract.sessions_used - 1
});
```
### 5.4 RPC `create_financial_record_for_session` — usar como single insert path
A RPC já existe e tem idempotência (memória `project_rpc_idempotency_cancelled` foi fix recente). Orchestrator usa ela como **única forma de criar record de sessão**. INSERT direto fica APENAS pra `createManualRecord` (lançamento avulso sem evento), que continua em `useFinancialRecords.createManualRecord`.
---
## 6. Plano de migração (faseado, Módulo 4)
### Fase A — Foundation (preparar terreno)
1. Criar `features/financeiro/services/` com:
- `_tenantGuards.js` (copy do agenda)
- `financialSelects.js` (extrair `BASE_SELECT` atual)
- `financialRecordsRepository.js` (extrair queries do `useFinancialRecords`)
- `financialExceptionsRepository.js` (novo — pra `financial_exceptions`)
- `billingContractsRepository.js` (novo — pra `billing_contracts`)
2. Adicionar `.eq('tenant_id', tid)` em todas operações (fix do audit alta sev).
### Fase B — Composables refatorados (sem mudar callers)
1. Mover `useFinancialRecords.js` pra `features/financeiro/composables/`.
2. Refatorar pra usar repository (thin wrapper). Aplicar canon `error = ref('')`.
3. Criar `features/financeiro/composables/useFinancialExceptions.js`.
4. Criar `features/financeiro/composables/useBillingContracts.js`.
5. Criar `features/financeiro/composables/useBillingOrchestrator.js` com signature acima.
6. Callers ainda usam `useFinancialRecords` direto + `useAgendaFinanceiro`**nada quebra ainda**.
### Fase C — Migração de callers (1 por vez)
1. Migrar `AgendaEventDialog.vue` pra `useBillingOrchestrator.applyStatusChange` em vez de `useAgendaFinanceiro.handleStatusChange`.
2. Migrar `useMelissaAgenda._applyStatusDecisions` (linha 1450-1505) pra orchestrator.
3. Migrar todos os callers de `useAgendaFinanceiro.gerarCobrancaManual``useBillingOrchestrator.generateChargeForEvent`.
### Fase D — Cleanup
1. Deletar `src/composables/useAgendaFinanceiro.js` (callers todos migrados).
2. Deletar `src/composables/useFinancialRecords.js` raiz (versão refatorada vive em `features/financeiro/composables/`).
3. Remover `_exceptionsCache` módulo-level (já estava no novo composable).
### Sanity checks pós-migração
- E2E Playwright: criar sessão → realizar com pagamento → mudar pra faltou → confirmar reverse → verificar contract.sessions_used. **NUNCA double-billing.**
- Memory `project_agenda_billing_decisoes` — confirmar 5 decisões mantidas (#1 híbrido, #4 semi-auto no-show, #5 bloqueia edit cobrada, #7 credit note, #8 pagamento separado).
---
## 7. Decisões resolvidas (2026-05-20)
### 7.1 ✅ `applyStatusChange` faz APENAS financeiro
Signature final: `applyStatusChange({ event, fromStatus, toStatus, decisions })` retorna `{ ok, actions[], needsConfirmation?, error? }`. Caller é responsável por atualizar a agenda separadamente (`agendaRepository.update()`).
**Consequência:** orchestrator stateless quanto à agenda. Caller faz wrapping:
```js
await agendaEvents.update(event.id, { status: novoStatus });
const billing = await billingOrchestrator.applyStatusChange({ ... });
// Se billing.needsConfirmation, mostrar dialog. Se erro, considerar rollback do status.
```
### 7.2 ✅ Reverse confirm via `needsConfirmation` no return
Quando orchestrator detecta reverse com record paid (realizado paid → agendado) ou pacote saldo consumido:
```js
// Primeira chamada — sem decisions
const r = await billingOrchestrator.applyStatusChange({ event, fromStatus: 'realizado', toStatus: 'agendado' });
// r = { ok: false, needsConfirmation: true, options: [
// { key: 'cancel_pending', label: 'Cancelar cobrança pendente', amount: 200 },
// { key: 'refund_paid', label: 'Estornar pagamento', amount: 200 },
// { key: 'manual', label: 'Resolver manualmente depois' }
// ] }
// Caller mostra dialog → user escolhe → re-chama
const r2 = await billingOrchestrator.applyStatusChange({
event, fromStatus: 'realizado', toStatus: 'agendado',
decisions: { reverseCleanup: 'refund_paid' }
});
// r2 = { ok: true, actions: [{ type: 'refunded', recordId, amount }] }
```
### 7.3 ✅ Transação via RPC `apply_billing_status_transition`
Toda mudança financeira de transição roda em RPC dedicada. Tudo ou nada. RPC entra como migration durante Módulo 4. Composable orchestrator faz apenas:
1. Resolve state atual (snapshot read-only)
2. Calcula decisões (state machine no JS)
3. Chama RPC com plano completo (`p_actions jsonb`)
4. RPC executa em ordem dentro de uma transação SQL
Signature proposta da RPC:
```sql
CREATE FUNCTION public.apply_billing_status_transition(
p_tenant_id uuid,
p_event_id uuid,
p_actions jsonb -- [{ kind: 'create_record', amount, due_date }, { kind: 'cancel_record', record_id }, ...]
) RETURNS jsonb; -- { ok, applied: [...], failed?: { kind, reason } }
```
### 7.4 ✅ Decisões #2/#3/#6 de billing — sessão dedicada antes do Módulo 4
Marcar sessão dedicada (~1h) pra fechar memória `project_agenda_billing_decisoes` antes da implementação. **Bloqueador parcial do Módulo 4** — orchestrator pode ser parcialmente implementado, mas a state machine só fica completa após resolver essas 3 decisões.
> ⚠️ **Pendência rastreada:** adicionar item em `dev_auditoria_items` ou agenda recorrente. Vai aparecer como **TODO inicial** na sessão de implementação do Módulo 4.
---
## 8. Open questions (não-bloqueantes pro design)
1. **`patient_timeline` integration:** quando orchestrator cria/cancela record, devia emitir evento `pagamento_recebido` / `pagamento_vencido` em `patient_timeline`? Hoje o enum suporta, mas não vejo inserts. **Sugestão:** adicionar como trigger no banco (não no orchestrator) — fica resiliente a chamadas que escapam do orchestrator.
2. **Gateway webhook (Asaas):** quando Asaas dispara webhook "paid", quem processa? Edge function dedicada que chama `financialRecords.markAsPaid(id, 'pix')`, sem passar por orchestrator (orchestrator é só pra mudanças via agenda). Documentar como caminho separado válido.
3. **Repasse a terapeuta (`therapist_payouts`):** quando record fica `paid`, gera entrada em `therapist_payout_records`? Hoje não. Decisão: trigger no banco que escuta UPDATE em `financial_records.status` → cria payout. Fora do escopo do orchestrator.
4. **`patient_assessments` (Fase 2):** notas clínicas com escalas têm relação com billing? Improvável — assessments são clínicos, não-monetizados. Confirmar quando implementar.
---
## 9. Cross-references
- Memórias relevantes:
- `project_rpc_idempotency_cancelled.md` — RPCs ignoram cancelled
- `project_billing_contracts_no_updated_at.md` — gotcha de UPDATE silently failing
- `project_agenda_billing_decisoes.md` — 5 decisões base
- `project_agenda_reverse_transitions.md` — confirm dialogs pra reverter
- `project_cross_week_propagation.md` — pacote upfront cross-week
- `project_c12_antecipar_iterar.md` — antecipar pacote (Watch sync resolveu snapshot stale)
- Audit baseline divergências Financeiro: ver `AUDIT_BASELINE.md` seção 4
- Blueprints: `repository-blueprint.md` + `composable-blueprint.md`
- ROADMAP: Fase 1 itens 1-4 (Monetização)
---
## 10. Checklist pra implementação (Módulo 4 da Fase 1)
- [ ] 5 repositories criados em `features/financeiro/services/`
- [ ] 4 composables criados em `features/financeiro/composables/`
- [ ] `useBillingOrchestrator` com signature acima
- [ ] State machine completa (todas transições da matriz 3.2)
- [ ] Cache de regras dentro da instância (não módulo-level)
- [ ] Idempotência testada (chamada 2× = noop)
- [ ] `.eq('tenant_id', tid)` em todas mutações (defesa em profundidade)
- [ ] RPC `apply_billing_status_transition` (decisão 7.3 opção B)
- [ ] AgendaEventDialog migrado pra orchestrator
- [ ] useMelissaAgenda._applyStatusDecisions migrado
- [ ] gerarCobrancaManual callers migrados
- [ ] useAgendaFinanceiro.js deletado
- [ ] useFinancialRecords.js raiz deletado
- [ ] E2E test cobrindo reverse transition (realizado paid → agendado)
- [ ] Confirm dialogs UI implementados (memória project_agenda_reverse_transitions)
+161
View File
@@ -0,0 +1,161 @@
# Padronização — Sweep estrutural pré-MVP
> **Iniciado:** 2026-05-20
> **Deadline MVP:** 3+ meses (lançamento limpo, sem usuários)
> **Escopo MVP:** Pacientes + Agenda + Billing + Prontuário + Financeiro avançado + Multi-tenant
---
## Diagnóstico
Sistema tem ~487 arquivos Vue, ~75-80% MVP-ready, padronização irregular. **Agenda passou por C1-C13 + análise sênior** — é o único módulo com profundidade arquitetural. Outros módulos têm divergências (composables sem `tenant_id` consistente, SELECTs inline, page layouts inconsistentes, dialogs que não seguem o blueprint).
Sem padronizar antes de escalar features novas (Fase 1 do ROADMAP), cada gap vira dívida composta.
---
## Estratégia em 1 frase
**Agenda é a referência madura.** Extrair seus padrões em blueprints → propagar módulo a módulo → tracking em `dev_auditoria_items` com tag `padronizacao:<modulo>`. **Não tocar agenda** até a Fase 4 (apenas pendências residuais).
---
## Fases
### Fase 0 — Fundação (3-5 dias)
Extrair os blueprints faltantes a partir da agenda:
- [x] `melissa-page-blueprint.md` — já existe
- [x] `melissa-table-page-blueprint.md` — já existe
- [x] `dialog-blueprint.md` — já existe
- [x] `repository-blueprint.md` — entregável 1 (2026-05-20)
- [x] `composable-blueprint.md` — entregável 2 (2026-05-20)
- [x] `quick-create-overlay-blueprint.md` — entregável 3 (2026-05-20). Documentado como **agenda-only** com promotion criteria explícito.
E baseline:
- [x] Update graphify do código atual (`graphify update src/` rodou 2026-05-20)
- [x] Audit baseline por módulo → ver `development/02-auditoria/AUDIT_BASELINE.md` (2026-05-20). 51 divergências catalogadas + 4 surpresas estruturais. **Pendente:** popular `dev_auditoria_items` no banco (decisão de batch insert vs UI).
### Fase 0.5 — Pré-Fase 1 setups (pós-audit baseline)
Decisões 6-9 introduzem 4 entregáveis novos antes da Fase 1:
- [x] **0.5.A** Atualizar `quick-create-overlay-blueprint.md` de "agenda-only" pra universal (2026-05-20) — header reescrito, seção 9 virou "Promotion History", path convention adicionada, checklist generalizado, memória `feedback_agenda_inline_quick_create` marcada como superseded
- [x] **0.5.B** Schema clínico modelado (2026-05-20). 4 migrations + 1 seed escritos em `database-novo/migrations/20260520000001..4_clinical_notes_*.sql` e `database-novo/seeds/seed_040_clinical_note_templates.sql`. **Não executado** — aguardando review do user pra rodar `node db.cjs migrate`. Tabelas: `clinical_notes` + `clinical_note_versions` (audit trail via trigger snapshot) + `clinical_note_templates` (6 templates do sistema: anamnese, SOAP, DAP, BIRP, evolução livre, plano padrão). FK órfã `documents.session_note_id` renomeada pra `clinical_note_id` com constraint. RLS: owner-only (CFP sigilo).
- [x] **0.5.C** Design doc `useBillingOrchestrator` (2026-05-20) — `development/02-auditoria/DESIGN_BILLING_ORCHESTRATOR.md`. State machine de 10 transições, dependências em 4 composables/5 repositories, plano de migração faseado em 4 fases (A/B/C/D), checklist de implementação. **4 decisões resolvidas:** (1) `applyStatusChange` só financeiro, agenda separada; (2) reverse confirm via `needsConfirmation` no return; (3) transação via RPC dedicada `apply_billing_status_transition`; (4) sessão dedicada pra decisões billing #2/#3/#6 antes do Módulo 4.
- [x] **0.5.D** Scaffold da feature `tenantship/` (2026-05-20). 7 arquivos criados em `src/features/tenantship/`: `_tenantGuards.js`, `tenantInvitesSelects.js` + `Repository.js`, `tenantMembersSelects.js` + `Repository.js`, `useTenantInvites.js`, `useTenantMembers.js`. Funções funcionais (não-stub) usando tabela existente `tenant_invites` + view `v_tenant_members_with_profiles`. **2 pendências documentadas no código:** (1) `acceptInvite()` é stub PT-BR explicando que precisa de RPC `accept_tenant_invite(p_token uuid)` — migration a criar; (2) `sendInvite()` só insere row — envio de email/WhatsApp fica pra Módulo 6 (Notificações).
### Fase 1 — Padronização módulo a módulo (4-6 semanas) — **em andamento**
Ordem confirmada com usuário:
| # | Módulo | Por que aqui | Status |
|---|---|---|---|
| 1 | **Home/Dashboard + Components base** | Alta visibilidade. Inclui refactor dos 3 quick-creates promovidos (decisão 6). | ✅ **Concluído 2026-05-20** (exceto M1.6 — MelissaLayout decomposition, deferida) |
| 2 | **Pacientes** | Núcleo de dados — alimenta agenda, prontuário, financeiro. | ✅ **Concluído 2026-05-20** (aguarda teste batch) |
| 3 | **Prontuário/Evolução** | Schema clínico já modelado (decisão 7). | ✅ **Foundation 2026-05-20** — repositories + composables prontos. Ativa quando migrations 0.5.B rodarem. UI da aba "Prontuário evolutivo" fica pra sessão dedicada. |
| 4 | **Financeiro/Billing** | `useBillingOrchestrator` desenhado (decisão 8). | ✅ **Foundation 2026-05-20** — 3 repositories + 4 composables novos. State machine completa + migração dos 3 callers BLOQUEADA pelas decisões #2/#3/#6 — fica pra sessão dedicada. Old `useAgendaFinanceiro.js` + `useFinancialRecords.js` continuam em paralelo. |
| 5 | **Multi-tenant + Tenantship** | Inclui implementar feature `tenantship/` (decisão 9). | ✅ **Concluído 2026-05-20.** MembersPage em `src/views/pages/admin/MembersPage.vue` (CRUD de membros + convites) + rota `/admin/members` registrada em `routes.clinic.js`. Migration `20260520000005_accept_tenant_invite_rpc.sql` criada (RPC SECURITY DEFINER com lock FOR UPDATE anti-race). `tenantInvitesRepository.acceptInvite` agora chama RPC real (não mais stub). SaasTenantFeaturesPage refatorada — 4 queries inline + 1 RPC extraídos pra `src/services/tenantFeatureAdminService.js`. **SetupWizardPage (2648 linhas) deferido** — refator de arquivo monolítico precisa sessão dedicada. |
| 6 | **Notificações (WhatsApp/Email)** | Depende de tudo acima. | ✅ **Foundation 2026-05-20**`noticesSelects.js` criado + `noticeService.js` refatorado pra usar constantes. `features/conversations/services/` com repository + selects. Channel factory + refactor de `useConversations.js` (fat composable) deferidos — sessão dedicada. SMS send ainda stub. |
#### Módulo 1 — sub-entregáveis
- [x] **M1.1** Criar `features/medicos/` (`_tenantGuards.js`, `medicosSelects.js`, `medicosRepository.js`, `useMedicos.js`) + refatorar `CadastroRapidoMedico.vue` pra usar repository (2026-05-20). Caller único (PatientsCadastroPage) — props/emits preservados. Zero referências `supabase`/`useTenantStore`/`getOwnerId`/`getTenantId` legacy no componente. **Testado pelo user ✅**
- [x] **M1.2** Criar `features/insurance/` (`_tenantGuards.js`, `insurancePlansSelects.js`, `insurancePlansRepository.js` com `findByName` pra uniqueness check, `useInsurancePlans.js`) + refatorar `CadastroRapidoConvenio.vue` + `InsurancePlanQuickCreateDialog.vue` (bônus da agenda) (2026-05-20). Repository agora faz duplicate check case-insensitive antes de criar — quick-create blueprint compliance. Aguardando teste.
- [x] **M1.3** Refatorado `ComponentCadastroRapido.vue` pra usar `usePatients` composable + `getMyActiveMember()` (novo helper em `tenantship/services/tenantMembersRepository.js`) (2026-05-20). Path NÃO mudou (continua em `src/components/`) — move pra `features/patients/components/` fica pra cleanup de M2. Props deprecated mantidas pra backwards-compat dos 8 callers. **Spillover M2:** fix `patientsRepository.createPatient` (audit alta — sempre injeta owner_id do uid logado, ignora payload) + `usePatients.error: ref('')` (audit média — canon). Aguardando teste.
- [x] **M1.4** Extraído `TEST_ACCOUNTS` de `HomeCards.vue` pra `src/config/devTestAccounts.js` (2026-05-20). HomeCards importa do novo arquivo; 6 usages funcionam idênticos.
- [x] **M1.5** Deletado `AgendaEventDialog.vue.bak` (não estava no git, 155KB órfão no disco) (2026-05-20).
- [ ] **M1.6** Decomposição parcial `MelissaLayout.vue` (sessão dedicada — pode ficar pra depois)
#### Módulo 2 — sub-entregáveis (2026-05-20, sem pausas de teste)
- [x] **M2.1** Criar `patientsSelects.js` (11 constantes — PATIENTS_SELECT_BASE + cross-feature: sessões, financial, documents, messages, recurrences, support_contacts, groups, tags). Estender `patientsRepository.js` com 15 funções novas + `resolveTenantId` helper local. Cross-feature reads ficam no patients até M4/M6 padronizarem (documentado nos comments).
- [x] **M2.2** `usePatientDetail.js` refatorado — 4 funções internas (getPatientById, getPatientRelations, getGroupsByIds, getTagsByIds) movidas pra repository. Composable agora é thin wrapper.
- [x] **M2.3** `usePatientFinancial.js` refatorado — `_lastPatientId` movido DENTRO da function (audit alta resolvida: state não vaza mais entre instâncias). 4 mutations (load/markPaid/markUnpaid/createRecord) delegam ao repository.
- [x] **M2.4** `usePatientSessions.js` refatorado — list+create+updateStatus via repository. Pattern virtual→materialize preservado usando `findSessionByRecurrence` + `createPatientSession` com recurrence_id/date. Recurrence expansion (useRecurrence) intacta. `supabase.auth.getUser()` mantido como context resolution (não é data query).
- [x] **M2.5** Quatro composables simples refatorados em paralelo: `usePatientMessages`, `usePatientDocuments`, `usePatientRecurrences`, `usePatientSupportContacts`. Cada um delega list+mutations ao repository.
- [x] **M2.6** Cleanups: `usePatients.js` upgraded pra Tipo A canônico completo (load/getById/create/update/remove com loading/error/re-throw consistente).
**Resultado:** zero `supabase.from(...)` em qualquer composable de `features/patients/composables/`. Todos os 8 composables seguem Tipo A do blueprint. `_lastPatientId` não vaza mais.
| 2 | **Pacientes** | Núcleo de dados — alimenta agenda, prontuário, financeiro. |
| 3 | **Prontuário/Evolução** | Schema clínico já modelado (decisão 7). Migration já aplicada. |
| 4 | **Financeiro/Billing** | `useBillingOrchestrator` já desenhado (decisão 8). |
| 5 | **Multi-tenant + Tenantship** | Inclui implementar feature `tenantship/` (decisão 9). |
| 6 | **Notificações (WhatsApp/Email)** | Depende de tudo acima. |
**Por módulo, sempre:**
1. Ler estado atual
2. Diff vs blueprints
3. Listar divergências em `dev_auditoria_items` (tag `padronizacao:<modulo>` + severidade)
4. Plano de fix
5. Executar
6. Testar (persistir HANDOFF+wiki+memória **antes** do teste manual)
7. Commit
8. Atualizar tracker
9. Log no wiki
### Fase 2 — Hotspots Graphify (paralela)
Do `project_graphify_findings_20260504`:
- [ ] `convertToPatient` duplicado
- [ ] Supabase client triplo
- [ ] 348 nós fracos
- [ ] Setup Wizard cohesion 0.05
### Fase 3 — Gaps de MVP (Fase 1 do ROADMAP)
- [🟡] **Gateway Asaas (Fase A foundation 2026-05-21)** — Design doc + 2 migrations (tables + RLS) + client service + 3 Edge Function stubs (create-payment-record, cancel-payment, sync-payment). Schema: `asaas_customers`, `asaas_payments`, `asaas_webhook_events` + 5 colunas em `payment_settings`. Fase B (implementação real) depende de credenciais + decisão modelo negócio (A/B/C). Ver `development/02-auditoria/DESIGN_ASAAS_GATEWAY.md`.
- [🟡] **Compliance CFP (#5/#8/#9 done · #6/#7 deferred · 2026-05-21)**
- #5 (registro profissional): migration `20260521000003_profiles_professional_registration.sql` — adiciona `professional_registration_type` (CHECK 8 conselhos) + `_number` + `_uf`.
- #8 (nome social): JÁ INTEGRADO — `patients.nome_social` schema existia + UI em 7 arquivos.
- #9 (especialidades): `20260521000004_specialties.sql` (tabela + profile_specialties M:N + RLS) + `seed_050_specialties.sql` (33 specialties) + `src/services/specialtiesService.js`.
- **#6 consent forms DEFERRED**: schema `document_templates` existe; falta seed + UI editor + workflow.
- **#7 assinatura DEFERRED**: schema `document_signatures` existe com status flow completo; falta portal UI pra paciente.
- [ ] E2E Playwright crítico (#16)
- [ ] Sentry (#18)
### Fase 4 — Agenda residual (por último)
- [ ] Popover snapshot stale → `ev.id` + computed
- [ ] Reverse transition confirm dialogs (realizado paid, faltou multa, pacote saldo)
- [ ] Replicação Rail (AgendaTerapeutaPage) + Clínica (AgendaClinicaPage)
- [ ] C12 antecipar — iterar UX
- [ ] Doc de ajuda completa
---
## Decisões tomadas
### Estratégicas iniciais (2026-05-20)
1. **Ordem dos módulos** ✅ Confirmada acima
2. **Tracker** ✅ Reusar `dev_auditoria_items` com tag `padronizacao:<modulo>`. Zero migration. View materializada agrupa por módulo se virar útil pra UI.
3. **Cadência** ✅ Validação por entregável (cada blueprint vira sessão antes de propagar)
4. **Agenda intocada** ✅ Até Fase 4
5. **Skills novas**`/audit-module <nome>` — minimalista. Outras só se emergir necessidade.
### Pós-audit baseline (2026-05-20)
6. **Quick-create blueprint****Promover pra universal** (3 candidates já existem fora da agenda). Refatorar `CadastroRapidoMedico.vue` + `CadastroRapidoConvenio.vue` + `ComponentCadastroRapido.vue` em paralelo no módulo 1.
7. **Schema clínico do prontuário****Modelar agora** (sessão dedicada antes da Fase 1). Sai com migration pronta de `patient_notes`/`clinic_sessions`/anamnese/evolução/plano terapêutico.
8. **Overlap billing agenda ↔ financeiro****Consolidar em 1 composable orquestrador** (`useBillingOrchestrator`). Resolve risco double-billing antes do refactor de Financeiro.
9. **Convites/membership****Feature separada `tenantship/`** com services + composables + página `/admin/members`. Semântica clara: gestão de membership.
---
## Antipadrão a evitar
- ❌ Refatorar tudo de uma vez (= nunca lançar)
- ❌ Tocar agenda durante a sweep (= regressão garantida)
- ❌ Pular o tracking em `dev_auditoria_items` (= perder rastreabilidade)
- ❌ Aplicar blueprint sem antes auditar divergências (= refazer trabalho)
---
## Referências
- Blueprints: `D:\leonohama\AgenciaPsi.com.br\Sistema\agenciapsi-primesakai\blueprints/`
- Agenda canônica: `src/features/agenda/services/` + `src/features/agenda/composables/useAgendaEvents.js`
- Tracker: tabela `dev_auditoria_items` (UI em `/saas/desenvolvimento`)
- Memória: `project_padronizacao_sweep.md`
- ROADMAP de features (não confundir): `development/04-roadmap/ROADMAP.md`
File diff suppressed because it is too large Load Diff
+15 -55
View File
@@ -24,8 +24,7 @@
<script setup>
import { ref, computed, watch } from 'vue'
import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
import { useInsurancePlans } from '@/features/insurance/composables/useInsurancePlans'
//
const props = defineProps({
@@ -34,35 +33,13 @@ const props = defineProps({
})
const emit = defineEmits(['update:modelValue', 'update:visible', 'selected'])
const toast = useToast()
const tenantStore = useTenantStore()
//
// Auth / tenant helpers
//
async function getOwnerId () {
const { data, error } = await supabase.auth.getUser()
if (error) throw error
const uid = data?.user?.id
if (!uid) throw new Error('Sessão inválida.')
return uid
}
async function getTenantId () {
const tid = tenantStore.tenantId || tenantStore.currentTenantId || tenantStore.tenant?.id
if (tid) return tid
const ownerId = await getOwnerId()
const { data, error } = await supabase
.from('tenant_members').select('tenant_id')
.eq('user_id', ownerId).eq('status', 'active')
.order('created_at', { ascending: false }).limit(1).single()
if (error) throw error
return data?.tenant_id
}
const toast = useToast()
const insuranceStore = useInsurancePlans()
//
// Estado
//
const plans = ref([])
const plans = insuranceStore.rows // alias reativo da lista do composable
const loading = ref(false)
const searchTerm = ref('')
@@ -93,19 +70,11 @@ const selectedPlan = computed(() =>
//
async function loadPlans () {
loading.value = true
try {
const ownerId = await getOwnerId()
const { data, error } = await supabase
.from('insurance_plans')
.select('id, name, notes, default_value, active')
.eq('owner_id', ownerId)
.eq('active', true)
.order('name', { ascending: true })
if (error) throw error
plans.value = data || []
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar convênios.', life: 3500 })
} finally { loading.value = false }
await insuranceStore.loadForOwner()
if (insuranceStore.error.value) {
toast.add({ severity: 'error', summary: 'Erro', detail: insuranceStore.error.value, life: 3500 })
}
loading.value = false
}
watch(() => props.visible, (v) => {
@@ -143,26 +112,17 @@ async function savePlan () {
if (!name) { formErr.value = 'Informe o nome do convênio.'; return }
saving.value = true; formErr.value = ''
try {
const ownerId = await getOwnerId()
const tenantId = await getTenantId()
const payload = {
owner_id: ownerId,
tenant_id: tenantId,
// Repository injeta owner_id + tenant_id, sanitiza strings, faz uniqueness check (case-insensitive).
const data = await insuranceStore.create({
name,
notes: String(newPlan.value.notes || '').trim() || null,
default_value: newPlan.value.default_value !== '' ? Number(newPlan.value.default_value) : null,
active: true,
}
const { data, error } = await supabase
.from('insurance_plans').insert(payload)
.select('id, name, notes, default_value, active').single()
if (error) throw error
plans.value = [...plans.value, data].sort((a, b) => a.name.localeCompare(b.name))
notes: newPlan.value.notes || null,
default_value: newPlan.value.default_value !== '' ? newPlan.value.default_value : null,
})
toast.add({ severity: 'success', summary: 'Convênio criado', detail: `"${data.name}" adicionado.`, life: 2500 })
selectPlan(data)
} catch (e) {
const msg = e?.message || ''
formErr.value = /duplicate/i.test(msg) ? 'Já existe um convênio com esse nome.' : (msg || 'Falha ao criar.')
formErr.value = msg || 'Falha ao criar.'
} finally { saving.value = false }
}
+47 -94
View File
@@ -22,9 +22,8 @@
<script setup>
import { ref, computed, watch } from 'vue'
import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
import { digitsOnly, fmtPhone } from '@/utils/validators'
import { useMedicos } from '@/features/medicos/composables/useMedicos'
//
const props = defineProps({
@@ -33,37 +32,15 @@ const props = defineProps({
})
const emit = defineEmits(['update:visible', 'created', 'selected'])
const toast = useToast()
const tenantStore = useTenantStore()
//
// Auth / tenant
//
async function getOwnerId () {
const { data, error } = await supabase.auth.getUser()
if (error) throw error
const uid = data?.user?.id
if (!uid) throw new Error('Sessão inválida.')
return uid
}
async function getTenantId () {
const tid = tenantStore.tenantId || tenantStore.currentTenantId || tenantStore.tenant?.id
if (tid) return tid
const ownerId = await getOwnerId()
const { data, error } = await supabase
.from('tenant_members').select('tenant_id')
.eq('user_id', ownerId).eq('status', 'active')
.order('created_at', { ascending: false }).limit(1).single()
if (error) throw error
return data?.tenant_id
}
const toast = useToast()
const medicosStore = useMedicos()
//
// Views: 'list' | 'create' | 'edit'
//
const view = ref('list')
const medicos = ref([])
const loading = ref(false)
const medicos = medicosStore.rows // alias reativo da lista carregada pelo composable
const loading = ref(false) // local só pra UI da list view (composable usa loading próprio internamente)
const searchTerm = ref('')
const editingId = ref(null) // uuid do médico sendo editado
@@ -134,19 +111,11 @@ const filteredMedicos = computed(() => {
//
async function loadMedicos () {
loading.value = true
try {
const ownerId = await getOwnerId()
const { data, error } = await supabase
.from('medicos')
.select('id, nome, crm, especialidade, telefone_profissional, clinica, cidade, estado')
.eq('owner_id', ownerId)
.eq('ativo', true)
.order('nome', { ascending: true })
if (error) throw error
medicos.value = data || []
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar médicos.', life: 3500 })
} finally { loading.value = false }
await medicosStore.loadForOwner()
if (medicosStore.error.value) {
toast.add({ severity: 'error', summary: 'Erro', detail: medicosStore.error.value, life: 3500 })
}
loading.value = false
}
watch(() => props.visible, async (v) => {
@@ -166,31 +135,28 @@ watch(() => props.visible, async (v) => {
})
async function loadMedicoForEdit (id) {
try {
const ownerId = await getOwnerId()
const { data, error } = await supabase
.from('medicos').select('*').eq('id', id).eq('owner_id', ownerId).single()
if (error) throw error
form.value = {
nome: data.nome || '',
crm: data.crm || '',
especialidade: data.especialidade || '',
especialidade_outra: '',
telefone_profissional: data.telefone_profissional ? fmtPhone(data.telefone_profissional) : '',
telefone_pessoal: data.telefone_pessoal ? fmtPhone(data.telefone_pessoal) : '',
email: data.email || '',
clinica: data.clinica || '',
cidade: data.cidade || '',
estado: data.estado || 'SP',
observacoes: data.observacoes || '',
}
editingId.value = id
view.value = 'edit'
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar médico.', life: 3000 })
const data = await medicosStore.fetchById(id)
if (!data) {
toast.add({ severity: 'error', summary: 'Erro', detail: medicosStore.error.value || 'Médico não encontrado.', life: 3000 })
view.value = 'list'
loadMedicos()
return
}
form.value = {
nome: data.nome || '',
crm: data.crm || '',
especialidade: data.especialidade || '',
especialidade_outra: '',
telefone_profissional: data.telefone_profissional ? fmtPhone(data.telefone_profissional) : '',
telefone_pessoal: data.telefone_pessoal ? fmtPhone(data.telefone_pessoal) : '',
email: data.email || '',
clinica: data.clinica || '',
cidade: data.cidade || '',
estado: data.estado || 'SP',
observacoes: data.observacoes || '',
}
editingId.value = id
view.value = 'edit'
}
//
@@ -229,44 +195,31 @@ async function saveMedico () {
const isUpdate = !!editingId.value
try {
const ownerId = await getOwnerId()
const tenantId = await getTenantId()
// Payload: owner_id e tenant_id são injetados pelo repository.
// Repository sanitiza trim/nullif em strings; componente só normaliza telefones (digits-only).
const payload = {
owner_id: ownerId,
tenant_id: tenantId,
nome,
crm: String(form.value.crm || '').trim() || null,
crm: form.value.crm || null,
especialidade: especialidadeFinal.value,
telefone_profissional: form.value.telefone_profissional ? digitsOnly(form.value.telefone_profissional) : null,
telefone_pessoal: form.value.telefone_pessoal ? digitsOnly(form.value.telefone_pessoal) : null,
email: String(form.value.email || '').trim() || null,
clinica: String(form.value.clinica || '').trim() || null,
cidade: String(form.value.cidade || '').trim() || null,
estado: String(form.value.estado || '').trim() || null,
observacoes: String(form.value.observacoes || '').trim() || null,
ativo: true,
email: form.value.email || null,
clinica: form.value.clinica || null,
cidade: form.value.cidade || null,
estado: form.value.estado || null,
observacoes: form.value.observacoes || null,
}
let data
if (isUpdate) {
const { data: d, error } = await supabase
.from('medicos').update({ ...payload, updated_at: new Date().toISOString() })
.eq('id', editingId.value).eq('owner_id', ownerId)
.select('id, nome, crm, especialidade, telefone_profissional, clinica, cidade, estado')
.single()
if (error) throw error
data = d
toast.add({ severity: 'success', summary: 'Médico atualizado', detail: `Dr(a). ${data.nome} atualizado.`, life: 2500 })
} else {
const { data: d, error } = await supabase
.from('medicos').insert(payload)
.select('id, nome, crm, especialidade, telefone_profissional, clinica, cidade, estado')
.single()
if (error) throw error
data = d
toast.add({ severity: 'success', summary: 'Médico cadastrado', detail: `Dr(a). ${data.nome} adicionado.`, life: 2500 })
}
const data = isUpdate
? await medicosStore.update(editingId.value, payload)
: await medicosStore.create(payload)
toast.add({
severity: 'success',
summary: isUpdate ? 'Médico atualizado' : 'Médico cadastrado',
detail: `Dr(a). ${data.nome} ${isUpdate ? 'atualizado' : 'adicionado'}.`,
life: 2500
})
emit(isUpdate ? 'selected' : 'created', data)
emit('selected', data)
+19 -40
View File
@@ -25,10 +25,13 @@ import { useToast } from 'primevue/usetoast';
import InputMask from 'primevue/inputmask';
import Message from 'primevue/message';
import { supabase } from '@/lib/supabase/client';
// Audit alta (2026-05-20): substituir supabase direto por repository pattern.
import { usePatients } from '@/features/patients/composables/usePatients';
import { getMyActiveMember } from '@/features/tenantship/services/tenantMembersRepository';
const { canSee } = useRoleGuard();
const route = useRoute();
const router = useRouter();
const patientsStore = usePatients();
const isOnPatientsPage = computed(() => {
const p = String(route.path || '');
@@ -145,31 +148,6 @@ function normalizePhoneDigits(v) {
return sanitizeDigits(v);
}
async function getOwnerId() {
const { data, error } = await supabase.auth.getUser();
if (error) throw error;
const user = data?.user;
if (!user?.id) throw new Error('Usuário não encontrado. Faça login novamente.');
return user.id;
}
/**
* Pega tenant_id + member_id do usuário logado.
*/
async function resolveTenantContextOrFail() {
const { data: authData, error: authError } = await supabase.auth.getUser();
if (authError) throw authError;
const uid = authData?.user?.id;
if (!uid) throw new Error('Sessão inválida.');
const { data, error } = await supabase.from('tenant_members').select('id, tenant_id').eq('user_id', uid).eq('status', 'active').order('created_at', { ascending: false }).limit(1).single();
if (error) throw error;
if (!data?.tenant_id || !data?.id) throw new Error('Responsible member not found');
return { tenantId: data.tenant_id, memberId: data.id };
}
/* ----------------------------
* Gerador (nome/email/telefone)
* ---------------------------- */
@@ -240,29 +218,30 @@ async function submit(mode = 'only') {
saving.value = true;
try {
const ownerId = await getOwnerId();
const { tenantId, memberId } = await resolveTenantContextOrFail();
// Resolve tenant_member ativo via repository (audit: não mais supabase direto)
const member = await getMyActiveMember();
if (!member?.id || !member?.tenant_id) {
throw new Error('Responsible member not found');
}
// extraPayload antes; tenant/responsible forçados depois (não podem ser sobrescritos sem querer)
// Payload canônico campos hardcoded da tabela patients.
// Props deprecated (tableName/ownerField/etc) são ignoradas internamente.
// owner_id é injetado pelo repository (auth.uid()) não passamos aqui.
// extraPayload pode trazer status, observações, etc.
const payload = {
...props.extraPayload,
[props.ownerField]: ownerId,
[props.tenantField]: tenantId,
[props.responsibleMemberField]: memberId,
[props.nameField]: nome,
[props.emailField]: email.toLowerCase(),
[props.phoneField]: normalizePhoneDigits(tel)
tenant_id: member.tenant_id,
responsible_member_id: member.id,
nome_completo: nome,
email_principal: email.toLowerCase(),
telefone: normalizePhoneDigits(tel)
};
Object.keys(payload).forEach((k) => {
if (payload[k] === undefined) delete payload[k];
});
const { data, error } = await supabase.from(props.tableName).insert(payload).select().single();
if (error) throw error;
const data = await patientsStore.create(payload);
toast.add({
severity: 'success',
@@ -100,6 +100,20 @@ const scenario = computed(() => {
const canAct = computed(() => record.value && (record.value.status === 'pending' || record.value.status === 'overdue'));
// Sessão encerrada (não rolou) bloqueia geração de cobrança nova.
// Multa em cancelado/faltou deve passar pelo AgendaStatusChangeConfirmDialog,
// não por "Gerar cobrança" solto que ignora o motivo.
const isSessaoEncerrada = computed(() => {
const s = String(props.evento?.status || '').toLowerCase();
return s === 'cancelado' || s === 'cancelada' || s === 'faltou';
});
const semCobrancaLabel = computed(() => {
const s = String(props.evento?.status || '').toLowerCase();
if (s === 'cancelado' || s === 'cancelada') return 'Sessão cancelada · sem cobrança ativa';
if (s === 'faltou') return 'Sessão não realizada · sem cobrança ativa';
return 'Sem cobrança gerada';
});
// buscar financial_record pelo evento
async function fetchRecord() {
if (!props.evento.id) return;
@@ -235,10 +249,13 @@ function requestCancel() {
<div v-else-if="scenario === 'sem-cobranca'" class="fin-panel__body fin-panel__body--empty">
<div class="flex items-center gap-2 text-[var(--text-color-secondary)]">
<i class="pi pi-minus-circle text-sm opacity-50" />
<span class="text-sm">Sem cobrança gerada</span>
<span class="text-sm">{{ semCobrancaLabel }}</span>
</div>
<Button label="Gerar cobrança" icon="pi pi-plus" size="small" class="rounded-full mt-2" :loading="generating || finLoading" @click="onGerarCobranca" />
<div v-if="props.evento.price" class="text-xs text-[var(--text-color-secondary)] mt-1">Valor da sessão: {{ fmtBRL(props.evento.price) }}</div>
<!-- Botão "Gerar cobrança" aparece em status ativo (agendado/realizado).
Pra cancelado/faltou: sessão não aconteceu cobrança nova não cabe
aqui. Pra registrar multa, usar o dialog de status change. -->
<Button v-if="!isSessaoEncerrada" label="Gerar cobrança" icon="pi pi-plus" size="small" class="rounded-full mt-2" :loading="generating || finLoading" @click="onGerarCobranca" />
<div v-if="props.evento.price && !isSessaoEncerrada" class="text-xs text-[var(--text-color-secondary)] mt-1">Valor da sessão: {{ fmtBRL(props.evento.price) }}</div>
</div>
<!-- Carregando o financial_record -->
+1 -1
View File
@@ -56,7 +56,7 @@ const STATUS_TO_EXCEPTION = {
function calcChargeAmount(originalAmount, rule) {
if (!rule || rule.charge_mode === 'none') return 0;
if (rule.charge_mode === 'full') return originalAmount;
if (rule.charge_mode === 'fixed') return rule.charge_value ?? 0;
if (rule.charge_mode === 'fixed_fee') return rule.charge_value ?? 0;
if (rule.charge_mode === 'percentage') {
const pct = rule.charge_pct ?? 0;
return parseFloat(((originalAmount * pct) / 100).toFixed(2));
+134
View File
@@ -0,0 +1,134 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/composables/useTopbarDevMenuExtras.js
|
| Extras DEV-only que aparecem dentro do mesmo botão "sliders" do topbar
| (junto com o switcher de planos do useTopbarPlanMenu). Adiciona:
|
| 1) Switcher de layout (rail | melissa) UPDATE em user_settings +
| localStorage + hard reload pra router decidir redirect.
|
| 2) Atalhos rápidos pra testar M1.3 (ComponentCadastroRapido nos
| diversos callers) e M1.1/M1.2 (CadastroRapidoMedico/Convenio).
|
| Visibilidade: assume que o caller renderiza o menu se `showPlanDevMenu`
| estiver true (DEV + permissão). Não duplica essa lógica aqui.
|
| Uso em AppTopbar.vue / MelissaLayout.vue:
| const { devExtrasModel } = useTopbarDevMenuExtras();
| const combinedDevMenuModel = computed(() => [
| ...planMenuModel.value,
| { separator: true },
| ...devExtrasModel.value
| ]);
| <Menu :model="combinedDevMenuModel" ... />
|--------------------------------------------------------------------------
*/
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import { useLayout } from '@/layout/composables/layout';
export function useTopbarDevMenuExtras() {
const router = useRouter();
const toast = useToast();
const { layoutConfig } = useLayout();
const currentVariant = computed(() => layoutConfig.variant || 'classic');
const isRailLike = computed(() => currentVariant.value === 'rail' || currentVariant.value === 'classic');
const isMelissa = computed(() => currentVariant.value === 'melissa');
async function setLayoutAndReload(variant) {
try {
const { data, error: authErr } = await supabase.auth.getUser();
if (authErr) throw authErr;
const uid = data?.user?.id;
if (!uid) throw new Error('Sem sessão.');
const { error } = await supabase
.from('user_settings')
.upsert(
{
user_id: uid,
layout_variant: variant,
updated_at: new Date().toISOString()
},
{ onConflict: 'user_id' }
);
if (error) throw error;
// Fast path: router.beforeEach/guards lêem localStorage antes do fetch
try {
localStorage.setItem('layout_variant', variant);
} catch (_) {
// ignore (Safari private mode etc.)
}
toast.add({ severity: 'success', summary: `Layout: ${variant}`, life: 1200 });
// Hard reload pra router redirecionar pra raiz correta
setTimeout(() => window.location.assign('/'), 250);
} catch (e) {
toast.add({
severity: 'error',
summary: 'Erro ao trocar layout',
detail: e?.message || 'Falha desconhecida.',
life: 4500
});
}
}
function goto(path) {
// router.push em vez de location.assign — mantém SPA + dev tools abertos.
// Se o guard redirecionar (ex: Melissa mode bloqueia /therapist/...),
// o usuário verá o redirect — sinal de que precisa trocar layout primeiro.
router.push(path).catch(() => {});
}
const devExtrasModel = computed(() => [
// ─── Layout ────────────────────────────────────────
{ label: 'Layout (DEV)', icon: 'pi pi-th-large', disabled: true },
{
label: isRailLike.value ? 'Rail (atual)' : 'Rail',
icon: isRailLike.value ? 'pi pi-check' : 'pi pi-bars',
disabled: isRailLike.value,
command: () => setLayoutAndReload('rail')
},
{
label: isMelissa.value ? 'Melissa (atual)' : 'Melissa',
icon: isMelissa.value ? 'pi pi-check' : 'pi pi-window-maximize',
disabled: isMelissa.value,
command: () => setLayoutAndReload('melissa')
},
{ separator: true },
// ─── Atalhos pra testar Módulo 1 ───────────────────
{ label: 'Testar Módulo 1 (DEV)', icon: 'pi pi-bullseye', disabled: true },
{
label: '→ Cadastro Paciente (M1.1 + M1.2)',
icon: 'pi pi-user-plus',
command: () => goto('/therapist/patients/cadastro')
},
{
label: '→ Lista de Pacientes (M1.3-E)',
icon: 'pi pi-list',
command: () => goto('/therapist/patients')
},
{
label: '→ Melissa Agenda (M1.3-B)',
icon: 'pi pi-calendar',
command: () => goto('/melissa/agenda')
},
{
label: '→ Melissa Pacientes (M1.3-B)',
icon: 'pi pi-users',
command: () => goto('/melissa/pacientes')
}
]);
return { devExtrasModel };
}
+29
View File
@@ -0,0 +1,29 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/config/devTestAccounts.js
|
| Contas de teste seedadas pelo banco para QA/dev. Usado em HomeCards.vue
| pra prefill de login (botão "Entrar como..." em dev).
|
| Senhas em texto vale porque o banco local tem essas mesmas senhas
| seedadas (seed_001/002/003). Em produção, este arquivo segue compilado
| mas o flag `isDev` em HomeCards.vue garante que os botões não aparecem.
|
| Pra gate em build de produção (remover do bundle), tratar como import
| dinâmico no futuro ou condicional via `import.meta.env.DEV` no consumidor.
|--------------------------------------------------------------------------
*/
export const TEST_ACCOUNTS = {
clinic_admin: { email: 'clinica3@agenciapsi.com.br', password: 'Teste@123' },
therapist: { email: 'terapeuta@agenciapsi.com.br', password: 'Teste@123' },
supervisor: { email: 'supervisor@agenciapsi.com.br', password: 'Teste@123' },
patient: { email: 'paciente@agenciapsi.com.br', password: 'Teste@123' },
saas: { email: 'saas@agenciapsi.com.br', password: 'Teste@123' },
editor: { email: 'editor@agenciapsi.com.br', password: 'Teste@123' },
therapist2: { email: 'therapist2@agenciapsi.com.br', password: 'Teste@123' },
therapist3: { email: 'therapist3@agenciapsi.com.br', password: 'Teste@123' },
secretary: { email: 'secretary@agenciapsi.com.br', password: 'Teste@123' }
};
@@ -112,6 +112,7 @@
<aside class="text-xs sticky top-20 self-start max-h-[80vh] overflow-y-auto">
<p class="font-semibold text-slate-700 uppercase tracking-wide mb-2 text-[.65rem]">Cenários</p>
<nav class="space-y-0.5" id="toc">
<a href="#addendum-c10" class="toc-link block px-2 py-1 rounded hover:bg-slate-100 text-violet-700">✦ Addendum C10 (20/05)</a>
<a href="#indicadores" class="toc-link block px-2 py-1 rounded hover:bg-slate-100 text-violet-700">★ Indicadores visuais</a>
<a href="#c1" class="toc-link block px-2 py-1 rounded hover:bg-slate-100">1 · Bloqueio</a>
<p class="font-semibold text-slate-500 uppercase mt-3 text-[.6rem] px-2">Avulsa</p>
@@ -143,6 +144,138 @@
<!-- Main -->
<main class="space-y-6">
<!-- ============================================================ -->
<!-- ADDENDUM 2026-05-20 — Divergências e melhorias C10 -->
<!-- ============================================================ -->
<section id="addendum-c10" class="scene">
<header class="mb-2">
<h2 class="text-base font-semibold text-slate-900 flex items-center gap-2">
<span class="pill pill-violet">✦ addendum</span>
Implementado em 20/05 (C10) — divergências e melhorias vs mockup
</h2>
<p class="text-xs text-slate-500 ml-1 mt-1">
O mockup original deste doc foi escrito antes da implementação real. Durante a bateria de testes C10 (status change avulsa), surgiram bugs, melhorias UX e travas que foram implementadas mas não estão refletidas nas seções abaixo. Este addendum captura essas mudanças. Cenários C1-C9 continuam fiéis ao mockup; C10 deve ser lido com este addendum em mente.
</p>
</header>
<div class="card">
<div class="card-head">
<i class="pi pi-wrench"></i>
<h4>O que ficou diferente / melhor que o mockup original</h4>
</div>
<div class="card-body p-4 space-y-3 text-sm">
<!-- 1. Multa cancela original + cria novo -->
<div>
<div class="font-semibold text-slate-800 mb-1">1. Multa <span class="pill pill-violet">cancela original + cria novo</span></div>
<div class="text-slate-600 text-xs leading-relaxed">
Antes do fix: <code>_applyStatusDecisions</code> INSERIA o record da multa MAS deixava o original pending → cobrança dupla (R$ 200 + R$ 30 = R$ 230). Fix em <code>useMelissaAgenda.js:1450-1505</code>: aplicar multa agora cancela o <code>ctx.pendingRecord</code> com nota de auditoria em <code>notes</code> ("[YYYY-MM-DD] Cancelada — substituída por multa de no-show"). Description do novo record carrega data da sessão pra paciente identificar na fatura: <code>"Multa por falta · sessão dd/mm/aa"</code>. ✅ Match com o mock C10/b.
</div>
</div>
<!-- 2. Hint contextual no dialog -->
<div>
<div class="font-semibold text-slate-800 mb-1">2. Hint contextual explicando regra <code>min_hours_notice</code> <span class="pill pill-info">novo</span></div>
<div class="text-slate-600 text-xs leading-relaxed">
No bloco "Aplicar multa?" do <code>AgendaStatusChangeConfirmDialog</code>, embaixo do checkbox aparece texto explicando por que veio (des)marcado por padrão:
<ul class="list-disc pl-5 mt-1 space-y-0.5">
<li><b>&gt; janela:</b> "Cancelou 18.5h antes da sessão. Regra: multa apenas quando cancelamento ocorre com menos de 2h de antecedência → sem multa por padrão."</li>
<li><b>&lt; janela:</b> "Cancelou 45min antes da sessão (menos que os 2h da regra) → multa aplicada por padrão."</li>
<li><b>Após início:</b> "Cancelou 0.5h após o início da sessão (menos que os 2h da regra) → multa aplicada por padrão."</li>
</ul>
Terapeuta vê a razão da pré-seleção e pode inverter conscientemente.
</div>
</div>
<!-- 3. Botão Agendada -->
<div>
<div class="font-semibold text-slate-800 mb-1">3. Botão "Agendada" no popover <span class="pill pill-info">novo</span></div>
<div class="text-slate-600 text-xs leading-relaxed">
O grupo "Marcar sessão como:" agora tem 5 botões (antes 4): <b>Agendada</b> (pi-calendar, variante <code>--info</code> cyan) | Realizada | Falta | Reagendar | Cancelar. Permite reset de status (realizado/faltou/cancelado → agendado) direto do popover sem precisar abrir o AgendaEventDialog completo. Único caminho de saída do estado encerrado (ver item 5).
</div>
</div>
<!-- 4. Label financeiro pra sessão encerrada -->
<div>
<div class="font-semibold text-slate-800 mb-1">4. Label do popover muda em sessão encerrada <span class="pill pill-info">UX</span></div>
<div class="text-slate-600 text-xs leading-relaxed">
Antes mostrava "A cobrar R$ 150" + botão "Gerar fatura" mesmo em sessão cancelada — sugeria que dava pra cobrar uma sessão que não aconteceu. Agora:
<ul class="list-disc pl-5 mt-1 space-y-0.5">
<li><code>status='cancelado'</code> + sem record ativo → "Sessão cancelada · sem cobrança ativa"</li>
<li><code>status='faltou'</code> + sem record ativo → "Sessão não realizada · sem cobrança ativa"</li>
<li>Multa pending continua mostrando "A receber R$ X (pendente)" normalmente</li>
</ul>
Bug paralelo fixado: <code>paymentLabel</code> agora usa <code>paymentAmount</code> também pra <code>'pending'</code> (antes só pra <code>'paid'</code>; multa de R$ 30 mostrava R$ 150 do <code>ev.price</code> original).
</div>
</div>
<!-- 5. Lock em sessão encerrada -->
<div>
<div class="font-semibold text-slate-800 mb-1">5. Lock em sessão encerrada (cancelado/faltou) <span class="pill pill-cancel">trava</span></div>
<div class="text-slate-600 text-xs leading-relaxed">
Sessão com status <code>cancelado</code> ou <code>faltou</code> bloqueia ações que abrem porta pra dados inconsistentes:
<ul class="list-disc pl-5 mt-1 space-y-0.5">
<li>Botão <b>"Editar sessão"</b> some do popover</li>
<li>Botão <b>"Gerar cobrança"</b> some do <code>AgendaEventoFinanceiroPanel</code> (dentro do AgendaEventDialog) — antes dava pra emitir fatura nova mesmo em sessão cancelada</li>
<li>Botões <b>Realizada / Falta / Reagendar / Cancelar</b> ficam <code>disabled</code> com tooltip "Sessão encerrada — use Agendada pra reativar antes"</li>
<li><b>Agendada</b> continua funcional (caminho explícito de recuperação caso tenha sido marcado por engano)</li>
<li>Badge $ amber some do card no FullCalendar (sessão encerrada + record cancelled → no badge)</li>
</ul>
</div>
</div>
<!-- 6. Bubble + reloadRange -->
<div>
<div class="font-semibold text-slate-800 mb-1">6. <code>_reloadRange()</code> após status change <span class="pill pill-info">fix</span></div>
<div class="text-slate-600 text-xs leading-relaxed">
<code>onUpdateSeriesEvent</code> não chamava <code>_reloadRange()</code> após <code>_applyStatusDecisions</code> — badge $ e label "A receber" ficavam stale até trocar de view ou F5. Fix: reload no fim do flow. Bug paralelo: <code>_reloadRange</code> não estava destruturado em <code>_buildHandlers(deps)</code> (era passado em deps mas não desempacotado) → toast "ReferenceError: _reloadRange is not defined" ao tentar reload. Ambos corrigidos.
</div>
</div>
<!-- 7. Dormant -->
<div>
<div class="font-semibold text-slate-800 mb-1">7. Bug dormente em <code>useAgendaFinanceiro.js</code> <span class="pill pill-info">fix</span></div>
<div class="text-slate-600 text-xs leading-relaxed">
<code>calcChargeAmount</code> comparava <code>charge_mode === 'fixed'</code>, mas o schema usa <code>'fixed_fee'</code>. Off-by-key silencioso que caía no fallback. Path não exercitado na Melissa (que usa <code>_applyStatusDecisions</code>, não <code>handleStatusChange</code>), mas iria quebrar se algum dia fosse. Fix: <code>'fixed_fee'</code>.
</div>
</div>
</div>
</div>
<!-- Pendências -->
<div class="card mt-3">
<div class="card-head">
<i class="pi pi-flag"></i>
<h4>Pendências mapeadas durante C10 — implementar pós-C13</h4>
</div>
<div class="card-body p-4 space-y-3 text-sm">
<div>
<div class="font-semibold text-amber-700 mb-1">⚠ Reverse transitions com multa órfã</div>
<div class="text-slate-600 text-xs leading-relaxed">
Caso: terapeuta marca "Faltou" com multa R$ 30 → percebe que foi engano → clica "Agendada" pra reativar → status volta pra agendado MAS multa R$ 30 fica pending órfã. Hoje precisa cancelar manualmente em <code>/financeiro</code>. Solução planejada: confirm dialog ao reverter de cancelado/faltou pra agendado com record/multa pending → oferecer auto-cancelar a multa também (radio sim/não). Memória salva em <code>project_agenda_reverse_transitions.md</code>.
</div>
</div>
<div>
<div class="font-semibold text-amber-700 mb-1">⚠ Popover Melissa = snapshot do clique</div>
<div class="text-slate-600 text-xs leading-relaxed">
<code>eventoSelecionado.value</code> é setado uma vez em <code>abrirEvento(ev)</code> — quando <code>_paymentStateMap</code> updata depois (ex: bulk-load assíncrono pós F5 leva 1-3s), o popover NÃO re-renderiza com state novo. Caso típico: F5 + clique rapidíssimo no card → popover mostra "A cobrar R$ 150" (state='none' default) porque snapshot pegou map vazio. Fix planejado: guardar <code>ev.id</code> em vez de <code>ev</code>, popover deriva via computed <code>eventos.value.find(...)</code>. Memória em <code>project_melissa_popover_snapshot.md</code>.
</div>
</div>
<div>
<div class="font-semibold text-amber-700 mb-1">⚠ A2 do João Almeida com markPaid não persistiu</div>
<div class="text-slate-600 text-xs leading-relaxed">
Durante teste C10/A2, usuário marcou Realizada + "Sim, registrar pagamento" + Maquininha. Toast verde, card mudou visual, mas DB mostra <code>financial_records.status='pending'</code> em vez de <code>'paid'</code>. A investigar pós-C13 — pode ser que o reset/realizada de novo tenha sobrescrito, ou o markPaid não tenha entrado no caminho de UPDATE.
</div>
</div>
</div>
</div>
</section>
<!-- ============================================================ -->
<!-- Legenda: Indicadores visuais de pagamento (badge $ + linha) -->
<!-- ============================================================ -->
File diff suppressed because it is too large Load Diff
@@ -43,14 +43,20 @@ import { ref, computed, watch } from 'vue';
const props = defineProps({
modelValue: { type: Boolean, default: false },
evento: { type: Object, default: null },
novoStatus: { type: String, default: '' }, // 'realizado' | 'faltou' | 'cancelado'
novoStatus: { type: String, default: '' }, // 'realizado' | 'faltou' | 'cancelado' | 'agendado' (reverse)
regraExcecao: { type: Object, default: null }, // row de financial_exceptions ou null
billingContract: { type: Object, default: null }, // row de billing_contracts ou null
billingContractStyle: { type: String, default: null }, // 'upfront' | 'saldo' | null
// Quando avulsa+pendente e novoStatus='realizado': financial_record relacionado
pendingRecord: { type: Object, default: null },
// Quando pacote saldo + realizado + record paid pré-existente (C12 antecipado):
// dialog não oferece "Gerar cobrança" só confirma consumo de saldo.
existingPaidRecord: { type: Object, default: null },
// Preço da sessão (pra calcular multa percentual e cobrança de pacote saldo)
sessionPrice: { type: Number, default: 0 }
sessionPrice: { type: Number, default: 0 },
// Reverse transition (novoStatus='agendado'): artefatos a desfazer.
// { previousStatus, activeRecords[], saldoConsumed }
reverseArtifacts: { type: Object, default: null }
});
const emit = defineEmits(['update:modelValue', 'confirm']);
@@ -62,6 +68,11 @@ const fineAmount = ref(0);
const markPaid = ref(true); // default em realizado: já recebeu
const paymentMethod = ref('pix'); // método em "como recebeu" ou "como cobrar"
const generatePackageCharge = ref(true); // realizado em pacote saldo: gera por padrão
// Reverse transition state (novoStatus='agendado')
// Decisões pra desfazer ao reverter pra agendado.
const reverseCancelPending = ref(true); // cancela records pending/overdue
const reverseRestoreSaldo = ref(true); // devolve 1 ao saldo do pacote
// Records 'paid' não têm radio só warning textual (estorno é manual).
// Reset/init ao abrir
watch(
@@ -72,19 +83,52 @@ watch(
consumeSaldo.value = !!props.regraExcecao?.default_consume_on_miss;
applyFine.value = _calcInitialFineApply();
fineAmount.value = _calcInitialFineAmount();
markPaid.value = true;
paymentMethod.value = 'pix';
// Default markPaid:
// - Avulsa realizada (showRegistrarPagto): default false (manter pendente)
// - Pacote saldo realizada (showCobrancaPacote): default false (gerar pendente)
// Em ambos casos o user precisa selecionar ativamente "Sim, já recebi"
// pra registrar paid evita marcar paid sem querer.
markPaid.value = false;
// paymentMethod default depende do contexto. Inicia 'pending' (que cai
// no select de "Como vai cobrar?" quando markPaid=false). Quando user
// troca pra "Sim, já recebi", precisa escolher PIX/Dinheiro/etc.
paymentMethod.value = 'pending';
generatePackageCharge.value = true;
// Reverse transition: defaults safer = cancela records pendentes +
// devolve saldo (typical recovery flow). Records paid não têm radio.
reverseCancelPending.value = true;
reverseRestoreSaldo.value = true;
}
);
// Computeds: o que renderizar
const isFaltouOrCancelado = computed(() => props.novoStatus === 'faltou' || props.novoStatus === 'cancelado');
const isRealizado = computed(() => props.novoStatus === 'realizado');
const isReverseAgendado = computed(() => props.novoStatus === 'agendado');
const isAvulsa = computed(() => !props.billingContract);
const isPacoteSaldo = computed(() => props.billingContract?.type === 'package' && props.billingContractStyle === 'saldo');
const isPacoteUpfront = computed(() => props.billingContract?.type === 'package' && props.billingContractStyle === 'upfront');
// Reverse transition: derivados pra renderizar blocos
const reversePendingRecords = computed(() => {
const recs = props.reverseArtifacts?.activeRecords || [];
return recs.filter((r) => r.status === 'pending' || r.status === 'overdue');
});
const reversePaidRecords = computed(() => {
const recs = props.reverseArtifacts?.activeRecords || [];
return recs.filter((r) => r.status === 'paid');
});
const reverseHasPaid = computed(() => reversePaidRecords.value.length > 0);
const reverseHasPending = computed(() => reversePendingRecords.value.length > 0);
const reverseShowSaldo = computed(() => isReverseAgendado.value && !!props.reverseArtifacts?.saldoConsumed);
const reversePreviousStatusLabel = computed(() => {
const s = props.reverseArtifacts?.previousStatus;
if (s === 'realizado') return 'Realizada';
if (s === 'faltou') return 'Faltou';
if (s === 'cancelado') return 'Cancelado';
return s || 'estado anterior';
});
// Mostrar bloco multa: faltou/cancelado + regra existe + charge_mode != 'none'
const showFineBlock = computed(() => isFaltouOrCancelado.value && props.regraExcecao && props.regraExcecao.charge_mode !== 'none');
@@ -94,12 +138,20 @@ const showSaldoBlock = computed(() => isFaltouOrCancelado.value && isPacoteSaldo
// Mostrar bloco "registrar pagamento": realizado + avulsa pendente
const showRegistrarPagto = computed(() => isRealizado.value && isAvulsa.value && props.pendingRecord && props.pendingRecord.status === 'pending');
// Mostrar bloco "cobrança no pacote": realizado + pacote saldo
const showCobrancaPacote = computed(() => isRealizado.value && isPacoteSaldo.value);
// Mostrar bloco "cobrança no pacote": realizado + pacote saldo + SEM paid pré-existente
// (se já tem paid via antecipação, mostra o bloco "já pago" em vez deste)
const showCobrancaPacote = computed(() => isRealizado.value && isPacoteSaldo.value && !props.existingPaidRecord);
// Mostrar bloco "já paga via antecipação": realizado + pacote saldo + paid pré-existente
const showAlreadyPaid = computed(() => isRealizado.value && isPacoteSaldo.value && !!props.existingPaidRecord);
// Header
const headerTitle = computed(() => {
const labels = { realizado: '✓ Marcar como Realizado', faltou: '⚠ Marcar como Faltou', cancelado: '✕ Marcar como Cancelado' };
const labels = {
realizado: '✓ Marcar como Realizado',
faltou: '⚠ Marcar como Faltou',
cancelado: '✕ Marcar como Cancelado',
agendado: '↺ Reativar sessão (voltar pra Agendada)'
};
return labels[props.novoStatus] || 'Atualizar status';
});
@@ -138,12 +190,13 @@ const paymentMethodOptions = [
{ value: 'deposito', label: 'Depósito' },
{ value: 'cartao_maquininha', label: 'Cartão (maquininha)' }
];
const paymentMethodOptionsCobranca = [
{ value: 'link', label: 'Enviar link de pagamento (Asaas)' },
{ value: 'pix', label: 'Já recebi — PIX' },
{ value: 'dinheiro', label: 'Já recebi — Dinheiro' },
{ value: 'deposito', label: 'Já recebi — Depósito' },
{ value: 'cartao_maquininha', label: 'Já recebi — Cartão (maquininha)' }
// Opções pra "Como vai cobrar?" quando markPaid=false (sessão pendente
// no pacote saldo). 'pending' = só registra como pendente, terapeuta
// cobra depois pelo /financeiro. 'link' = gera link Asaas e marca
// payment_method='asaas' no record (pós-confirm o handler updata).
const paymentMethodOptionsPending = [
{ value: 'pending', label: 'Apenas registrar como pendente' },
{ value: 'link', label: 'Enviar link de pagamento (Asaas)' }
];
const regraResumo = computed(() => {
@@ -182,15 +235,43 @@ function _calcInitialFineApply() {
return true;
}
// Texto explicativo de porquê a multa veio (des)marcada por padrão.
// Aparece abaixo do checkbox no bloco multa pra deixar a regra visível
// ao terapeuta no momento da decisão.
const fineDefaultReason = computed(() => {
const r = props.regraExcecao;
if (!r || r.charge_mode === 'none') return '';
if (props.novoStatus !== 'cancelado' || r.min_hours_notice == null || !props.evento?.inicio_em) return '';
const horasAteSessao = (new Date(props.evento.inicio_em).getTime() - Date.now()) / (1000 * 60 * 60);
const min = Number(r.min_hours_notice);
const horasFmt = horasAteSessao < 0
? `${Math.abs(horasAteSessao).toFixed(1)}h após o início`
: horasAteSessao < 1
? `${Math.round(horasAteSessao * 60)}min antes`
: `${horasAteSessao.toFixed(1)}h antes`;
if (horasAteSessao >= min) {
return `Cancelou ${horasFmt} da sessão. Regra: multa apenas quando cancelamento ocorre com menos de ${min}h de antecedência → sem multa por padrão.`;
}
return `Cancelou ${horasFmt} da sessão (menos que os ${min}h da regra) → multa aplicada por padrão.`;
});
// Actions
function onConfirm() {
// markPaid agora é considerado em DOIS contextos:
// 1. Avulsa pendente (showRegistrarPagto): paciente já pagou a cobrança?
// 2. Pacote saldo realizado (showCobrancaPacote): já recebeu o valor da sessão?
// Em ambos casos: markPaid=true record vira paid; false fica pending.
const considerMarkPaid = showRegistrarPagto.value || (showCobrancaPacote.value && generatePackageCharge.value);
emit('confirm', {
consumeSaldo: showSaldoBlock.value ? consumeSaldo.value : false,
applyFine: showFineBlock.value ? applyFine.value : false,
fineAmount: showFineBlock.value && applyFine.value ? Number(fineAmount.value) || 0 : 0,
markPaid: showRegistrarPagto.value ? markPaid.value : false,
markPaid: considerMarkPaid ? markPaid.value : false,
paymentMethod: paymentMethod.value,
generatePackageCharge: showCobrancaPacote.value ? generatePackageCharge.value : false
generatePackageCharge: showCobrancaPacote.value ? generatePackageCharge.value : false,
// Reverse transition: só relevante quando novoStatus='agendado'
reverseCancelPending: isReverseAgendado.value ? reverseCancelPending.value : false,
reverseRestoreSaldo: isReverseAgendado.value ? reverseRestoreSaldo.value : false
});
emit('update:modelValue', false);
}
@@ -225,6 +306,67 @@ function onCancel() {
</div>
</div>
<!-- Reverse transition: voltar pra Agendada (com artefatos) -->
<div v-if="isReverseAgendado" class="asccd-block">
<div class="asccd-block__title">
<i class="pi pi-info-circle" />
Reverter status de <b>{{ reversePreviousStatusLabel }}</b> pra <b>Agendada</b>
</div>
<small class="asccd-hint">Esta sessão tem ações financeiras vinculadas. Escolha o que fazer com cada uma antes de reverter:</small>
<!-- Records pendentes -->
<div v-if="reverseHasPending" class="asccd-block mt-3">
<div class="asccd-block__title">
<i class="pi pi-money-bill" />
Cobrança pendente vinculada
</div>
<ul class="asccd-list">
<li v-for="r in reversePendingRecords" :key="r.id">
<span class="font-medium">{{ r.description || 'Cobrança' }}</span>
<span> · R$ {{ _fmtBRL(r.final_amount || r.amount) }}</span>
</li>
</ul>
<div class="asccd-radio-group mt-2">
<label class="asccd-radio">
<input type="radio" :value="true" v-model="reverseCancelPending" />
<span><b>Cancelar</b> a(s) cobrança(s) recomendado</span>
</label>
<label class="asccd-radio">
<input type="radio" :value="false" v-model="reverseCancelPending" />
<span><b>Manter</b> ativa(s) sessão volta agendada mas cobrança continua aberta</span>
</label>
</div>
</div>
<!-- Records pagos: warning sem ação -->
<div v-if="reverseHasPaid" class="asccd-info mt-3">
<i class="pi pi-exclamation-triangle" />
<div>
<b>Atenção:</b> Esta sessão tem cobrança(s) paga(s) ({{ reversePaidRecords.length }} record(s)).
Reverter o status NÃO estorna o pagamento automaticamente pra estornar use o /financeiro.
</div>
</div>
<!-- Saldo consumido em pacote -->
<div v-if="reverseShowSaldo" class="asccd-block mt-3">
<div class="asccd-block__title">
<i class="pi pi-wallet" />
Saldo do pacote consumido
</div>
<div class="asccd-fine-rule">Saldo atual: {{ billingContract?.sessions_used ?? '?' }} de {{ billingContract?.total_sessions ?? '?' }} usadas</div>
<div class="asccd-radio-group mt-2">
<label class="asccd-radio">
<input type="radio" :value="true" v-model="reverseRestoreSaldo" />
<span><b>Devolver</b> 1 sessão ao saldo recomendado</span>
</label>
<label class="asccd-radio">
<input type="radio" :value="false" v-model="reverseRestoreSaldo" />
<span><b>Manter</b> saldo consumido sessão volta agendada mas saldo continua decrementado</span>
</label>
</div>
</div>
</div>
<!-- Bloco SALDO (pacote saldo + faltou/cancelado) -->
<div v-if="showSaldoBlock" class="asccd-block">
<div class="asccd-block__title">
@@ -264,6 +406,9 @@ function onCancel() {
class="asccd-fine-input"
/>
</div>
<small v-if="fineDefaultReason" class="asccd-hint">
<i class="pi pi-info-circle" /> {{ fineDefaultReason }}
</small>
<small v-if="isPacoteUpfront" class="asccd-hint">
Pacote pago; multa entra como cobrança adicional avulsa.
</small>
@@ -304,6 +449,23 @@ function onCancel() {
</div>
</div>
<!-- Bloco "JÁ PAGA via antecipação" (C12 fluxo de retorno) -->
<div v-if="showAlreadyPaid" class="asccd-block">
<div class="asccd-block__title">
<i class="pi pi-check-circle" />
Sessão paga via antecipação
</div>
<div class="asccd-info">
<i class="pi pi-info-circle" />
<div>
Cobrança de <b>R$ {{ _fmtBRL(existingPaidRecord.final_amount || existingPaidRecord.amount) }}</b>
foi registrada como paga ({{ existingPaidRecord.payment_method || 'método não definido' }}).
Marcar como Realizada vai <b>consumir 1 sessão do saldo</b>
({{ billingContract?.sessions_used ?? 0 }} {{ (billingContract?.sessions_used ?? 0) + 1 }}/{{ billingContract?.total_sessions ?? '?' }}).
</div>
</div>
</div>
<!-- Bloco COBRANÇA NO PACOTE (realizado + pacote saldo) -->
<div v-if="showCobrancaPacote" class="asccd-block">
<div class="asccd-block__title">
@@ -315,11 +477,35 @@ function onCancel() {
<Checkbox v-model="generatePackageCharge" inputId="asccd-gen-charge" binary />
<label for="asccd-gen-charge" class="cursor-pointer">Gerar cobrança e consumir 1 sessão</label>
</div>
<div v-if="generatePackageCharge" class="asccd-method-row">
<label class="asccd-method-label">Como cobrar?</label>
<!-- Sub-question 1: a sessão foi paga? (espelha o padrão da avulsa) -->
<div v-if="generatePackageCharge" class="asccd-radio-group mt-2">
<label class="asccd-radio">
<input type="radio" :value="false" v-model="markPaid" />
<span>Não, gerar como cobrança pendente</span>
</label>
<label class="asccd-radio">
<input type="radio" :value="true" v-model="markPaid" />
<span>Sim, recebi</span>
</label>
</div>
<!-- Sub-question 2a: se "Já recebi" método de recebimento (sem prefixo) -->
<div v-if="generatePackageCharge && markPaid" class="asccd-method-row">
<label class="asccd-method-label">Como recebeu?</label>
<Select
v-model="paymentMethod"
:options="paymentMethodOptionsCobranca"
:options="paymentMethodOptions"
optionLabel="label"
optionValue="value"
size="small"
class="asccd-method-select"
/>
</div>
<!-- Sub-question 2b: se "Pendente" forma de cobrança (link Asaas vs registrar simples) -->
<div v-if="generatePackageCharge && !markPaid" class="asccd-method-row">
<label class="asccd-method-label">Como vai cobrar?</label>
<Select
v-model="paymentMethod"
:options="paymentMethodOptionsPending"
optionLabel="label"
optionValue="value"
size="small"
@@ -471,4 +657,15 @@ function onCancel() {
color: var(--text-color-secondary);
font-style: italic;
}
.asccd-list {
margin-top: 0.3rem;
padding-left: 1.2rem;
font-size: 0.78rem;
color: var(--text-color);
}
.asccd-list li {
list-style: disc;
padding: 0.1rem 0;
}
</style>
@@ -18,18 +18,19 @@
<script setup>
import { ref, watch } from 'vue';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { useInsurancePlans } from '@/features/insurance/composables/useInsurancePlans';
const props = defineProps({
modelValue: { type: Boolean, default: false },
// ownerId mantido por compat repository sempre injeta owner_id = auth.uid() logado.
// Nos fluxos atuais (AgendaEventDialog), o usuário logado já é o owner.
ownerId: { type: String, default: '' },
initialName: { type: String, default: '' }
});
const emit = defineEmits(['update:modelValue', 'created']);
const toast = useToast();
const tenantStore = useTenantStore();
const insuranceStore = useInsurancePlans();
const visible = ref(props.modelValue);
watch(() => props.modelValue, (v) => { visible.value = v; });
@@ -56,29 +57,25 @@ const canSave = () => !!form.value.name?.trim();
async function onSave() {
if (!canSave()) return;
const ownerId = props.ownerId || (await supabase.auth.getUser()).data?.user?.id;
const tid = tenantStore.activeTenantId || tenantStore.tenantId;
if (!ownerId || !tid) {
toast.add({ severity: 'error', summary: 'Sem contexto', detail: 'Owner ou tenant ausentes.', life: 3500 });
return;
}
saving.value = true;
try {
const payload = {
owner_id: ownerId,
tenant_id: tid,
name: form.value.name.trim().slice(0, 120),
default_value: form.value.default_value != null ? Number(form.value.default_value) : null,
notes: form.value.notes?.trim().slice(0, 500) || null,
active: true
};
const { data, error } = await supabase.from('insurance_plans').insert(payload).select().single();
if (error) throw error;
// Repository injeta owner_id + tenant_id, sanitiza, e faz uniqueness check.
const data = await insuranceStore.create({
name: form.value.name,
default_value: form.value.default_value,
notes: form.value.notes
});
toast.add({ severity: 'success', summary: 'Convênio criado', life: 2200 });
emit('created', data);
visible.value = false;
} catch (e) {
toast.add({ severity: 'error', summary: 'Falha ao criar convênio', detail: e?.message || 'Erro inesperado', life: 4000 });
const isDup = /existe um convênio/i.test(e?.message || '');
toast.add({
severity: isDup ? 'warn' : 'error',
summary: isDup ? 'Nome em uso' : 'Falha ao criar convênio',
detail: e?.message || 'Erro inesperado',
life: 4000
});
} finally {
saving.value = false;
}
@@ -0,0 +1,22 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/conversations/services/_tenantGuards.js
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
export function assertTenantId(tenantId) {
if (!tenantId || tenantId === 'null' || tenantId === 'undefined') {
throw new Error('Tenant ativo inválido. Selecione a clínica antes de acessar conversas.');
}
}
export async function getUid() {
const { data, error } = await supabase.auth.getUser();
if (error) throw error;
const uid = data?.user?.id;
if (!uid) throw new Error('Usuário não autenticado.');
return uid;
}
@@ -0,0 +1,115 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/conversations/services/conversationsRepository.js
|
| Repository de conversas (threads + messages). Foundation pra Módulo 6.
|
| src/composables/useConversations.js (existente) AINDA tem supabase direto.
| Migração pra usar este repository fica pra sessão dedicada (composable é fat
| e mistura listing + threading + realtime audit baseline pediu split em
| useConversationsList + useConversationThreadDetail).
|
| Channel send (WhatsApp Evolution/Twilio, SMS Twilio) NÃO está aqui é
| operação cross-canal que precisa de factory dedicada (ver audit alta:
| conversationDrawerStore lógica de envio sem abstração).
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { assertTenantId } from './_tenantGuards';
import {
CONVERSATION_THREAD_SELECT,
CONVERSATION_MESSAGE_SELECT,
CONVERSATION_MESSAGE_SELECT_BRIEF
} from './conversationsSelects';
function resolveTenantId(tenantIdArg) {
const tenantStore = useTenantStore();
const tenantId = tenantIdArg || tenantStore.activeTenantId || tenantStore.tenantId;
assertTenantId(tenantId);
return tenantId;
}
// ─── Threads ─────────────────────────────────────────────────────────────
/**
* Lista threads do tenant. Limit 500 (default UI usage), ordem desc por
* last_message_at.
*/
export async function listThreads({ tenantId, limit = 500 } = {}) {
const tid = resolveTenantId(tenantId);
const { data, error } = await supabase.from('conversation_threads').select(CONVERSATION_THREAD_SELECT).eq('tenant_id', tid).order('last_message_at', { ascending: false }).limit(limit);
if (error) throw error;
return data || [];
}
/**
* thread por id.
*/
export async function getThreadById(threadId, { tenantId } = {}) {
if (!threadId) throw new Error('threadId obrigatório.');
const tid = resolveTenantId(tenantId);
const { data, error } = await supabase.from('conversation_threads').select(CONVERSATION_THREAD_SELECT).eq('id', threadId).eq('tenant_id', tid).maybeSingle();
if (error) throw error;
return data || null;
}
/**
* Atualiza thread (assigned_to, kanban_status, etc).
*/
export async function updateThread(threadId, patch, { tenantId } = {}) {
if (!threadId) throw new Error('threadId obrigatório.');
const tid = resolveTenantId(tenantId);
const { data, error } = await supabase.from('conversation_threads').update({ ...patch, updated_at: new Date().toISOString() }).eq('id', threadId).eq('tenant_id', tid).select(CONVERSATION_THREAD_SELECT).single();
if (error) throw error;
return data;
}
// ─── Messages ────────────────────────────────────────────────────────────
/**
* Lista mensagens de uma thread. Limit 500 desc por created_at.
*/
export async function listMessagesByThread(threadId, { tenantId, limit = 500 } = {}) {
if (!threadId) return [];
const tid = resolveTenantId(tenantId);
const { data, error } = await supabase.from('conversation_messages').select(CONVERSATION_MESSAGE_SELECT).eq('tenant_id', tid).eq('thread_id', threadId).order('created_at', { ascending: true }).limit(limit);
if (error) throw error;
return data || [];
}
/**
* Lista mensagens por paciente (brief sem attachments pesados).
* Usado em prontuário (PatientConversationsTab).
*/
export async function listMessagesByPatient(patientId, { tenantId, limit = 200 } = {}) {
if (!patientId) return [];
const tid = resolveTenantId(tenantId);
const { data, error } = await supabase.from('conversation_messages').select(CONVERSATION_MESSAGE_SELECT_BRIEF).eq('tenant_id', tid).eq('patient_id', patientId).order('created_at', { ascending: false }).limit(limit);
if (error) throw error;
return data || [];
}
/**
* Atualiza kanban_status de uma mensagem (in-app workflow).
*/
export async function updateMessageKanban(messageId, kanbanStatus, { tenantId } = {}) {
if (!messageId) throw new Error('messageId obrigatório.');
const tid = resolveTenantId(tenantId);
const { error } = await supabase.from('conversation_messages').update({ kanban_status: kanbanStatus, updated_at: new Date().toISOString() }).eq('id', messageId).eq('tenant_id', tid);
if (error) throw error;
}
@@ -0,0 +1,35 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/conversations/services/conversationsSelects.js
|
| SELECTs canônicos de conversation_threads e conversation_messages.
| Threads usa `*` porque a UI usa muitos campos derivados (last_message_at,
| unread_count, assigned_to, contact_number, patient_name, etc).
|--------------------------------------------------------------------------
*/
/** Thread — UI usa praticamente todos os campos. */
export const CONVERSATION_THREAD_SELECT = '*';
/** Mensagem — campos canônicos pra timeline. */
export const CONVERSATION_MESSAGE_SELECT = `
id, tenant_id, thread_id, patient_id, body, direction, channel,
created_at, sent_at, delivered_at, read_at,
kanban_status, status, contact_number, attachments
`
.replace(/\s+/g, ' ')
.trim();
/** Mensagem brief — listings em prontuário (sem campos pesados). */
export const CONVERSATION_MESSAGE_SELECT_BRIEF = `
id, body, direction, created_at, channel, kanban_status
`
.replace(/\s+/g, ' ')
.trim();
/**
* Canais identificados no sistema (memória audit baseline M6).
*/
export const CHANNEL_TYPES = Object.freeze(['whatsapp', 'sms', 'email', 'in_app']);
@@ -0,0 +1,122 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/financeiro/composables/useBillingContracts.js
|
| Thin wrapper sobre billingContractsRepository. Operações de pacote/contrato:
| listForPatient, fetchById, create, update, increment/decrement sessions_used.
|
| Gotcha: billing_contracts não tem updated_at repository strips.
|--------------------------------------------------------------------------
*/
import { ref } from 'vue';
import {
listForPatient,
getById,
create as repoCreate,
update as repoUpdate,
incrementSessionsUsed,
decrementSessionsUsed
} from '@/features/financeiro/services/billingContractsRepository';
export function useBillingContracts() {
const rows = ref([]);
const loading = ref(false);
const error = ref('');
async function loadForPatient(patientId, opts = {}) {
if (!patientId) {
rows.value = [];
return;
}
loading.value = true;
error.value = '';
try {
rows.value = await listForPatient(patientId, opts);
} catch (e) {
error.value = e?.message || 'Falha ao carregar contratos.';
rows.value = [];
} finally {
loading.value = false;
}
}
async function fetchById(contractId, opts = {}) {
try {
return await getById(contractId, opts);
} catch (e) {
error.value = e?.message || 'Falha ao carregar contrato.';
return null;
}
}
async function create(payload) {
loading.value = true;
error.value = '';
try {
const created = await repoCreate(payload);
rows.value = [created, ...rows.value];
return created;
} catch (e) {
error.value = e?.message || 'Falha ao criar contrato.';
throw e;
} finally {
loading.value = false;
}
}
async function update(contractId, patch, opts = {}) {
loading.value = true;
error.value = '';
try {
const updated = await repoUpdate(contractId, patch, opts);
const idx = rows.value.findIndex((r) => r.id === contractId);
if (idx >= 0) rows.value[idx] = updated;
return updated;
} catch (e) {
error.value = e?.message || 'Falha ao atualizar contrato.';
throw e;
} finally {
loading.value = false;
}
}
async function consume(contractId, opts = {}) {
error.value = '';
try {
const updated = await incrementSessionsUsed(contractId, opts);
const idx = rows.value.findIndex((r) => r.id === contractId);
if (idx >= 0) rows.value[idx] = updated;
return updated;
} catch (e) {
error.value = e?.message || 'Falha ao incrementar saldo.';
throw e;
}
}
async function returnSession(contractId, opts = {}) {
error.value = '';
try {
const updated = await decrementSessionsUsed(contractId, opts);
const idx = rows.value.findIndex((r) => r.id === contractId);
if (idx >= 0) rows.value[idx] = updated;
return updated;
} catch (e) {
error.value = e?.message || 'Falha ao devolver saldo.';
throw e;
}
}
return {
rows,
loading,
error,
loadForPatient,
fetchById,
create,
update,
consume,
returnSession
};
}
@@ -0,0 +1,145 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/financeiro/composables/useBillingOrchestrator.js
|
| ORCHESTRATOR central de billing single entry point pra qualquer mudança
| de cobrança vinculada a evento da agenda. Resolve overlap entre os 3
| caminhos atuais (useAgendaFinanceiro.gerarCobrancaManual, handleStatusChange,
| useMelissaAgenda._applyStatusDecisions).
|
| FOUNDATION em construção. Decisões #2/#3/#6 (memória project_agenda_billing_decisoes)
| ainda pendentes state machine completa fica pra sessão dedicada antes do
| rollout dos callers.
|
| Design completo: development/02-auditoria/DESIGN_BILLING_ORCHESTRATOR.md
|
| Plano de migração (Fases A-D do design doc):
| [x] Fase A repositories criados (financialRecords, financialExceptions, billingContracts)
| [x] Fase B composables thin wrappers criados (useFinancialRecords, useFinancialExceptions, useBillingContracts, este orchestrator)
| [ ] Fase C state machine completa + migração dos 3 callers (BLOQUEADA pelas decisões #2/#3/#6)
| [ ] Fase D cleanup (deletar src/composables/useFinancialRecords.js + useAgendaFinanceiro.js)
|--------------------------------------------------------------------------
*/
import { ref } from 'vue';
import { useFinancialRecords } from './useFinancialRecords';
import { useFinancialExceptions } from './useFinancialExceptions';
import { useBillingContracts } from './useBillingContracts';
export function useBillingOrchestrator() {
const loading = ref(false);
const error = ref('');
const financialRecords = useFinancialRecords();
const exceptions = useFinancialExceptions();
const contracts = useBillingContracts();
/**
* Resolve state atual do evento (snapshot pra decisões da transição).
* @returns {Promise<{records: Array, packageInfo: Object|null}>}
*/
async function resolveBillingState(eventId, { billing_contract_id } = {}) {
const records = await financialRecords.fetchByEvent(eventId);
const packageInfo = billing_contract_id ? await contracts.fetchById(billing_contract_id) : null;
return { records, packageInfo };
}
/**
* Gera cobrança manual pra evento sem cobrança ainda. Idempotente
* (RPC create_financial_record_for_session ignora cancelled).
*/
async function generateChargeForEvent(event, options = {}) {
if (event.billing_contract_id) {
return { ok: false, error: 'Sessão faz parte de um pacote. Cobrança gerenciada pelo contrato.' };
}
loading.value = true;
error.value = '';
try {
const amount = options.amount ?? event.price ?? 0;
const dueDate = options.due_date || (event.inicio_em ? new Date(event.inicio_em).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10));
const data = await financialRecords.createRecord({
patient_id: event.patient_id ?? event.paciente_id ?? null,
agenda_evento_id: event.id,
amount,
due_date: dueDate
});
return data;
} catch (e) {
error.value = e?.message || 'Falha ao gerar cobrança.';
return { ok: false, error: error.value };
} finally {
loading.value = false;
}
}
/**
* Lista records de um evento. Helper público pra UI.
*/
async function fetchRecordsForEvent(eventId) {
return financialRecords.fetchByEvent(eventId);
}
/**
* Cancela TODOS os records pending/overdue de um evento (soft).
* Use em reverse transitions confirmadas pelo user.
*/
async function cancelRecordsForEvent(eventId, reason) {
const records = await financialRecords.fetchByEvent(eventId);
const cancelable = records.filter((r) => ['pending', 'overdue'].includes(r.status));
const results = [];
for (const r of cancelable) {
const res = await financialRecords.cancelRecord(r.id, { reason });
results.push({ id: r.id, ...res });
}
return results;
}
/**
* 🚧 STATE MACHINE implementação completa BLOQUEADA pelas decisões #2/#3/#6.
*
* Estrutura prevista (do DESIGN_BILLING_ORCHESTRATOR.md):
*
* applyStatusChange({ event, fromStatus, toStatus, decisions? })
* { ok, actions: [...], needsConfirmation?, error? }
*
* Transições:
* agendadorealizado | agendadofaltou | agendadocancelado
* realizadoagendado (REVERSE) | realizadofaltou (CROSS)
* faltouagendado (REVERSE) | canceladoagendado (REVERSE)
*
* Quando completar:
* 1. Migrar callers (AgendaEventDialog, useMelissaAgenda._applyStatusDecisions,
* callers de gerarCobrancaManual)
* 2. Deletar src/composables/useFinancialRecords.js
* 3. Deletar src/composables/useAgendaFinanceiro.js
*/
async function applyStatusChange(_params) {
throw new Error(
'applyStatusChange ainda não implementado. ' +
'Decisões #2/#3/#6 de billing pendentes (memória project_agenda_billing_decisoes). ' +
'Ver DESIGN_BILLING_ORCHESTRATOR.md seção 7.4. ' +
'Caller deve continuar usando useAgendaFinanceiro.handleStatusChange até Fase C estar pronta.'
);
}
function invalidateRules() {
exceptions.invalidate();
}
return {
loading,
error,
// Sub-composables (acesso direto se precisar de algo específico)
financialRecords,
exceptions,
contracts,
// High-level operations
resolveBillingState,
generateChargeForEvent,
fetchRecordsForEvent,
cancelRecordsForEvent,
applyStatusChange,
invalidateRules
};
}
@@ -0,0 +1,90 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/financeiro/composables/useFinancialExceptions.js
|
| Cache de regras de exceção financeira POR INSTÂNCIA do composable.
| Substitui o _exceptionsCache módulo-level do useAgendaFinanceiro.js
| (que vazava entre instâncias audit baseline alta).
|
| Cache TTL: vive enquanto o composable existir. Chamar invalidate()
| ao trocar tenant.
|--------------------------------------------------------------------------
*/
import { ref } from 'vue';
import {
getRule,
listAll,
upsertRule,
calcChargeAmount
} from '@/features/financeiro/services/financialExceptionsRepository';
export function useFinancialExceptions() {
const rules = ref([]);
const loading = ref(false);
const error = ref('');
// Cache local — chave: `${tenantId}:${exceptionType}`
const _cache = new Map();
async function getRuleCached(exceptionType, { tenantId } = {}) {
const key = `${tenantId || 'active'}:${exceptionType}`;
if (_cache.has(key)) return _cache.get(key);
try {
const rule = await getRule(exceptionType, { tenantId });
_cache.set(key, rule);
return rule;
} catch (e) {
error.value = e?.message || 'Falha ao carregar regra de exceção.';
return null;
}
}
async function loadAll(opts = {}) {
loading.value = true;
error.value = '';
try {
rules.value = await listAll(opts);
} catch (e) {
error.value = e?.message || 'Falha ao carregar regras.';
rules.value = [];
} finally {
loading.value = false;
}
}
async function upsert(payload) {
loading.value = true;
error.value = '';
try {
const updated = await upsertRule(payload);
const idx = rules.value.findIndex((r) => r.id === updated.id);
if (idx >= 0) rules.value[idx] = updated;
else rules.value = [...rules.value, updated];
invalidate();
return updated;
} catch (e) {
error.value = e?.message || 'Falha ao salvar regra.';
throw e;
} finally {
loading.value = false;
}
}
function invalidate() {
_cache.clear();
}
return {
rules,
loading,
error,
getRuleCached,
loadAll,
upsert,
invalidate,
// re-export utilitário (puro, não state)
calcChargeAmount
};
}
@@ -0,0 +1,211 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/financeiro/composables/useFinancialRecords.js
|
| Thin wrapper sobre financialRecordsRepository (composable-blueprint Tipo A).
| Substitui src/composables/useFinancialRecords.js quando callers migrarem.
|
| Mantém as mesmas funções públicas + computeds (summary) drop-in
| replacement quando hora chegar.
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue';
import {
list as repoList,
getById,
listByEvent,
createForSession,
createManual,
markAsPaid as repoMarkAsPaid,
markAsUnpaid as repoMarkAsUnpaid,
cancel as repoCancel,
update as repoUpdate
} from '@/features/financeiro/services/financialRecordsRepository';
export function useFinancialRecords() {
const records = ref([]);
const total = ref(0);
const loading = ref(false);
const error = ref('');
// ── computed: resumo financeiro ──────────────────────────────────────
const summary = computed(() => {
const now = new Date();
const thisYear = now.getFullYear();
const thisMonth = now.getMonth();
const countByStatus = { pending: 0, paid: 0, overdue: 0, cancelled: 0 };
let totalPending = 0;
let totalOverdue = 0;
let totalPaidThisMonth = 0;
for (const r of records.value) {
countByStatus[r.status] = (countByStatus[r.status] ?? 0) + 1;
if (r.status === 'pending') totalPending += r.final_amount ?? r.amount ?? 0;
if (r.status === 'overdue') totalOverdue += r.final_amount ?? r.amount ?? 0;
if (r.status === 'paid' && r.paid_at) {
const d = new Date(r.paid_at);
if (d.getFullYear() === thisYear && d.getMonth() === thisMonth) {
totalPaidThisMonth += r.final_amount ?? r.amount ?? 0;
}
}
}
return { totalPending, totalOverdue, totalPaidThisMonth, countByStatus };
});
async function fetchRecords(filters = {}) {
loading.value = true;
error.value = '';
try {
const result = await repoList(filters);
records.value = result.rows;
total.value = result.total;
return result;
} catch (e) {
error.value = e?.message || 'Erro ao carregar registros financeiros.';
records.value = [];
total.value = 0;
return { rows: [], total: 0 };
} finally {
loading.value = false;
}
}
async function fetchByEvent(eventId) {
try {
return await listByEvent(eventId);
} catch (e) {
error.value = e?.message || 'Erro ao buscar records do evento.';
return [];
}
}
async function fetchById(recordId) {
try {
return await getById(recordId);
} catch (e) {
error.value = e?.message || 'Erro ao buscar record.';
return null;
}
}
async function createRecord(payload) {
loading.value = true;
error.value = '';
try {
const data = await createForSession(payload);
await fetchRecords();
return { ok: true, data };
} catch (e) {
error.value = e?.message || 'Erro ao criar cobrança.';
return { ok: false, error: e?.message };
} finally {
loading.value = false;
}
}
async function createManualRecord(payload) {
loading.value = true;
error.value = '';
try {
const data = await createManual(payload);
records.value = [data, ...records.value];
return { ok: true, data };
} catch (e) {
error.value = e?.message || 'Erro ao criar lançamento manual.';
return { ok: false, error: e?.message };
} finally {
loading.value = false;
}
}
async function markAsPaid(recordId, paymentMethod) {
error.value = '';
try {
await repoMarkAsPaid(recordId, paymentMethod);
const idx = records.value.findIndex((r) => r.id === recordId);
if (idx !== -1) {
records.value[idx] = {
...records.value[idx],
status: 'paid',
paid_at: new Date().toISOString(),
payment_method: paymentMethod
};
}
return { ok: true };
} catch (e) {
error.value = e?.message || 'Erro ao marcar como pago.';
return { ok: false, error: e?.message };
}
}
async function markAsUnpaid(recordId) {
error.value = '';
try {
await repoMarkAsUnpaid(recordId);
const idx = records.value.findIndex((r) => r.id === recordId);
if (idx !== -1) {
records.value[idx] = {
...records.value[idx],
status: 'pending',
paid_at: null,
payment_method: null
};
}
return { ok: true };
} catch (e) {
error.value = e?.message || 'Erro ao reverter pagamento.';
return { ok: false, error: e?.message };
}
}
async function cancelRecord(recordId, opts = {}) {
error.value = '';
try {
await repoCancel(recordId, opts);
const idx = records.value.findIndex((r) => r.id === recordId);
if (idx !== -1) {
records.value[idx] = { ...records.value[idx], status: 'cancelled' };
}
return { ok: true };
} catch (e) {
error.value = e?.message || 'Erro ao cancelar registro.';
return { ok: false, error: e?.message };
}
}
async function updateRecord(recordId, patch) {
loading.value = true;
error.value = '';
try {
const updated = await repoUpdate(recordId, patch);
const idx = records.value.findIndex((r) => r.id === recordId);
if (idx >= 0) records.value[idx] = updated;
return { ok: true, data: updated };
} catch (e) {
error.value = e?.message || 'Erro ao atualizar registro.';
return { ok: false, error: e?.message };
} finally {
loading.value = false;
}
}
return {
records,
total,
loading,
error,
summary,
fetchRecords,
fetchByEvent,
fetchById,
createRecord,
createManualRecord,
markAsPaid,
markAsUnpaid,
cancelRecord,
updateRecord
};
}
@@ -0,0 +1,24 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/financeiro/services/_tenantGuards.js
|
| Guards compartilhados pelos repositories do feature financeiro.
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
export function assertTenantId(tenantId) {
if (!tenantId || tenantId === 'null' || tenantId === 'undefined') {
throw new Error('Tenant ativo inválido. Selecione a clínica antes de operar no financeiro.');
}
}
export async function getUid() {
const { data, error } = await supabase.auth.getUser();
if (error) throw error;
const uid = data?.user?.id;
if (!uid) throw new Error('Usuário não autenticado.');
return uid;
}
@@ -0,0 +1,182 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/financeiro/services/asaasGatewayService.js
|
| Cliente JS pra orquestrar cobranças Asaas via Edge Functions.
| Browser NUNCA fala direto com Asaas API key não pode chegar aqui.
|
| Arquitetura: ver development/02-auditoria/DESIGN_ASAAS_GATEWAY.md
|
| FOUNDATION SKELETON. Edge Functions ainda são stubs chamadas vão
| retornar erro até deploy real. Requer credenciais Asaas configuradas.
|
| Pré-requisitos (do user):
| 1. Migration 20260521000001_asaas_gateway_tables.sql aplicada
| 2. Migration 20260521000002_asaas_gateway_rls.sql aplicada
| 3. Edge Functions deployadas (asaas-create-payment-record, asaas-cancel-payment)
| 4. API key Asaas inserida em payment_settings (via UI futura ou SQL manual)
| 5. payment_settings.asaas_enabled = true
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
// ─── Status mapping Asaas → financial_records.status ────────────────────────
const ASAAS_TO_STATUS = {
PENDING: 'pending',
RECEIVED: 'paid',
CONFIRMED: 'paid',
RECEIVED_IN_CASH: 'paid',
OVERDUE: 'overdue',
REFUNDED: 'refunded',
CHARGEBACK_REQUESTED: 'refunded',
CHARGEBACK_DISPUTE: 'cancelled',
DELETED: 'cancelled'
};
export function mapAsaasStatus(asaasStatus) {
return ASAAS_TO_STATUS[asaasStatus] || 'pending';
}
// ─── Validations ────────────────────────────────────────────────────────────
function assertTenantId(tenantId) {
if (!tenantId || tenantId === 'null' || tenantId === 'undefined') {
throw new Error('Tenant ativo inválido pra operar Asaas.');
}
}
function resolveTenantId() {
const tenantStore = useTenantStore();
const tid = tenantStore.activeTenantId || tenantStore.tenantId;
assertTenantId(tid);
return tid;
}
// ─── Core API ───────────────────────────────────────────────────────────────
/**
* Cria cobrança Asaas pra um financial_record existente.
*
* Invoca Edge Function `asaas-create-payment-record` que:
* 1. financial_record + patient
* 2. Garante asaas_customer existe (cascade pra create-customer-patient)
* 3. POST /payments no Asaas com externalReference=financial_record.id
* 4. INSERT asaas_payments + UPDATE financial_records.payment_link
*
* @param {string} financialRecordId
* @param {Object} opts
* @param {'PIX'|'BOLETO'|'CREDIT_CARD'} [opts.method='PIX']
* @param {string} [opts.dueDate] - YYYY-MM-DD. Default = financial_record.due_date
* @returns {Promise<{asaas_payment_id, payment_url, pix_qr_code?, pix_copy_paste?, bank_slip_url?}>}
*/
export async function createPaymentForRecord(financialRecordId, opts = {}) {
if (!financialRecordId) throw new Error('financialRecordId obrigatório.');
const tenantId = resolveTenantId();
const method = opts.method || 'PIX';
if (!['PIX', 'BOLETO', 'CREDIT_CARD'].includes(method)) {
throw new Error(`Método inválido: ${method}. Aceitos: PIX, BOLETO, CREDIT_CARD.`);
}
const { data, error } = await supabase.functions.invoke('asaas-create-payment-record', {
body: {
tenant_id: tenantId,
financial_record_id: financialRecordId,
billing_type: method,
due_date: opts.dueDate || null
}
});
if (error) throw new Error(formatEdgeError(error));
if (!data?.ok) throw new Error(data?.error || 'Falha ao criar cobrança Asaas.');
return data.payment;
}
/**
* Cancela cobrança Asaas. Não afeta o financial_record diretamente webhook
* processará PAYMENT_DELETED e fará o sync.
*
* @param {string} asaasPaymentId
*/
export async function cancelPayment(asaasPaymentId) {
if (!asaasPaymentId) throw new Error('asaasPaymentId obrigatório.');
const tenantId = resolveTenantId();
const { data, error } = await supabase.functions.invoke('asaas-cancel-payment', {
body: { tenant_id: tenantId, asaas_payment_id: asaasPaymentId }
});
if (error) throw new Error(formatEdgeError(error));
if (!data?.ok) throw new Error(data?.error || 'Falha ao cancelar cobrança.');
return data;
}
/**
* Busca info de pagamento Asaas (PIX QR code, boleto URL, status atual).
* Read-only: vai na tabela asaas_payments. Não chama API Asaas.
*
* @param {string} financialRecordId
* @returns {Promise<Object|null>}
*/
export async function getPaymentForRecord(financialRecordId) {
if (!financialRecordId) return null;
const tenantId = resolveTenantId();
const { data, error } = await supabase
.from('asaas_payments')
.select('id, asaas_payment_id, billing_type, status, value, due_date, payment_date, invoice_url, payment_url, bank_slip_url, pix_qr_code, pix_copy_paste, cancelled_at')
.eq('tenant_id', tenantId)
.eq('financial_record_id', financialRecordId)
.is('cancelled_at', null)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
if (error) throw error;
return data || null;
}
/**
* Sincroniza força um pagamento Asaas (consulta API Asaas + atualiza row local).
* Use quando suspeitar que webhook falhou (record fica pending mas paciente diz que pagou).
*
* @param {string} asaasPaymentId
*/
export async function syncPayment(asaasPaymentId) {
if (!asaasPaymentId) throw new Error('asaasPaymentId obrigatório.');
const tenantId = resolveTenantId();
const { data, error } = await supabase.functions.invoke('asaas-sync-payment', {
body: { tenant_id: tenantId, asaas_payment_id: asaasPaymentId }
});
if (error) throw new Error(formatEdgeError(error));
if (!data?.ok) throw new Error(data?.error || 'Falha ao sincronizar.');
return data;
}
/**
* Verifica se gateway Asaas está habilitado pro tenant ativo.
* Usado pra mostrar/esconder botões de cobrança Asaas na UI.
*/
export async function isGatewayEnabled() {
const tenantId = resolveTenantId();
const { data, error } = await supabase.from('payment_settings').select('asaas_enabled, asaas_environment').eq('tenant_id', tenantId).maybeSingle();
if (error) return false;
return !!data?.asaas_enabled;
}
// ─── Helpers ────────────────────────────────────────────────────────────────
function formatEdgeError(err) {
if (typeof err === 'string') return err;
if (err?.message) return err.message;
if (err?.error) return String(err.error);
return 'Erro desconhecido na Edge Function.';
}
@@ -0,0 +1,137 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/financeiro/services/billingContractsRepository.js
|
| Repository de billing_contracts pacotes/contratos de cobrança (upfront,
| pagamento por sessão, etc).
|
| Gotcha conhecido (memória project_billing_contracts_no_updated_at):
| billing_contracts NÃO tem coluna updated_at. UPDATE com updated_at falha
| silently em Promise.allSettled. Repository NÃO inclui updated_at em patches.
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { assertTenantId, getUid } from './_tenantGuards';
import { BILLING_CONTRACT_SELECT } from './financialSelects';
function resolveTenantId(tenantIdArg) {
const tenantStore = useTenantStore();
const tenantId = tenantIdArg || tenantStore.activeTenantId || tenantStore.tenantId;
assertTenantId(tenantId);
return tenantId;
}
/**
* Lista contratos ativos do paciente.
*/
export async function listForPatient(patientId, { tenantId, includeDeleted = false } = {}) {
if (!patientId) return [];
const tid = resolveTenantId(tenantId);
let q = supabase.from('billing_contracts').select(BILLING_CONTRACT_SELECT).eq('tenant_id', tid).eq('patient_id', patientId).order('created_at', { ascending: false });
if (!includeDeleted) q = q.is('deleted_at', null);
const { data, error } = await q;
if (error) throw error;
return data || [];
}
/**
* contrato por id. Refresh FRESH do banco usado pelo orchestrator antes
* de UPDATE pra evitar race condition (memória project_agenda_reverse_transitions).
*/
export async function getById(contractId, { tenantId } = {}) {
if (!contractId) throw new Error('contractId obrigatório.');
const tid = resolveTenantId(tenantId);
const { data, error } = await supabase.from('billing_contracts').select(BILLING_CONTRACT_SELECT).eq('id', contractId).eq('tenant_id', tid).maybeSingle();
if (error) throw error;
return data || null;
}
/**
* Cria contrato.
*/
export async function create(payload) {
if (!payload?.patient_id) throw new Error('patient_id obrigatório.');
if (!payload?.charging_style) throw new Error('charging_style obrigatório.');
const uid = await getUid();
const tid = resolveTenantId(payload.tenantId);
const row = {
tenant_id: tid,
owner_id: uid,
patient_id: payload.patient_id,
charging_style: payload.charging_style,
sessions_total: payload.sessions_total ?? null,
sessions_used: 0,
total_amount: payload.total_amount != null ? Number(payload.total_amount) : null,
status: payload.status || 'active',
start_date: payload.start_date || null,
end_date: payload.end_date || null
};
const { data, error } = await supabase.from('billing_contracts').insert([row]).select(BILLING_CONTRACT_SELECT).single();
if (error) throw error;
return data;
}
/**
* Update NÃO INCLUI updated_at (tabela não tem essa coluna gotcha conhecido).
*/
export async function update(contractId, patch, { tenantId } = {}) {
if (!contractId) throw new Error('contractId obrigatório.');
const tid = resolveTenantId(tenantId);
// eslint-disable-next-line no-unused-vars
const { updated_at: _dropped, ...safePatch } = patch || {};
const { data, error } = await supabase.from('billing_contracts').update(safePatch).eq('id', contractId).eq('tenant_id', tid).select(BILLING_CONTRACT_SELECT).single();
if (error) throw error;
return data;
}
/**
* Incrementa sessions_used em 1 (consume).
* FRESH antes do UPDATE pra evitar race.
*/
export async function incrementSessionsUsed(contractId, { tenantId } = {}) {
const current = await getById(contractId, { tenantId });
if (!current) throw new Error('Contrato não encontrado.');
const newCount = (Number(current.sessions_used) || 0) + 1;
return update(contractId, { sessions_used: newCount }, { tenantId });
}
/**
* Decrementa sessions_used (reverse). FRESH antes.
*/
export async function decrementSessionsUsed(contractId, { tenantId } = {}) {
const current = await getById(contractId, { tenantId });
if (!current) throw new Error('Contrato não encontrado.');
const newCount = Math.max(0, (Number(current.sessions_used) || 0) - 1);
return update(contractId, { sessions_used: newCount }, { tenantId });
}
/**
* Busca records cross-week por recurrence_id (memória project_cross_week_propagation).
* Útil pra bulk-load de pacote upfront.
*/
export async function findRecordsByRecurrence(recurrenceId, { tenantId } = {}) {
if (!recurrenceId) return [];
const tid = resolveTenantId(tenantId);
const { data, error } = await supabase.from('financial_records').select('id, status, agenda_evento_id, billing_contract_id').eq('tenant_id', tid).is('deleted_at', null).not('agenda_evento_id', 'is', null);
// NOTE: filter por recurrence_id requer join — fica como TODO no orchestrator
// (memória project_cross_week_propagation: query records cross-week por recurrence_id).
if (error) throw error;
return data || [];
}
@@ -0,0 +1,108 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/financeiro/services/financialExceptionsRepository.js
|
| Regras de exceção financeira (no-show, cancelamento, etc).
| Extraído de src/composables/useAgendaFinanceiro.js.
|
| Prioridade: regra própria do owner > regra global do tenant (owner_id IS NULL).
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { assertTenantId, getUid } from './_tenantGuards';
import { FINANCIAL_EXCEPTION_SELECT } from './financialSelects';
const VALID_EXCEPTION_TYPES = ['patient_no_show', 'patient_cancellation', 'professional_cancellation'];
function resolveTenantId(tenantIdArg) {
const tenantStore = useTenantStore();
const tenantId = tenantIdArg || tenantStore.activeTenantId || tenantStore.tenantId;
assertTenantId(tenantId);
return tenantId;
}
/**
* a regra de exceção ativa pra um tipo + tenant.
* Prioriza owner próprio se existir; senão regra global do tenant.
*/
export async function getRule(exceptionType, { tenantId } = {}) {
if (!VALID_EXCEPTION_TYPES.includes(exceptionType)) {
throw new Error(`exception_type inválido. Aceitos: ${VALID_EXCEPTION_TYPES.join(', ')}.`);
}
const tid = resolveTenantId(tenantId);
const uid = await getUid();
const { data, error } = await supabase
.from('financial_exceptions')
.select(FINANCIAL_EXCEPTION_SELECT)
.eq('tenant_id', tid)
.eq('exception_type', exceptionType)
.or(`owner_id.eq.${uid},owner_id.is.null`)
.order('owner_id', { ascending: false, nullsLast: true })
.limit(1)
.maybeSingle();
if (error) throw error;
return data || null;
}
/**
* Lista todas as regras do tenant (config page).
*/
export async function listAll({ tenantId } = {}) {
const tid = resolveTenantId(tenantId);
const { data, error } = await supabase.from('financial_exceptions').select(FINANCIAL_EXCEPTION_SELECT).eq('tenant_id', tid).order('exception_type', { ascending: true });
if (error) throw error;
return data || [];
}
/**
* Cria/atualiza regra (upsert).
*/
export async function upsertRule(payload) {
if (!payload?.exception_type) throw new Error('exception_type obrigatório.');
if (!VALID_EXCEPTION_TYPES.includes(payload.exception_type)) {
throw new Error(`exception_type inválido. Aceitos: ${VALID_EXCEPTION_TYPES.join(', ')}.`);
}
const uid = await getUid();
const tid = resolveTenantId(payload.tenantId);
const row = {
tenant_id: tid,
owner_id: payload.ownerScoped ? uid : null,
exception_type: payload.exception_type,
charge_mode: payload.charge_mode || 'none',
charge_value: payload.charge_value != null ? Number(payload.charge_value) : null,
charge_pct: payload.charge_pct != null ? Number(payload.charge_pct) : null,
min_hours_notice: payload.min_hours_notice != null ? Number(payload.min_hours_notice) : null,
default_consume_on_miss: !!payload.default_consume_on_miss,
updated_at: new Date().toISOString()
};
const { data, error } = await supabase.from('financial_exceptions').upsert(row, { onConflict: 'tenant_id,owner_id,exception_type' }).select(FINANCIAL_EXCEPTION_SELECT).single();
if (error) throw error;
return data;
}
/**
* Calcula valor a cobrar conforme charge_mode.
* - none: 0 (não cobra)
* - full: amount original
* - fixed_fee: charge_value fixo
* - percentage: amount * (charge_pct / 100)
*/
export function calcChargeAmount(originalAmount, rule) {
if (!rule || rule.charge_mode === 'none') return 0;
if (rule.charge_mode === 'full') return Number(originalAmount) || 0;
if (rule.charge_mode === 'fixed_fee') return Number(rule.charge_value ?? 0);
if (rule.charge_mode === 'percentage') {
const pct = Number(rule.charge_pct ?? 0);
return parseFloat(((Number(originalAmount) * pct) / 100).toFixed(2));
}
return Number(originalAmount) || 0;
}
@@ -0,0 +1,230 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/financeiro/services/financialRecordsRepository.js
|
| Repository de financial_records. Extraído de src/composables/useFinancialRecords.js.
| Pattern canônico ver blueprints/repository-blueprint.md.
|
| Cobre: list (com filtros), getById, createForSession (RPC), createManual,
| markAsPaid (RPC), markAsUnpaid, cancel, update.
|
| RPC `create_financial_record_for_session` (existe no banco) é o caminho
| ÚNICO de criação a partir de sessão idempotente, ignora cancelled
| (memória project_rpc_idempotency_cancelled).
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { assertTenantId, getUid } from './_tenantGuards';
import { FINANCIAL_RECORD_SELECT, flattenFinancialRecord } from './financialSelects';
function resolveTenantId(tenantIdArg) {
const tenantStore = useTenantStore();
const tenantId = tenantIdArg || tenantStore.activeTenantId || tenantStore.tenantId;
assertTenantId(tenantId);
return tenantId;
}
/**
* Lista lançamentos com filtros + paginação.
*
* @param {Object} [filters]
* @param {string} [filters.tenantId]
* @param {string} [filters.status] - 'pending'|'paid'|'overdue'|'partial'|'cancelled'|'refunded'
* @param {string} [filters.type] - 'receita'|'despesa'
* @param {string} [filters.patient_id]
* @param {string} [filters.due_date_from]
* @param {string} [filters.due_date_to]
* @param {number} [filters.limit=50]
* @param {number} [filters.offset=0]
* @returns {Promise<{rows: Array, total: number}>}
*/
export async function list(filters = {}) {
const tid = resolveTenantId(filters.tenantId);
const limit = filters.limit ?? 50;
const offset = filters.offset ?? 0;
let q = supabase
.from('financial_records')
.select(FINANCIAL_RECORD_SELECT, { count: 'exact' })
.eq('tenant_id', tid)
.is('deleted_at', null)
.order('due_date', { ascending: false })
.range(offset, offset + limit - 1);
if (filters.status) q = q.eq('status', filters.status);
if (filters.type) q = q.eq('type', filters.type);
if (filters.patient_id) q = q.eq('patient_id', filters.patient_id);
if (filters.due_date_from) q = q.gte('due_date', filters.due_date_from);
if (filters.due_date_to) q = q.lte('due_date', filters.due_date_to);
const { data, error, count } = await q;
if (error) throw error;
return {
rows: (data || []).map(flattenFinancialRecord),
total: count ?? 0
};
}
/**
* um record por id.
*/
export async function getById(recordId, { tenantId } = {}) {
if (!recordId) throw new Error('recordId obrigatório.');
const tid = resolveTenantId(tenantId);
const { data, error } = await supabase.from('financial_records').select(FINANCIAL_RECORD_SELECT).eq('id', recordId).eq('tenant_id', tid).maybeSingle();
if (error) throw error;
return data ? flattenFinancialRecord(data) : null;
}
/**
* Busca records ativos vinculados a um evento da agenda (status pending|overdue|paid).
* Usado pelo orchestrator pra checar idempotência antes de criar.
*/
export async function listByEvent(eventId, { tenantId } = {}) {
if (!eventId) return [];
const tid = resolveTenantId(tenantId);
const { data, error } = await supabase.from('financial_records').select(FINANCIAL_RECORD_SELECT).eq('tenant_id', tid).eq('agenda_evento_id', eventId).is('deleted_at', null);
if (error) throw error;
return (data || []).map(flattenFinancialRecord);
}
/**
* Cria cobrança a partir de sessão via RPC idempotente.
* RPC `create_financial_record_for_session` ignora cancelled/refunded pode
* chamar 2× sem regerar (memória project_rpc_idempotency_cancelled).
*/
export async function createForSession(payload) {
if (!payload) throw new Error('Payload vazio.');
if (!payload.patient_id) throw new Error('patient_id obrigatório.');
if (!payload.agenda_evento_id) throw new Error('agenda_evento_id obrigatório.');
if (payload.amount == null) throw new Error('amount obrigatório.');
if (!payload.due_date) throw new Error('due_date obrigatório.');
const uid = await getUid();
const tid = resolveTenantId(payload.tenantId);
const { data, error } = await supabase.rpc('create_financial_record_for_session', {
p_tenant_id: tid,
p_owner_id: uid,
p_patient_id: payload.patient_id,
p_agenda_evento_id: payload.agenda_evento_id,
p_amount: Number(payload.amount),
p_due_date: payload.due_date
});
if (error) throw error;
return data;
}
/**
* Cria lançamento manual avulso (sem sessão). INSERT direto, não via RPC.
*/
export async function createManual(payload) {
if (!payload) throw new Error('Payload vazio.');
if (payload.amount == null || Number.isNaN(Number(payload.amount))) {
throw new Error('Valor inválido.');
}
if (!payload.due_date) throw new Error('due_date obrigatório.');
const uid = await getUid();
const tid = resolveTenantId(payload.tenantId);
const discount = Number(payload.discount_amount ?? 0);
const amount = Number(payload.amount);
const row = {
tenant_id: tid,
owner_id: uid,
patient_id: payload.patient_id ?? null,
agenda_evento_id: null,
type: payload.type || 'receita',
amount,
discount_amount: discount,
final_amount: amount - discount,
status: payload.status || 'pending',
due_date: payload.due_date,
payment_method: payload.payment_method || null,
description: payload.description ? String(payload.description).trim() || null : null,
notes: payload.notes ? String(payload.notes).trim() || null : null
};
const { data, error } = await supabase.from('financial_records').insert([row]).select(FINANCIAL_RECORD_SELECT).single();
if (error) throw error;
return flattenFinancialRecord(data);
}
/**
* Marca record como pago via RPC (server-side timestamps + audit).
*/
export async function markAsPaid(recordId, paymentMethod) {
if (!recordId) throw new Error('recordId obrigatório.');
const { data, error } = await supabase.rpc('mark_as_paid', {
p_financial_record_id: recordId,
p_payment_method: paymentMethod
});
if (error) throw error;
return data;
}
/**
* Reverte status pago pending (UPDATE direto). Mantém payment_method/paid_at
* limpos pra reconciliação manual.
*/
export async function markAsUnpaid(recordId, { tenantId } = {}) {
if (!recordId) throw new Error('recordId obrigatório.');
const tid = resolveTenantId(tenantId);
const { error } = await supabase
.from('financial_records')
.update({
status: 'pending',
paid_at: null,
payment_method: null,
updated_at: new Date().toISOString()
})
.eq('id', recordId)
.eq('tenant_id', tid);
if (error) throw error;
}
/**
* Cancela record (soft status='cancelled'). Defesa em profundidade: .eq('tenant_id').
*/
export async function cancel(recordId, { tenantId, reason } = {}) {
if (!recordId) throw new Error('recordId obrigatório.');
const tid = resolveTenantId(tenantId);
const patch = { status: 'cancelled', updated_at: new Date().toISOString() };
if (reason) patch.notes = String(reason).trim() || null;
const { error } = await supabase.from('financial_records').update(patch).eq('id', recordId).eq('tenant_id', tid);
if (error) throw error;
}
/**
* Atualiza campos arbitrários (use com cautela não atualiza status/paid_at via aqui).
*/
export async function update(recordId, patch, { tenantId } = {}) {
if (!recordId) throw new Error('recordId obrigatório.');
if (!patch) throw new Error('Patch vazio.');
const tid = resolveTenantId(tenantId);
const safePatch = { ...patch, updated_at: new Date().toISOString() };
const { data, error } = await supabase.from('financial_records').update(safePatch).eq('id', recordId).eq('tenant_id', tid).select(FINANCIAL_RECORD_SELECT).single();
if (error) throw error;
return flattenFinancialRecord(data);
}
@@ -0,0 +1,79 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/financeiro/services/financialSelects.js
|
| SELECTs canônicos do feature financeiro. Extraído de src/composables/
| useFinancialRecords.js (que tinha BASE_SELECT inline).
|--------------------------------------------------------------------------
*/
/**
* SELECT completo de financial_records com joins de patient + agenda_evento.
* FKs explícitas pra evitar ambiguidade.
*/
export const FINANCIAL_RECORD_SELECT = `
id, tenant_id, owner_id, patient_id, agenda_evento_id, billing_contract_id,
type, amount, discount_amount, final_amount,
status, due_date, paid_at, payment_method, payment_link,
description, notes, created_at, updated_at,
patients!patient_id (
id, nome_completo, identification_color
),
agenda_eventos!agenda_evento_id (
id, inicio_em, status, tipo
)
`
.replace(/\s+/g, ' ')
.trim();
/**
* SELECT mínimo list views simples, sem joins.
*/
export const FINANCIAL_RECORD_SELECT_BRIEF = `
id, type, amount, final_amount, status, due_date, paid_at,
description, payment_method, created_at, agenda_evento_id, billing_contract_id
`
.replace(/\s+/g, ' ')
.trim();
/**
* Flatten UI espera `patient_nome` flat às vezes.
*/
export function flattenFinancialRecord(r) {
if (!r) return r;
const patient = r.patients || null;
const evento = r.agenda_eventos || null;
return {
...r,
patient_nome: patient?.nome_completo || r.patient_nome || '',
patient_color: patient?.identification_color || r.patient_color || '',
evento_inicio_em: evento?.inicio_em || r.evento_inicio_em || null,
evento_status: evento?.status || r.evento_status || null,
evento_tipo: evento?.tipo || r.evento_tipo || null
};
}
/**
* SELECT financial_exceptions regras de cobrança em casos especiais
* (no-show, cancelamento, etc).
*/
export const FINANCIAL_EXCEPTION_SELECT = `
id, tenant_id, owner_id, exception_type, charge_mode, charge_value,
charge_pct, min_hours_notice, default_consume_on_miss,
created_at, updated_at
`
.replace(/\s+/g, ' ')
.trim();
/**
* SELECT billing_contracts.
*/
export const BILLING_CONTRACT_SELECT = `
id, tenant_id, owner_id, patient_id, charging_style,
sessions_total, sessions_used, total_amount, status,
start_date, end_date, deleted_at, created_at
`
.replace(/\s+/g, ' ')
.trim();
@@ -0,0 +1,97 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/insurance/composables/useInsurancePlans.js
|
| Thin wrapper sobre insurancePlansRepository.
| Pattern: composable-blueprint Tipo A (default).
|--------------------------------------------------------------------------
*/
import { ref } from 'vue';
import { listForOwner, getById, findByName, create as repoCreate, update as repoUpdate, softDelete as repoSoftDelete } from '@/features/insurance/services/insurancePlansRepository';
export function useInsurancePlans() {
const rows = ref([]);
const loading = ref(false);
const error = ref('');
async function loadForOwner(opts = {}) {
loading.value = true;
error.value = '';
try {
rows.value = await listForOwner(opts);
} catch (e) {
error.value = e?.message || 'Falha ao carregar convênios.';
rows.value = [];
} finally {
loading.value = false;
}
}
async function fetchById(id, opts = {}) {
loading.value = true;
error.value = '';
try {
return await getById(id, opts);
} catch (e) {
error.value = e?.message || 'Falha ao carregar convênio.';
return null;
} finally {
loading.value = false;
}
}
async function create(payload) {
loading.value = true;
error.value = '';
try {
const created = await repoCreate(payload);
if (created.active) {
rows.value = [...rows.value, created].sort((a, b) => (a.name || '').localeCompare(b.name || ''));
}
return created;
} catch (e) {
error.value = e?.message || 'Falha ao criar convênio.';
throw e;
} finally {
loading.value = false;
}
}
async function update(id, patch, opts = {}) {
loading.value = true;
error.value = '';
try {
const updated = await repoUpdate(id, patch, opts);
const idx = rows.value.findIndex((r) => r.id === id);
if (idx >= 0) {
if (updated.active) rows.value[idx] = { ...rows.value[idx], ...updated };
else rows.value.splice(idx, 1);
}
return updated;
} catch (e) {
error.value = e?.message || 'Falha ao atualizar convênio.';
throw e;
} finally {
loading.value = false;
}
}
async function softDelete(id, opts = {}) {
loading.value = true;
error.value = '';
try {
await repoSoftDelete(id, opts);
rows.value = rows.value.filter((r) => r.id !== id);
return true;
} catch (e) {
error.value = e?.message || 'Falha ao remover convênio.';
throw e;
} finally {
loading.value = false;
}
}
return { rows, loading, error, loadForOwner, fetchById, findByName, create, update, softDelete };
}
@@ -0,0 +1,25 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/insurance/services/_tenantGuards.js
|
| Guards compartilhados entre repositories do feature insurance.
| Pattern canônico ver blueprints/repository-blueprint.md seção 3.
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
export function assertTenantId(tenantId) {
if (!tenantId || tenantId === 'null' || tenantId === 'undefined') {
throw new Error('Tenant ativo inválido. Selecione a clínica/tenant antes de operar.');
}
}
export async function getUid() {
const { data, error } = await supabase.auth.getUser();
if (error) throw error;
const uid = data?.user?.id;
if (!uid) throw new Error('Usuário não autenticado.');
return uid;
}
@@ -0,0 +1,166 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/insurance/services/insurancePlansRepository.js
|
| Repository da tabela public.insurance_plans.
| Pure functions seguindo blueprints/repository-blueprint.md.
|
| Schema (servicos_prontuarios.sql):
| id, owner_id, tenant_id,
| name text, notes text, default_value numeric(10,2),
| active boolean DEFAULT true, created_at, updated_at
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { assertTenantId, getUid } from './_tenantGuards';
import { INSURANCE_PLAN_SELECT } from './insurancePlansSelects';
function resolveTenantId(tenantIdArg) {
const tenantStore = useTenantStore();
const tenantId = tenantIdArg || tenantStore.activeTenantId || tenantStore.tenantId;
assertTenantId(tenantId);
return tenantId;
}
/**
* Lista convênios ativos do owner. Ordenados por name ascending.
*
* @param {Object} [opts]
* @param {string} [opts.ownerId]
* @param {string} [opts.tenantId]
* @param {boolean} [opts.includeInactive=false]
*/
export async function listForOwner({ ownerId, tenantId, includeInactive = false } = {}) {
const tid = resolveTenantId(tenantId);
const uid = ownerId || (await getUid());
let q = supabase.from('insurance_plans').select(INSURANCE_PLAN_SELECT).eq('tenant_id', tid).eq('owner_id', uid).order('name', { ascending: true });
if (!includeInactive) q = q.eq('active', true);
const { data, error } = await q;
if (error) throw error;
return data || [];
}
/**
* convênio por id. Filtra owner_id + tenant_id por segurança.
*/
export async function getById(id, { tenantId } = {}) {
if (!id) throw new Error('ID inválido.');
const tid = resolveTenantId(tenantId);
const uid = await getUid();
const { data, error } = await supabase.from('insurance_plans').select(INSURANCE_PLAN_SELECT).eq('id', id).eq('tenant_id', tid).eq('owner_id', uid).maybeSingle();
if (error) throw error;
return data || null;
}
/**
* Procura convênio ativo por nome (case-insensitive). Usado pra duplicate check
* antes de criar (uniqueness check do quick-create blueprint).
*
* @param {Object} opts
* @param {string} opts.name
* @param {string} [opts.ownerId]
* @param {string} [opts.tenantId]
*/
export async function findByName({ name, ownerId, tenantId } = {}) {
if (!name) return null;
const tid = resolveTenantId(tenantId);
const uid = ownerId || (await getUid());
const safeName = String(name).trim();
if (!safeName) return null;
const { data, error } = await supabase.from('insurance_plans').select(INSURANCE_PLAN_SELECT).eq('tenant_id', tid).eq('owner_id', uid).eq('active', true).ilike('name', safeName).limit(1).maybeSingle();
if (error) throw error;
return data || null;
}
/**
* Cria convênio. Pré-checa duplicidade por nome (case-insensitive) se
* existe ativo, lança erro PT-BR. Repository injeta owner_id + tenant_id.
*/
export async function create(payload) {
if (!payload) throw new Error('Payload vazio.');
const name = String(payload.name || '').trim();
if (!name) throw new Error('Nome do convênio é obrigatório.');
if (name.length > 120) throw new Error('Nome do convênio muito longo (máx 120).');
const uid = await getUid();
const tid = resolveTenantId();
// Uniqueness check (quick-create blueprint)
const dup = await findByName({ name, ownerId: uid, tenantId: tid });
if (dup) {
throw new Error('Já existe um convênio com esse nome.');
}
const insertPayload = {
owner_id: uid,
tenant_id: tid,
name: name.slice(0, 120),
notes: payload.notes ? String(payload.notes).trim().slice(0, 500) || null : null,
default_value: payload.default_value != null && payload.default_value !== '' ? Number(payload.default_value) : null,
active: payload.active !== false
};
const { data, error } = await supabase.from('insurance_plans').insert([insertPayload]).select(INSURANCE_PLAN_SELECT).single();
if (error) throw error;
return data;
}
/**
* Atualiza convênio. Filtra por id + tenant_id.
*/
export async function update(id, patch, { tenantId } = {}) {
if (!id) throw new Error('ID inválido.');
if (!patch) throw new Error('Patch vazio.');
const tid = resolveTenantId(tenantId);
const safePatch = sanitize(patch);
safePatch.updated_at = new Date().toISOString();
const { data, error } = await supabase.from('insurance_plans').update(safePatch).eq('id', id).eq('tenant_id', tid).select(INSURANCE_PLAN_SELECT).single();
if (error) throw error;
return data;
}
/**
* Soft delete: marca active=false. Preserva histórico.
*/
export async function softDelete(id, { tenantId } = {}) {
if (!id) throw new Error('ID inválido.');
const tid = resolveTenantId(tenantId);
const { error } = await supabase.from('insurance_plans').update({ active: false, updated_at: new Date().toISOString() }).eq('id', id).eq('tenant_id', tid);
if (error) throw error;
return true;
}
// ─── helpers internos ────────────────────────────────────────────────────────
function sanitize(payload) {
const out = { ...payload };
if ('name' in out && typeof out.name === 'string') {
const t = out.name.trim();
out.name = t === '' ? null : t.slice(0, 120);
}
if ('notes' in out && typeof out.notes === 'string') {
const t = out.notes.trim();
out.notes = t === '' ? null : t.slice(0, 500);
}
if ('default_value' in out) {
const v = out.default_value;
out.default_value = v == null || v === '' ? null : Number(v);
}
return out;
}
@@ -0,0 +1,15 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/insurance/services/insurancePlansSelects.js
|
| SELECT canônico da tabela insurance_plans.
|--------------------------------------------------------------------------
*/
export const INSURANCE_PLAN_SELECT = `
id, owner_id, tenant_id,
name, notes, default_value, active,
created_at, updated_at
`.trim();
@@ -0,0 +1,101 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/medicos/composables/useMedicos.js
|
| Thin wrapper sobre medicosRepository.
| Pattern: composable-blueprint Tipo A (default).
|--------------------------------------------------------------------------
*/
import { ref } from 'vue';
import { listForOwner, getById, create as repoCreate, update as repoUpdate, softDelete as repoSoftDelete } from '@/features/medicos/services/medicosRepository';
export function useMedicos() {
const rows = ref([]);
const loading = ref(false);
const error = ref('');
async function loadForOwner(opts = {}) {
loading.value = true;
error.value = '';
try {
rows.value = await listForOwner(opts);
} catch (e) {
error.value = e?.message || 'Falha ao carregar médicos.';
rows.value = [];
} finally {
loading.value = false;
}
}
async function fetchById(id, opts = {}) {
loading.value = true;
error.value = '';
try {
return await getById(id, opts);
} catch (e) {
error.value = e?.message || 'Falha ao carregar médico.';
return null;
} finally {
loading.value = false;
}
}
async function create(payload) {
loading.value = true;
error.value = '';
try {
const created = await repoCreate(payload);
// Adiciona na lista local mantendo ordenação por nome
if (created.ativo) {
rows.value = [...rows.value, created].sort((a, b) => (a.nome || '').localeCompare(b.nome || ''));
}
return created;
} catch (e) {
error.value = e?.message || 'Falha ao criar médico.';
throw e;
} finally {
loading.value = false;
}
}
async function update(id, patch, opts = {}) {
loading.value = true;
error.value = '';
try {
const updated = await repoUpdate(id, patch, opts);
const idx = rows.value.findIndex((r) => r.id === id);
if (idx >= 0) {
if (updated.ativo) {
rows.value[idx] = { ...rows.value[idx], ...updated };
} else {
rows.value.splice(idx, 1);
}
}
return updated;
} catch (e) {
error.value = e?.message || 'Falha ao atualizar médico.';
throw e;
} finally {
loading.value = false;
}
}
async function softDelete(id, opts = {}) {
loading.value = true;
error.value = '';
try {
await repoSoftDelete(id, opts);
rows.value = rows.value.filter((r) => r.id !== id);
return true;
} catch (e) {
error.value = e?.message || 'Falha ao remover médico.';
throw e;
} finally {
loading.value = false;
}
}
return { rows, loading, error, loadForOwner, fetchById, create, update, softDelete };
}
@@ -0,0 +1,25 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/medicos/services/_tenantGuards.js
|
| Guards compartilhados entre repositories do feature medicos.
| Pattern canônico ver blueprints/repository-blueprint.md seção 3.
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
export function assertTenantId(tenantId) {
if (!tenantId || tenantId === 'null' || tenantId === 'undefined') {
throw new Error('Tenant ativo inválido. Selecione a clínica/tenant antes de operar.');
}
}
export async function getUid() {
const { data, error } = await supabase.auth.getUser();
if (error) throw error;
const uid = data?.user?.id;
if (!uid) throw new Error('Usuário não autenticado.');
return uid;
}
@@ -0,0 +1,160 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/medicos/services/medicosRepository.js
|
| Repository da tabela public.medicos. Pure functions seguindo
| blueprints/repository-blueprint.md.
|
| Schema (servicos_prontuarios.sql):
| id, owner_id, tenant_id, nome, crm, especialidade,
| telefone_profissional, telefone_pessoal, email, clinica,
| cidade, estado='SP', observacoes, ativo=true, created_at, updated_at
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { assertTenantId, getUid } from './_tenantGuards';
import { MEDICO_LIST_SELECT, MEDICO_FULL_SELECT } from './medicosSelects';
function resolveTenantId(tenantIdArg) {
const tenantStore = useTenantStore();
const tenantId = tenantIdArg || tenantStore.activeTenantId || tenantStore.tenantId;
assertTenantId(tenantId);
return tenantId;
}
/**
* Lista médicos ativos do owner (escopo terapeuta solo).
* Ordenados por nome ascending.
*
* @param {Object} [opts]
* @param {string} [opts.ownerId] - default: uid logado
* @param {string} [opts.tenantId]
* @param {boolean} [opts.includeInactive=false]
*/
export async function listForOwner({ ownerId, tenantId, includeInactive = false } = {}) {
const tid = resolveTenantId(tenantId);
const uid = ownerId || (await getUid());
let q = supabase.from('medicos').select(MEDICO_LIST_SELECT).eq('tenant_id', tid).eq('owner_id', uid).order('nome', { ascending: true });
if (!includeInactive) q = q.eq('ativo', true);
const { data, error } = await q;
if (error) throw error;
return data || [];
}
/**
* um médico completo (pra edit). Filtra owner_id + tenant_id por segurança.
*
* @param {string} id
* @param {Object} [opts]
* @param {string} [opts.tenantId]
*/
export async function getById(id, { tenantId } = {}) {
if (!id) throw new Error('ID inválido.');
const tid = resolveTenantId(tenantId);
const uid = await getUid();
const { data, error } = await supabase.from('medicos').select(MEDICO_FULL_SELECT).eq('id', id).eq('tenant_id', tid).eq('owner_id', uid).maybeSingle();
if (error) throw error;
return data || null;
}
/**
* Cria médico. Injeta owner_id (uid logado) + tenant_id (store).
* Payload aceita os campos canônicos da tabela; o repository sanitiza
* trims e nullif vazio.
*
* @param {Object} payload
*/
export async function create(payload) {
if (!payload) throw new Error('Payload vazio.');
if (!payload.nome || !String(payload.nome).trim()) {
throw new Error('Nome do médico é obrigatório.');
}
const uid = await getUid();
const tid = resolveTenantId();
const insertPayload = {
...sanitize(payload),
owner_id: uid,
tenant_id: tid,
ativo: payload.ativo !== false
};
const { data, error } = await supabase.from('medicos').insert([insertPayload]).select(MEDICO_FULL_SELECT).single();
if (error) throw error;
return data;
}
/**
* Atualiza médico. Filtra por id + tenant_id (defesa em profundidade RLS reforça).
* updated_at é atualizado server-side ou aqui se não houver trigger.
*
* @param {string} id
* @param {Object} patch
* @param {Object} [opts]
* @param {string} [opts.tenantId]
*/
export async function update(id, patch, { tenantId } = {}) {
if (!id) throw new Error('ID inválido.');
if (!patch) throw new Error('Patch vazio.');
const tid = resolveTenantId(tenantId);
const safePatch = {
...sanitize(patch),
updated_at: new Date().toISOString()
};
const { data, error } = await supabase.from('medicos').update(safePatch).eq('id', id).eq('tenant_id', tid).select(MEDICO_FULL_SELECT).single();
if (error) throw error;
return data;
}
/**
* Soft delete: marca ativo=false em vez de DELETE. Preserva histórico
* de encaminhamentos antigos referentes a este médico.
*
* @param {string} id
* @param {Object} [opts]
* @param {string} [opts.tenantId]
*/
export async function softDelete(id, { tenantId } = {}) {
if (!id) throw new Error('ID inválido.');
const tid = resolveTenantId(tenantId);
const { error } = await supabase.from('medicos').update({ ativo: false, updated_at: new Date().toISOString() }).eq('id', id).eq('tenant_id', tid);
if (error) throw error;
return true;
}
// ─── helpers internos ────────────────────────────────────────────────────────
/**
* Sanitiza payload: trim em strings, nullif vazio.
* Não sanitiza telefones ( chegam digits-only do componente)
* nem owner_id/tenant_id/ativo (controlados pelo repository).
*/
function sanitize(payload) {
const stringFields = ['nome', 'crm', 'especialidade', 'telefone_profissional', 'telefone_pessoal', 'email', 'clinica', 'cidade', 'estado', 'observacoes'];
const out = { ...payload };
for (const f of stringFields) {
if (f in out) {
const v = out[f];
if (typeof v === 'string') {
const trimmed = v.trim();
out[f] = trimmed === '' ? null : trimmed;
}
}
}
return out;
}
@@ -0,0 +1,30 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/medicos/services/medicosSelects.js
|
| Fonte única de SELECTs da tabela medicos.
|--------------------------------------------------------------------------
*/
/**
* SELECT pra listas (sem campos pesados/sensíveis: telefone_pessoal,
* observacoes, email carregados em getById/edit).
*/
export const MEDICO_LIST_SELECT = `
id, nome, crm, especialidade,
telefone_profissional, clinica, cidade, estado, ativo
`.trim();
/**
* SELECT completo pra edição (todos os campos).
*/
export const MEDICO_FULL_SELECT = `
id, owner_id, tenant_id,
nome, crm, especialidade,
telefone_profissional, telefone_pessoal,
email, clinica, cidade, estado,
observacoes, ativo,
created_at, updated_at
`.trim();
+4 -3
View File
@@ -17,6 +17,7 @@
// Serviço central de acesso ao Supabase para Global Notices
import { supabase } from '@/lib/supabase/client';
import { GLOBAL_NOTICE_SELECT, NOTICE_DISMISSAL_SELECT } from './noticesSelects';
// ── Leitura ────────────────────────────────────────────────────
@@ -28,7 +29,7 @@ import { supabase } from '@/lib/supabase/client';
export async function fetchActiveNotices() {
const now = new Date().toISOString();
const { data, error } = await supabase.from('global_notices').select('*').eq('is_active', true).or(`starts_at.is.null,starts_at.lte.${now}`).or(`ends_at.is.null,ends_at.gte.${now}`).order('priority', { ascending: false });
const { data, error } = await supabase.from('global_notices').select(GLOBAL_NOTICE_SELECT).eq('is_active', true).or(`starts_at.is.null,starts_at.lte.${now}`).or(`ends_at.is.null,ends_at.gte.${now}`).order('priority', { ascending: false });
if (error) throw error;
return data || [];
@@ -38,7 +39,7 @@ export async function fetchActiveNotices() {
* Busca todos os notices (sem filtro de ativo) para o painel admin.
*/
export async function fetchAllNotices() {
const { data, error } = await supabase.from('global_notices').select('*').order('priority', { ascending: false }).order('created_at', { ascending: false });
const { data, error } = await supabase.from('global_notices').select(GLOBAL_NOTICE_SELECT).order('priority', { ascending: false }).order('created_at', { ascending: false });
if (error) throw error;
return data || [];
@@ -77,7 +78,7 @@ export async function loadUserDismissals() {
} = await supabase.auth.getUser();
if (!user?.id) return [];
const { data } = await supabase.from('notice_dismissals').select('notice_id, version').eq('user_id', user.id);
const { data } = await supabase.from('notice_dismissals').select(NOTICE_DISMISSAL_SELECT).eq('user_id', user.id);
return data || [];
}
+17
View File
@@ -0,0 +1,17 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/notices/noticesSelects.js
|
| SELECTs canônicos de notices. Extraídos de noticeService.js (audit alta).
| global_notices tem muitos campos usados pela UI usa `*` por simplicidade.
| notice_dismissals tem 2 colunas relevantes.
|--------------------------------------------------------------------------
*/
/** SELECT completo de global_notices. */
export const GLOBAL_NOTICE_SELECT = '*';
/** SELECT mínimo de notice_dismissals (pra checar se user já dismissou). */
export const NOTICE_DISMISSAL_SELECT = 'notice_id, version';
@@ -17,6 +17,9 @@
<script setup>
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
// Fase 2 (Graphify hotspot): convertToPatient duplicado em 2 pages INSERT/UPDATE
// extraídos pro repository pra remover duplicação.
import { createPatient, markIntakeConverted } from '@/features/patients/services/patientsRepository';
import { logError } from '@/support/supportLogger';
import { useConfirm } from 'primevue/useconfirm';
import { useToast } from 'primevue/usetoast';
@@ -402,13 +405,13 @@ async function convertToPatient() {
if (patientPayload[k] === undefined) delete patientPayload[k];
});
const { data: created, error: insErr } = await supabase.from('patients').insert(patientPayload).select('id').single();
if (insErr) throw insErr;
// Repository chamadas (Fase 2 convertToPatient de-dup).
// patientsRepository.createPatient strip owner_id do payload + sempre injeta auth.uid().
const created = await createPatient(patientPayload);
const patientId = created?.id;
if (!patientId) throw new Error('Falha ao obter ID do paciente criado.');
const { error: upErr } = await supabase.from('patient_intake_requests').update({ status: 'converted', converted_patient_id: patientId, updated_at: new Date().toISOString() }).eq('id', item.id);
if (upErr) throw upErr;
await markIntakeConverted(item.id, patientId);
toast.add({ severity: 'success', summary: 'Convertido', detail: 'Cadastro convertido em paciente.', life: 2500 });
dlg.value.open = false;
@@ -4,62 +4,18 @@
|--------------------------------------------------------------------------
| Arquivo: src/features/patients/composables/usePatientDetail.js
|
| Composable de detalhe completo de paciente patient row + grupos + tags
| Extraido do PatientProntuario.vue pra permitir reuso em MelissaPaciente.vue.
| Mantem a mesma logica original (Promise.all em 2 etapas, RLS-aware).
| Detalhe completo de paciente patient row + grupos + tags. Extraido do
| PatientProntuario.vue pra permitir reuso em MelissaPaciente.vue.
| Mantem a mesma logica (Promise.all em 2 etapas) agora via repository.
|--------------------------------------------------------------------------
*/
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
async function getPatientById(id) {
const { data, error } = await supabase
.from('patients')
.select('*')
.eq('id', id)
.maybeSingle();
if (error) throw error;
return data;
}
async function getPatientRelations(id) {
const { data: g, error: ge } = await supabase
.from('patient_group_patient')
.select('patient_group_id')
.eq('patient_id', id);
if (ge) throw ge;
const { data: t, error: te } = await supabase
.from('patient_patient_tag')
.select('tag_id')
.eq('patient_id', id);
if (te) throw te;
return {
groupIds: (g || []).map((x) => x.patient_group_id).filter(Boolean),
tagIds: (t || []).map((x) => x.tag_id).filter(Boolean)
};
}
async function getGroupsByIds(ids) {
if (!ids?.length) return [];
const { data, error } = await supabase
.from('patient_groups')
.select('id, nome')
.in('id', ids)
.order('nome', { ascending: true });
if (error) throw error;
return (data || []).map((g) => ({ id: g.id, name: g.nome }));
}
async function getTagsByIds(ids) {
if (!ids?.length) return [];
const { data, error } = await supabase
.from('patient_tags')
.select('id, nome, cor')
.in('id', ids)
.order('nome', { ascending: true });
if (error) throw error;
return (data || []).map((t) => ({ id: t.id, name: t.nome, color: t.cor }));
}
import {
getPatientById,
getPatientRelations,
getGroupsByIds,
getTagsByIds
} from '@/features/patients/services/patientsRepository';
export function usePatientDetail() {
const patient = ref(null);
@@ -4,13 +4,14 @@
|--------------------------------------------------------------------------
| Arquivo: src/features/patients/composables/usePatientDocuments.js
|
| Documentos do paciente carrega so os campos pra KPIs (count, tipo,
| ultima atualizacao). O detalhe completo fica em DocumentsListPage que
| tem composable proprio. Filtra deletados (deleted_at IS NULL).
| Documentos do paciente campos pra KPIs (count, tipo, última atualização).
| O detalhe completo fica em DocumentsListPage. Filtra deletados.
|
| I/O delegada ao patientsRepository.
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { listDocumentsByPatient } from '@/features/patients/services/patientsRepository';
import { fmtSize, DOC_TYPE_LABEL } from '@/features/patients/utils/patientFormatters';
export function usePatientDocuments() {
@@ -27,15 +28,7 @@ export function usePatientDocuments() {
error.value = '';
documents.value = [];
try {
const { data, error: err } = await supabase
.from('documents')
.select('id, tipo_documento, created_at, status_revisao, tamanho_bytes')
.eq('patient_id', patientId)
.is('deleted_at', null)
.order('created_at', { ascending: false })
.limit(200);
if (err) throw err;
documents.value = data || [];
documents.value = await listDocumentsByPatient(patientId);
} catch (e) {
error.value = e?.message || 'Falha ao carregar documentos.';
documents.value = [];
@@ -45,9 +38,7 @@ export function usePatientDocuments() {
}
const total = computed(() => documents.value.length);
const totalBytes = computed(() =>
documents.value.reduce((acc, d) => acc + Number(d.tamanho_bytes || 0), 0)
);
const totalBytes = computed(() => documents.value.reduce((acc, d) => acc + Number(d.tamanho_bytes || 0), 0));
const tiposCount = computed(() => {
const map = new Map();
documents.value.forEach((d) => {
@@ -58,10 +49,6 @@ export function usePatientDocuments() {
});
const ultimo = computed(() => documents.value[0] || null);
/**
* Tipo de documento mais comum (alimenta KPI "Mais comum").
* Retorna { tipo, count, label } ou null se vazio.
*/
const topType = computed(() => {
const por = {};
for (const d of documents.value) {
@@ -74,16 +61,8 @@ export function usePatientDocuments() {
return { tipo, count, label: DOC_TYPE_LABEL[tipo] || tipo };
});
/**
* Count de documentos com status_revisao === 'pendente'.
*/
const pendentes = computed(() =>
documents.value.filter((d) => d.status_revisao === 'pendente').length
);
const pendentes = computed(() => documents.value.filter((d) => d.status_revisao === 'pendente').length);
/**
* Tamanho total formatado em string legivel (B/KB/MB/GB).
*/
const sizeTotalFormatted = computed(() => fmtSize(totalBytes.value));
return {
@@ -4,20 +4,26 @@
|--------------------------------------------------------------------------
| Arquivo: src/features/patients/composables/usePatientFinancial.js
|
| Lancamentos financeiros (financial_records) do paciente. Filtra type=receita,
| limita 100. Schema: paid_at NULL = pendente, preenchido = pago.
| Lançamentos financeiros (financial_records) do paciente. type=receita,
| limit 100. paid_at NULL = pendente, preenchido = pago.
| "Vencido" = paid_at IS NULL AND due_date < hoje.
| Computeds derivados: kpis (em aberto, atrasado, total, ultimo pago).
| Computeds: kpis (em aberto, atrasado, total, último pago, status financeiro).
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
import {
listFinancialRecordsByPatient,
createFinancialRecord,
markFinancialRecordPaid,
markFinancialRecordUnpaid
} from '@/features/patients/services/patientsRepository';
export function usePatientFinancial() {
const records = ref([]);
const loading = ref(false);
const error = ref('');
const busy = ref(false);
// Dentro da function — não vaza entre instâncias (audit alta resolvida)
let _lastPatientId = null;
async function load(patientId) {
@@ -30,15 +36,7 @@ export function usePatientFinancial() {
error.value = '';
records.value = [];
try {
const { data, error: err } = await supabase
.from('financial_records')
.select('id, type, amount, due_date, paid_at, description, payment_method, category, created_at')
.eq('patient_id', patientId)
.eq('type', 'receita')
.order('created_at', { ascending: false })
.limit(100);
if (err) throw err;
records.value = data || [];
records.value = await listFinancialRecordsByPatient(patientId);
} catch (e) {
error.value = e?.message || 'Falha ao carregar lançamentos.';
records.value = [];
@@ -73,11 +71,7 @@ export function usePatientFinancial() {
});
/**
* Status financeiro detalhado pra KPI da Visao Geral.
* - emDia: nenhum pendente vencido (paid_at NULL && due_date < hoje)
* - proxVenc: proximo pendente com due_date no futuro
* - totalPendente / totalPago: somatorio
* - vencidos: count de pendentes vencidos
* Status financeiro detalhado pra KPI da Visão Geral.
*/
const statusFinanceiro = computed(() => {
const recs = records.value;
@@ -90,9 +84,10 @@ export function usePatientFinancial() {
const vencidos = pendentes.filter(
(r) => r.due_date && new Date(r.due_date + 'T23:59:59').getTime() < now
);
const proxVenc = pendentes
.filter((r) => r.due_date && new Date(r.due_date + 'T00:00:00').getTime() >= now)
.sort((a, b) => new Date(a.due_date) - new Date(b.due_date))[0] || null;
const proxVenc =
pendentes
.filter((r) => r.due_date && new Date(r.due_date + 'T00:00:00').getTime() >= now)
.sort((a, b) => new Date(a.due_date) - new Date(b.due_date))[0] || null;
const totalPendente = pendentes.reduce((acc, r) => acc + (Number(r.amount) || 0), 0);
const totalPago = pagos.reduce((acc, r) => acc + (Number(r.amount) || 0), 0);
return {
@@ -104,10 +99,6 @@ 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;
@@ -116,19 +107,11 @@ export function usePatientFinancial() {
})
);
/**
* 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;
await markFinancialRecordPaid(recordId);
if (_lastPatientId) await load(_lastPatientId);
return { ok: true };
} catch (e) {
@@ -138,49 +121,14 @@ 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' };
return { ok: false, error: 'Valor inválido' };
}
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;
const data = await createFinancialRecord(patientId, payload);
if (_lastPatientId) await load(_lastPatientId);
return { ok: true, data };
} catch (e) {
@@ -190,18 +138,11 @@ export function usePatientFinancial() {
}
}
/**
* 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;
await markFinancialRecordUnpaid(recordId);
if (_lastPatientId) await load(_lastPatientId);
return { ok: true };
} catch (e) {
@@ -4,13 +4,15 @@
|--------------------------------------------------------------------------
| Arquivo: src/features/patients/composables/usePatientMessages.js
|
| Mensagens de conversa do paciente. Carrega 200 mais recentes (in+out)
| pra alimentar o card "Ultimas mensagens" (Visao Geral, top 4) e os
| KPIs da aba Conversas. Conversa completa fica no PatientConversationsTab.
| Mensagens de conversa do paciente. 200 mais recentes (in+out) pra alimentar
| o card "Últimas mensagens" (top 4) e KPIs da aba Conversas.
| Conversa completa fica no PatientConversationsTab.
|
| I/O delegada ao patientsRepository.
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { listMessagesByPatient } from '@/features/patients/services/patientsRepository';
export function usePatientMessages() {
const messages = ref([]);
@@ -26,14 +28,7 @@ export function usePatientMessages() {
error.value = '';
messages.value = [];
try {
const { data, error: err } = await supabase
.from('conversation_messages')
.select('id, body, direction, created_at, channel, kanban_status')
.eq('patient_id', patientId)
.order('created_at', { ascending: false })
.limit(200);
if (err) throw err;
messages.value = data || [];
messages.value = await listMessagesByPatient(patientId);
} catch (e) {
error.value = e?.message || 'Falha ao carregar mensagens.';
messages.value = [];
@@ -43,18 +38,11 @@ export function usePatientMessages() {
}
const recentes = computed(() => messages.value.slice(0, 4));
const totalIn = computed(() =>
messages.value.filter((m) => m.direction === 'in' || m.direction === 'inbound').length
);
const totalOut = computed(() =>
messages.value.filter((m) => m.direction === 'out' || m.direction === 'outbound').length
);
const totalIn = computed(() => messages.value.filter((m) => m.direction === 'in' || m.direction === 'inbound').length);
const totalOut = computed(() => messages.value.filter((m) => m.direction === 'out' || m.direction === 'outbound').length);
const ultimaMensagem = computed(() => messages.value[0] || null);
const primeiraMensagem = computed(() => messages.value[messages.value.length - 1] || null);
/**
* Canais unicos usados nas mensagens (whatsapp, sms, email).
*/
const canais = computed(() => {
const set = new Set();
for (const m of messages.value) if (m.channel) set.add(m.channel);
@@ -4,15 +4,18 @@
|--------------------------------------------------------------------------
| Arquivo: src/features/patients/composables/usePatientRecurrences.js
|
| Carrega regras de recorrencia (recurrence_rules) filtradas por paciente.
| Usado pela Tab Agenda do MelissaPaciente pra mostrar "este paciente tem
| sessao toda segunda 14h" e dar acoes inline (cancelar/reativar).
| Regras de recorrência (recurrence_rules) do paciente. Usado pela Tab Agenda
| do MelissaPaciente pra mostrar "este paciente tem sessão toda segunda 14h"
| e ações inline (cancelar/reativar).
|
| Mutations espelham o pattern de MelissaRecorrencias.vue.
| I/O delegada ao patientsRepository.
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
import {
listRecurrencesByPatient,
updateRecurrenceStatus
} from '@/features/patients/services/patientsRepository';
export function usePatientRecurrences() {
const rules = ref([]);
@@ -31,15 +34,9 @@ export function usePatientRecurrences() {
error.value = '';
rules.value = [];
try {
const { data, error: err } = await supabase
.from('recurrence_rules')
.select('*')
.eq('patient_id', patientId)
.order('start_date', { ascending: false });
if (err) throw err;
rules.value = data || [];
rules.value = await listRecurrencesByPatient(patientId);
} catch (e) {
error.value = e?.message || 'Falha ao carregar recorrencias.';
error.value = e?.message || 'Falha ao carregar recorrências.';
rules.value = [];
} finally {
loading.value = false;
@@ -50,11 +47,7 @@ export function usePatientRecurrences() {
if (!ruleId || busy.value) return { ok: false, error: 'busy' };
busy.value = true;
try {
const { error: err } = await supabase
.from('recurrence_rules')
.update({ status: 'cancelado', updated_at: new Date().toISOString() })
.eq('id', ruleId);
if (err) throw err;
await updateRecurrenceStatus(ruleId, 'cancelado');
if (_lastPatientId) await load(_lastPatientId);
return { ok: true };
} catch (e) {
@@ -68,11 +61,7 @@ export function usePatientRecurrences() {
if (!ruleId || busy.value) return { ok: false, error: 'busy' };
busy.value = true;
try {
const { error: err } = await supabase
.from('recurrence_rules')
.update({ status: 'ativo', updated_at: new Date().toISOString() })
.eq('id', ruleId);
if (err) throw err;
await updateRecurrenceStatus(ruleId, 'ativo');
if (_lastPatientId) await load(_lastPatientId);
return { ok: true };
} catch (e) {
@@ -4,23 +4,32 @@
|--------------------------------------------------------------------------
| Arquivo: src/features/patients/composables/usePatientSessions.js
|
| Carrega sessoes (agenda_eventos) do paciente. Limit 100 mais recentes
| ordenadas desc por inicio_em. Compativel com a logica original do
| PatientProntuario.vue.
| Carrega sessões (agenda_eventos) do paciente. Limit 100 reais + expansão
| de recorrências do owner dentro de uma janela de 18 meses. Merge desc por
| inicio_em pra alimentar prontuário/timeline.
|
| I/O delegada ao patientsRepository. Expansão de recorrência continua via
| useRecurrence (composable da agenda não é supabase direto, é orquestração).
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useRecurrence } from '@/features/agenda/composables/useRecurrence';
import {
listSessionsByPatient,
createPatientSession,
updatePatientSessionStatus,
findSessionByRecurrence
} from '@/features/patients/services/patientsRepository';
export function usePatientSessions() {
const sessions = ref([]);
const loading = ref(false);
const error = ref('');
const busy = ref(false); // mutations em curso (updateStatus etc)
const busy = ref(false);
// State per-instância — não vaza
let _lastPatientId = null;
// Instancia local — refs internos (rules/exceptions) ficam isolados deste consumidor.
const { loadAndExpand } = useRecurrence();
async function load(patientId) {
@@ -33,23 +42,14 @@ export function usePatientSessions() {
error.value = '';
sessions.value = [];
try {
// 1. Linhas reais — `recurrence_id`/`recurrence_date` inclusos pra
// mergeWithStoredSessions deduplicar virtuais de sessões já materializadas.
const { data, error: err } = await supabase
.from('agenda_eventos')
.select('id, inicio_em, fim_em, status, modalidade, tipo, titulo, titulo_custom, observacoes, patient_id, recurrence_id, recurrence_date')
.eq('patient_id', patientId)
.order('inicio_em', { ascending: false })
.limit(100);
if (err) throw err;
const realRows = data || [];
// 1. Linhas reais via repository
const realRows = await listSessionsByPatient(patientId);
// 2. Expande recorrências do owner + filtra só as deste paciente.
// Range default: 6 meses atrás → 12 meses à frente (cobre histórico
// recente + ~1 ano de séries semanais/quinzenais futuras). Sem expansão,
// sessão 1 aparece (materializada) mas as N-1 virtuais ficam invisíveis.
// 2. Expande recorrências do owner + filtra pra este paciente.
// Range: 6 meses atrás → 12 meses à frente (histórico + ~1 ano de séries futuras).
let virtualOccs = [];
try {
// auth.getUser é context, não data query — pode ficar inline.
const { data: userData } = await supabase.auth.getUser();
const ownerId = userData?.user?.id || null;
if (ownerId) {
@@ -57,7 +57,9 @@ export function usePatientSessions() {
try {
const { useTenantStore } = await import('@/stores/tenantStore');
tenantId = useTenantStore().activeTenantId || null;
} catch { /* sem tenant store — segue */ }
} catch {
/* sem tenant store — segue */
}
const now = new Date();
const rangeStart = new Date(now.getFullYear(), now.getMonth() - 6, 1);
@@ -67,12 +69,12 @@ export function usePatientSessions() {
virtualOccs = expanded.filter((r) => r.is_occurrence && r.patient_id === patientId);
}
} catch (e) {
// Fallback silencioso — UI segue funcional só com sessões reais.
// Fallback silencioso — UI segue só com sessões reais.
// eslint-disable-next-line no-console
console.warn('[usePatientSessions] recurrence expand falhou:', e);
}
// 3. Merge desc por inicio_em (mantém contrato do composable original).
// 3. Merge desc por inicio_em
const merged = [...realRows, ...virtualOccs];
merged.sort((a, b) => String(b.inicio_em).localeCompare(String(a.inicio_em)));
sessions.value = merged;
@@ -84,122 +86,63 @@ export function usePatientSessions() {
}
}
// Helpers derivados — proxima sessao agendada e status corrente
// ─── Computeds derivados ────────────────────────────────
const proximaSessao = computed(() => {
const now = Date.now();
return [...sessions.value]
.filter((s) => s.inicio_em && new Date(s.inicio_em).getTime() >= now)
.sort((a, b) => new Date(a.inicio_em) - new Date(b.inicio_em))[0] || null;
return (
[...sessions.value]
.filter((s) => s.inicio_em && new Date(s.inicio_em).getTime() >= now)
.sort((a, b) => new Date(a.inicio_em) - new Date(b.inicio_em))[0] || null
);
});
const ultimaSessao = computed(() => {
const now = Date.now();
return sessions.value
.filter((s) => s.inicio_em && new Date(s.inicio_em).getTime() < now)
.sort((a, b) => new Date(b.inicio_em) - new Date(a.inicio_em))[0] || null;
return (
sessions.value
.filter((s) => s.inicio_em && new Date(s.inicio_em).getTime() < now)
.sort((a, b) => new Date(b.inicio_em) - new Date(a.inicio_em))[0] || null
);
});
const totalSessoes = computed(() => sessions.value.length);
// Conta status com regex pra cobrir variantes pt-br
// (realizada/realizado/presente; falta/faltou; cancelada/cancelado/remarcada).
const totalRealizadas = computed(() =>
sessions.value.filter((s) => /realiz|present/i.test(String(s.status || ''))).length
);
const totalFaltas = computed(() =>
sessions.value.filter((s) => /falt/i.test(String(s.status || ''))).length
);
const totalCanceladas = computed(() =>
sessions.value.filter((s) => /cancel|remarca/i.test(String(s.status || ''))).length
);
const totalRealizadas = computed(() => sessions.value.filter((s) => /realiz|present/i.test(String(s.status || ''))).length);
const totalFaltas = computed(() => sessions.value.filter((s) => /falt/i.test(String(s.status || ''))).length);
const totalCanceladas = computed(() => sessions.value.filter((s) => /cancel|remarca/i.test(String(s.status || ''))).length);
/**
* Top 6 sessoes "atendidas" (qualquer status que indica encontro: realizado,
* faltou, cancelado, remarcado) alimenta a Timeline da Visao Geral.
*/
const ultimasAtendidas = computed(() =>
sessions.value
.filter((s) => /realiz|present|falt|cancel|remarca/i.test(String(s.status || '')))
.slice(0, 6)
sessions.value.filter((s) => /realiz|present|falt|cancel|remarca/i.test(String(s.status || ''))).slice(0, 6)
);
/**
* Cria uma nova sessao na agenda do paciente.
*
* payload: {
* inicio_em: ISO timestamp,
* fim_em: ISO timestamp,
* tipo: 'sessao' | 'primeira' | 'retorno' | etc,
* modalidade: 'presencial' | 'online',
* titulo?: string,
* titulo_custom?: string,
* observacoes?: string
* }
* Retorna {ok, data?, error?}.
*/
// ─── Mutations ─────────────────────────────────────────
async function createSession(patientId, payload = {}) {
if (!patientId || busy.value) return { ok: false, error: 'busy' };
if (!payload?.inicio_em || !payload?.fim_em) {
return { ok: false, error: 'Inicio/fim obrigatorios' };
return { ok: false, error: 'Início/fim obrigatórios' };
}
busy.value = true;
try {
const { data: userData } = await supabase.auth.getUser();
const ownerId = userData?.user?.id;
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,
inicio_em: payload.inicio_em,
fim_em: payload.fim_em,
status: 'agendado',
modalidade: payload.modalidade || 'presencial',
tipo: payload.tipo || 'sessao',
titulo: String(payload.titulo || '').trim() || null,
titulo_custom: String(payload.titulo_custom || '').trim() || null,
observacoes: String(payload.observacoes || '').trim() || null
};
const { data, error: err } = await supabase
.from('agenda_eventos')
.insert([row])
.select()
.single();
if (err) throw err;
const data = await createPatientSession(patientId, payload);
if (_lastPatientId) await load(_lastPatientId);
return { ok: true, data };
} catch (e) {
return { ok: false, error: e?.message || 'Erro ao agendar sessao' };
return { ok: false, error: e?.message || 'Erro ao agendar sessão' };
} finally {
busy.value = false;
}
}
/**
* Atualiza o status de uma sessao (mutation). Recarrega a lista do paciente
* ao final pra refletir o novo estado nos computeds derivados.
*
* Aceita string (UUID legado) OU a row inteira da sessão. Quando vier a row
* e ela for ocorrência virtual (is_occurrence=true, id `rec::ruleId::date`),
* MATERIALIZA primeiro: cria/encontra a linha real em agenda_eventos com
* recurrence_id+recurrence_date apontando pra regra, depois aplica o status.
* Sem isso o UPDATE falha com "invalid input syntax for type uuid" porque
* o id virtual nunca existiu no banco. Espelha o pattern de
* useMelissaAgenda.onUpdateSeriesEvent (L808-850).
*
* Retorna {ok: true} ou {ok: false, error: msg}.
* Atualiza status aceita UUID string OU row inteira. Se virtual
* (is_occurrence), materializa antes via createPatientSession.
*/
async function updateStatus(sessionOrId, novoStatus) {
if (!sessionOrId || busy.value) return { ok: false, error: 'busy' };
busy.value = true;
try {
// Caminho A — string UUID legado ou row real (id é UUID real).
const isObject = typeof sessionOrId === 'object' && sessionOrId !== null;
const isVirtual = isObject && !!sessionOrId.is_occurrence;
@@ -208,16 +151,12 @@ export function usePatientSessions() {
if (!realId || typeof realId !== 'string' || realId.startsWith('rec::')) {
return { ok: false, error: 'ID inválido pra atualizar status (virtual sem row).' };
}
const { error: err } = await supabase
.from('agenda_eventos')
.update({ status: novoStatus })
.eq('id', realId);
if (err) throw err;
await updatePatientSessionStatus(realId, novoStatus);
if (_lastPatientId) await load(_lastPatientId);
return { ok: true };
}
// Caminho B — ocorrência virtual: materializar antes de atualizar.
// Virtual: materializar antes
const row = sessionOrId;
const rid = row.recurrence_id;
const rDate = row.recurrence_date || row.original_date || String(row.inicio_em || '').slice(0, 10);
@@ -226,52 +165,25 @@ export function usePatientSessions() {
return { ok: false, error: 'Ocorrência sem recurrence_id/date — não dá pra materializar.' };
}
// Já existe row materializada (mesmo recurrence_id+date)? Usa ela.
const { data: existing, error: exErr } = await supabase
.from('agenda_eventos')
.select('id')
.eq('recurrence_id', rid)
.eq('recurrence_date', rDate)
.maybeSingle();
if (exErr) throw exErr;
const existing = await findSessionByRecurrence(rid, rDate);
if (existing?.id) {
const { error: upErr } = await supabase
.from('agenda_eventos')
.update({ status: novoStatus })
.eq('id', existing.id);
if (upErr) throw upErr;
await updatePatientSessionStatus(existing.id, novoStatus);
} else {
// Materializa NOVA row a partir da virtual. Owner/tenant via auth+store.
const { data: userData } = await supabase.auth.getUser();
const ownerId = userData?.user?.id || null;
let tenantId = null;
try {
const { useTenantStore } = await import('@/stores/tenantStore');
tenantId = useTenantStore().activeTenantId || null;
} catch { /* sem store — segue */ }
const newRow = {
owner_id: ownerId,
tenant_id: tenantId,
recurrence_id: rid,
recurrence_date: rDate,
patient_id: row.patient_id || row.paciente_id || _lastPatientId,
tipo: row.tipo || 'sessao',
status: novoStatus,
// Cria row real materializada (createPatientSession suporta recurrence_id/date)
await createPatientSession(row.patient_id || row.paciente_id || _lastPatientId, {
inicio_em: row.inicio_em,
fim_em: row.fim_em,
status: novoStatus,
modalidade: row.modalidade || 'presencial',
titulo: row.titulo || null,
titulo_custom: row.titulo_custom || null,
observacoes: row.observacoes || null,
determined_commitment_id: row.determined_commitment_id || null,
price: row.price ?? null
};
const { error: insErr } = await supabase
.from('agenda_eventos')
.insert([newRow]);
if (insErr) throw insErr;
tipo: row.tipo || 'sessao',
titulo: row.titulo,
titulo_custom: row.titulo_custom,
observacoes: row.observacoes,
recurrence_id: rid,
recurrence_date: rDate,
determined_commitment_id: row.determined_commitment_id,
price: row.price
});
}
if (_lastPatientId) await load(_lastPatientId);
@@ -3,13 +3,16 @@
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/patients/composables/usePatientSupportContacts.js
| V#9 composable de contatos de suporte do paciente (responsável, parente,
| amigo). Encapsula CRUD + estado reativo.
| V#9 contatos de suporte do paciente (responsável, parente, amigo).
| Encapsula CRUD + estado reativo. I/O delegada ao patientsRepository.
|--------------------------------------------------------------------------
*/
import { ref } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { digitsOnly, fmtPhone } from '@/utils/validators';
import {
listSupportContactsByPatient,
replacePatientSupportContacts
} from '@/features/patients/services/patientsRepository';
function novoContato() {
return {
@@ -55,12 +58,7 @@ export function usePatientSupportContacts() {
}
loading.value = true;
try {
const { data, error } = await supabase
.from('patient_support_contacts')
.select('*')
.eq('patient_id', patientId)
.order('is_primario', { ascending: false });
if (error) throw error;
const data = await listSupportContactsByPatient(patientId);
contatos.value = (data || []).map((c) => ({
_k: c.id,
nome: c.nome || '',
@@ -71,6 +69,7 @@ export function usePatientSupportContacts() {
is_primario: !!c.is_primario
}));
} catch {
// Fallback silencioso pra não quebrar prontuário se tabela estiver vazia/RLS
contatos.value = [];
} finally {
loading.value = false;
@@ -79,26 +78,13 @@ export function usePatientSupportContacts() {
/**
* Substitui contatos do paciente: deleta tudo do owner + reinserta os com nome.
* @param {string} patientId
* @param {string} tenantId
* @param {string} ownerId
*/
async function save(patientId, tenantId, ownerId) {
if (!patientId) throw new Error('patientId obrigatório');
const { error: del } = await supabase
.from('patient_support_contacts')
.delete()
.eq('patient_id', patientId)
.eq('owner_id', ownerId);
if (del) throw del;
const rows = contatos.value
.filter((c) => c.nome.trim())
.map((c) => ({
patient_id: patientId,
owner_id: ownerId,
tenant_id: tenantId,
nome: c.nome.trim() || null,
relacao: c.relacao || null,
tipo: c.tipo || null,
@@ -107,9 +93,8 @@ export function usePatientSupportContacts() {
is_primario: !!c.is_primario
}));
if (!rows.length) return;
const { error: ins } = await supabase.from('patient_support_contacts').insert(rows);
if (ins) throw ins;
// Repository injeta patient_id, owner_id, tenant_id em cada row
await replacePatientSupportContacts(patientId, rows, { tenantId, ownerId });
}
return { contatos, loading, add, remove, reset, iniciaisFor, load, save };
@@ -3,8 +3,10 @@
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/patients/composables/usePatients.js
| V#3 composable que agrega estado reativo (rows/loading/error) e delega
| toda I/O ao patientsRepository. Mesmo padrão de useAgendaEvents.
|
| Thin wrapper sobre patientsRepository (composable-blueprint Tipo A).
| Toda I/O delegada ao repository. State reativo: rows, loading, error.
| Mutations re-throw após registrar error.value pra caller decidir UX.
|--------------------------------------------------------------------------
*/
import { ref } from 'vue';
@@ -19,11 +21,11 @@ import {
export function usePatients() {
const rows = ref([]);
const loading = ref(false);
const error = ref(null);
const error = ref('');
async function load(opts) {
loading.value = true;
error.value = null;
error.value = '';
try {
rows.value = await listPatients(opts);
} catch (e) {
@@ -35,19 +37,55 @@ export function usePatients() {
}
async function getById(id, opts) {
return getPatientById(id, opts);
loading.value = true;
error.value = '';
try {
return await getPatientById(id, opts);
} catch (e) {
error.value = e?.message || 'Erro ao carregar paciente';
return null;
} finally {
loading.value = false;
}
}
async function create(payload) {
return createPatient(payload);
loading.value = true;
error.value = '';
try {
return await createPatient(payload);
} catch (e) {
error.value = e?.message || 'Erro ao criar paciente';
throw e;
} finally {
loading.value = false;
}
}
async function update(id, patch, opts) {
return updatePatient(id, patch, opts);
loading.value = true;
error.value = '';
try {
return await updatePatient(id, patch, opts);
} catch (e) {
error.value = e?.message || 'Erro ao atualizar paciente';
throw e;
} finally {
loading.value = false;
}
}
async function remove(id, opts) {
await softDeletePatient(id, opts);
loading.value = true;
error.value = '';
try {
await softDeletePatient(id, opts);
} catch (e) {
error.value = e?.message || 'Erro ao remover paciente';
throw e;
} finally {
loading.value = false;
}
}
return { rows, loading, error, load, getById, create, update, remove };
@@ -0,0 +1,130 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/patients/prontuario/composables/useClinicalNoteTemplates.js
|
| Thin wrapper sobre clinicalNoteTemplatesRepository.
| Carrega templates visíveis (sistema + tenant + owner), filtra por noteType.
|
| Depende das migrations 0.5.B + seed_040_clinical_note_templates.sql.
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue';
import {
listAvailable,
getById,
getByKey,
create as repoCreate,
update as repoUpdate,
softDelete as repoSoftDelete
} from '@/features/patients/prontuario/services/clinicalNoteTemplatesRepository';
export function useClinicalNoteTemplates() {
const rows = ref([]);
const loading = ref(false);
const error = ref('');
async function load(opts = {}) {
loading.value = true;
error.value = '';
try {
rows.value = await listAvailable(opts);
} catch (e) {
error.value = e?.message || 'Falha ao carregar templates.';
rows.value = [];
} finally {
loading.value = false;
}
}
async function fetchById(id) {
try {
return await getById(id);
} catch (e) {
error.value = e?.message || 'Falha ao carregar template.';
return null;
}
}
async function fetchByKey(key, opts = {}) {
try {
return await getByKey(key, opts);
} catch (e) {
error.value = e?.message || 'Falha ao carregar template.';
return null;
}
}
async function create(payload) {
loading.value = true;
error.value = '';
try {
const created = await repoCreate(payload);
rows.value = [...rows.value, created];
return created;
} catch (e) {
error.value = e?.message || 'Falha ao criar template.';
throw e;
} finally {
loading.value = false;
}
}
async function update(id, patch) {
loading.value = true;
error.value = '';
try {
const updated = await repoUpdate(id, patch);
const idx = rows.value.findIndex((r) => r.id === id);
if (idx >= 0) rows.value[idx] = updated;
return updated;
} catch (e) {
error.value = e?.message || 'Falha ao atualizar template.';
throw e;
} finally {
loading.value = false;
}
}
async function softDelete(id) {
loading.value = true;
error.value = '';
try {
await repoSoftDelete(id);
rows.value = rows.value.filter((r) => r.id !== id);
return true;
} catch (e) {
error.value = e?.message || 'Falha ao remover template.';
throw e;
} finally {
loading.value = false;
}
}
// ─── Computeds derivados ────────────────────────────────────────────
const systemTemplates = computed(() => rows.value.filter((t) => t.is_system));
const tenantTemplates = computed(() => rows.value.filter((t) => !t.is_system && t.tenant_id && !t.owner_id));
const ownerTemplates = computed(() => rows.value.filter((t) => !t.is_system && t.owner_id));
function byType(noteType) {
return rows.value.filter((t) => t.note_type === noteType);
}
return {
rows,
loading,
error,
load,
fetchById,
fetchByKey,
create,
update,
softDelete,
systemTemplates,
tenantTemplates,
ownerTemplates,
byType
};
}
@@ -0,0 +1,190 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/patients/prontuario/composables/useClinicalNotes.js
|
| Thin wrapper sobre clinicalNotesRepository (composable-blueprint Tipo A).
| State reativo: rows, loading, error.
|
| Depende das migrations 0.5.B aplicadas no banco. Sem isso, qualquer
| operação retorna erro de "tabela clinical_notes não existe".
|--------------------------------------------------------------------------
*/
import { ref } from 'vue';
import {
listForPatient,
listForSession,
getById,
create as repoCreate,
update as repoUpdate,
softDelete as repoSoftDelete,
restore as repoRestore,
setPinned as repoSetPinned,
listVersions,
getVersion
} from '@/features/patients/prontuario/services/clinicalNotesRepository';
export function useClinicalNotes() {
const rows = ref([]);
const loading = ref(false);
const error = ref('');
async function loadForPatient(patientId, opts = {}) {
if (!patientId) {
rows.value = [];
return;
}
loading.value = true;
error.value = '';
try {
rows.value = await listForPatient(patientId, opts);
} catch (e) {
error.value = e?.message || 'Falha ao carregar notas clínicas.';
rows.value = [];
} finally {
loading.value = false;
}
}
async function loadForSession(sessionEventId, opts = {}) {
if (!sessionEventId) {
rows.value = [];
return;
}
loading.value = true;
error.value = '';
try {
rows.value = await listForSession(sessionEventId, opts);
} catch (e) {
error.value = e?.message || 'Falha ao carregar notas da sessão.';
rows.value = [];
} finally {
loading.value = false;
}
}
async function fetchById(noteId, opts = {}) {
loading.value = true;
error.value = '';
try {
return await getById(noteId, opts);
} catch (e) {
error.value = e?.message || 'Falha ao carregar nota.';
return null;
} finally {
loading.value = false;
}
}
async function create(payload) {
loading.value = true;
error.value = '';
try {
const created = await repoCreate(payload);
// Insere no topo se pinned, senão por ordem natural (já vem com created_at = now)
rows.value = [created, ...rows.value];
return created;
} catch (e) {
error.value = e?.message || 'Falha ao criar nota.';
throw e;
} finally {
loading.value = false;
}
}
async function update(noteId, patch, opts = {}) {
loading.value = true;
error.value = '';
try {
const updated = await repoUpdate(noteId, patch, opts);
const idx = rows.value.findIndex((r) => r.id === noteId);
if (idx >= 0) rows.value[idx] = updated;
return updated;
} catch (e) {
error.value = e?.message || 'Falha ao atualizar nota.';
throw e;
} finally {
loading.value = false;
}
}
async function softDelete(noteId, opts = {}) {
loading.value = true;
error.value = '';
try {
await repoSoftDelete(noteId, opts);
rows.value = rows.value.filter((r) => r.id !== noteId);
return true;
} catch (e) {
error.value = e?.message || 'Falha ao remover nota.';
throw e;
} finally {
loading.value = false;
}
}
async function restore(noteId, opts = {}) {
loading.value = true;
error.value = '';
try {
await repoRestore(noteId, opts);
return true;
} catch (e) {
error.value = e?.message || 'Falha ao restaurar nota.';
throw e;
} finally {
loading.value = false;
}
}
async function togglePinned(noteId, pinned, opts = {}) {
loading.value = true;
error.value = '';
try {
const updated = await repoSetPinned(noteId, pinned, opts);
const idx = rows.value.findIndex((r) => r.id === noteId);
if (idx >= 0) rows.value[idx] = updated;
return updated;
} catch (e) {
error.value = e?.message || 'Falha ao fixar nota.';
throw e;
} finally {
loading.value = false;
}
}
async function fetchVersions(noteId) {
try {
return await listVersions(noteId);
} catch (e) {
error.value = e?.message || 'Falha ao carregar versões.';
return [];
}
}
async function fetchVersion(noteId, versionNumber) {
try {
return await getVersion(noteId, versionNumber);
} catch (e) {
error.value = e?.message || 'Falha ao carregar versão.';
return null;
}
}
return {
rows,
loading,
error,
loadForPatient,
loadForSession,
fetchById,
create,
update,
softDelete,
restore,
togglePinned,
fetchVersions,
fetchVersion
};
}
@@ -0,0 +1,25 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/patients/prontuario/services/_tenantGuards.js
|
| Guards compartilhados pelos repositories do prontuário clínico.
| Pattern canônico ver blueprints/repository-blueprint.md seção 3.
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
export function assertTenantId(tenantId) {
if (!tenantId || tenantId === 'null' || tenantId === 'undefined') {
throw new Error('Tenant ativo inválido. Selecione a clínica/tenant antes de operar no prontuário.');
}
}
export async function getUid() {
const { data, error } = await supabase.auth.getUser();
if (error) throw error;
const uid = data?.user?.id;
if (!uid) throw new Error('Usuário não autenticado.');
return uid;
}
@@ -0,0 +1,140 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/patients/prontuario/services/clinicalNoteTemplatesRepository.js
|
| Repository de clinical_note_templates. Escopo escalonado:
| - Sistema (is_system=true, tenant_id NULL) todos authenticated leem
| - Tenant-wide (tenant_id, owner_id NULL) membros do tenant
| - Owner (tenant_id + owner_id) o owner
|
| RLS bloqueia INSERT/UPDATE/DELETE de templates is_system via seed.
| Templates do tenant podem ser criados/editados pelo tenant_admin.
|
| Schema: ver migrations/20260520000001_clinical_notes_tables.sql
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { assertTenantId, getUid } from './_tenantGuards';
import { CLINICAL_NOTE_TEMPLATE_SELECT } from './clinicalNotesSelects';
const VALID_NOTE_TYPES = ['anamnese', 'evolucao_sessao', 'plano_terapeutico', 'observacao_livre', 'resumo_caso'];
function resolveTenantId(tenantIdArg) {
const tenantStore = useTenantStore();
const tenantId = tenantIdArg || tenantStore.activeTenantId || tenantStore.tenantId;
assertTenantId(tenantId);
return tenantId;
}
/**
* Lista templates visíveis pelo usuário ativo (sistema + tenant + owner).
* RLS no banco filtra automaticamente; aqui ordenamos.
*
* @param {Object} [opts]
* @param {string} [opts.noteType] - filtra por tipo de nota
* @param {string} [opts.tenantId]
* @param {boolean} [opts.includeInactive=false]
*/
export async function listAvailable({ noteType, tenantId, includeInactive = false } = {}) {
resolveTenantId(tenantId); // garante tenant ativo (RLS depende)
let q = supabase.from('clinical_note_templates').select(CLINICAL_NOTE_TEMPLATE_SELECT).order('is_system', { ascending: false }).order('name', { ascending: true });
if (!includeInactive) q = q.eq('active', true);
if (noteType) q = q.eq('note_type', noteType);
const { data, error } = await q;
if (error) throw error;
return data || [];
}
/**
* template por id.
*/
export async function getById(templateId) {
if (!templateId) throw new Error('ID inválido.');
const { data, error } = await supabase.from('clinical_note_templates').select(CLINICAL_NOTE_TEMPLATE_SELECT).eq('id', templateId).maybeSingle();
if (error) throw error;
return data || null;
}
/**
* template por key (útil pra defaults 'soap', 'dap', 'birp', 'anamnese_padrao').
* Prioriza is_system se houver conflito de key (cobre seed primeiro).
*/
export async function getByKey(key, { noteType } = {}) {
if (!key) throw new Error('Key inválida.');
let q = supabase.from('clinical_note_templates').select(CLINICAL_NOTE_TEMPLATE_SELECT).eq('key', key).eq('active', true);
if (noteType) q = q.eq('note_type', noteType);
const { data, error } = await q.order('is_system', { ascending: false }).limit(1).maybeSingle();
if (error) throw error;
return data || null;
}
/**
* Cria template tenant-wide ou owner-scoped. is_system bloqueado em RLS.
*/
export async function create(payload) {
if (!payload?.key) throw new Error('key obrigatória.');
if (!payload?.name) throw new Error('name obrigatório.');
if (!payload?.note_type) throw new Error('note_type obrigatório.');
if (!VALID_NOTE_TYPES.includes(payload.note_type)) {
throw new Error(`note_type inválido. Aceitos: ${VALID_NOTE_TYPES.join(', ')}.`);
}
if (!payload?.structure) throw new Error('structure (jsonb) obrigatória.');
const uid = await getUid();
const tid = resolveTenantId();
const row = {
tenant_id: tid,
owner_id: payload.ownerScoped ? uid : null,
key: String(payload.key).trim(),
name: String(payload.name).trim(),
note_type: payload.note_type,
description: payload.description ? String(payload.description).trim() || null : null,
structure: payload.structure,
is_system: false,
is_global: false,
active: payload.active !== false
};
const { data, error } = await supabase.from('clinical_note_templates').insert([row]).select(CLINICAL_NOTE_TEMPLATE_SELECT).single();
if (error) throw error;
return data;
}
/**
* Atualiza template. is_system bloqueado em RLS.
*/
export async function update(templateId, patch) {
if (!templateId) throw new Error('ID inválido.');
const safePatch = { ...patch, updated_at: new Date().toISOString() };
if ('is_system' in safePatch) delete safePatch.is_system; // RLS bloqueia mas defesa em profundidade
const { data, error } = await supabase.from('clinical_note_templates').update(safePatch).eq('id', templateId).select(CLINICAL_NOTE_TEMPLATE_SELECT).single();
if (error) throw error;
return data;
}
/**
* Soft delete via active=false.
*/
export async function softDelete(templateId) {
if (!templateId) throw new Error('ID inválido.');
const { error } = await supabase.from('clinical_note_templates').update({ active: false, updated_at: new Date().toISOString() }).eq('id', templateId);
if (error) throw error;
return true;
}
@@ -0,0 +1,251 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/patients/prontuario/services/clinicalNotesRepository.js
|
| Repository de clinical_notes (anamnese, evolução, plano, observação livre,
| resumo de caso). RLS owner-only (CFP sigilo profissional).
|
| Schema: ver migrations/20260520000001_clinical_notes_tables.sql
| Trigger AUTO-versiona em INSERT e UPDATE de content/title/deleted_at.
|
| Pré-requisito: migrations 20260520000001..3 executadas no banco.
| Sem isso, todas as funções abaixo retornam erro de "tabela não existe".
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { assertTenantId, getUid } from './_tenantGuards';
import {
CLINICAL_NOTE_SELECT,
CLINICAL_NOTE_SELECT_BRIEF,
CLINICAL_NOTE_VERSION_SELECT,
flattenNoteRow
} from './clinicalNotesSelects';
const VALID_NOTE_TYPES = ['anamnese', 'evolucao_sessao', 'plano_terapeutico', 'observacao_livre', 'resumo_caso'];
function resolveTenantId(tenantIdArg) {
const tenantStore = useTenantStore();
const tenantId = tenantIdArg || tenantStore.activeTenantId || tenantStore.tenantId;
assertTenantId(tenantId);
return tenantId;
}
function assertNoteType(noteType) {
if (!VALID_NOTE_TYPES.includes(noteType)) {
throw new Error(`Tipo de nota inválido. Aceitos: ${VALID_NOTE_TYPES.join(', ')}.`);
}
}
/**
* Lista notas ativas (deleted_at IS NULL) de um paciente.
* Pinned primeiro, depois desc por created_at.
*
* @param {string} patientId
* @param {Object} [opts]
* @param {string} [opts.tenantId]
* @param {string|null} [opts.noteType] - filtra por tipo (anamnese, evolucao_sessao, etc)
* @param {boolean} [opts.includeDeleted=false]
* @param {boolean} [opts.brief=false] - usa SELECT brief sem content (lista/timeline)
*/
export async function listForPatient(patientId, { tenantId, noteType = null, includeDeleted = false, brief = false } = {}) {
if (!patientId) return [];
const tid = resolveTenantId(tenantId);
const select = brief ? CLINICAL_NOTE_SELECT_BRIEF : CLINICAL_NOTE_SELECT;
let q = supabase
.from('clinical_notes')
.select(select)
.eq('tenant_id', tid)
.eq('patient_id', patientId)
.order('pinned', { ascending: false })
.order('created_at', { ascending: false });
if (!includeDeleted) q = q.is('deleted_at', null);
if (noteType) q = q.eq('note_type', noteType);
const { data, error } = await q;
if (error) throw error;
return (data || []).map(flattenNoteRow);
}
/**
* Lista notas vinculadas a uma sessão (session_event_id).
* Útil pra mostrar "anotações desta sessão" no AgendaEventDialog.
*/
export async function listForSession(sessionEventId, { tenantId, brief = false } = {}) {
if (!sessionEventId) return [];
const tid = resolveTenantId(tenantId);
const select = brief ? CLINICAL_NOTE_SELECT_BRIEF : CLINICAL_NOTE_SELECT;
const { data, error } = await supabase.from('clinical_notes').select(select).eq('tenant_id', tid).eq('session_event_id', sessionEventId).is('deleted_at', null).order('created_at', { ascending: false });
if (error) throw error;
return (data || []).map(flattenNoteRow);
}
/**
* uma nota completa por id.
*/
export async function getById(noteId, { tenantId } = {}) {
if (!noteId) throw new Error('ID inválido.');
const tid = resolveTenantId(tenantId);
const { data, error } = await supabase.from('clinical_notes').select(CLINICAL_NOTE_SELECT).eq('id', noteId).eq('tenant_id', tid).maybeSingle();
if (error) throw error;
return data ? flattenNoteRow(data) : null;
}
/**
* Cria nota clínica. owner_id + tenant_id + created_by injetados pelo repository.
* Trigger no banco cria automaticamente version_number=1.
*
* @param {Object} payload
* @param {string} payload.patient_id - obrigatório
* @param {string} payload.note_type - obrigatório (CHECK no banco)
* @param {string} [payload.session_event_id]
* @param {string} [payload.template_id]
* @param {string} [payload.title]
* @param {string} [payload.content_text]
* @param {Object} [payload.content_structured]
* @param {boolean} [payload.pinned=false]
* @param {boolean} [payload.is_draft=false]
*/
export async function create(payload) {
if (!payload?.patient_id) throw new Error('patient_id obrigatório.');
if (!payload?.note_type) throw new Error('note_type obrigatório.');
assertNoteType(payload.note_type);
if (!payload.content_text && !payload.content_structured) {
throw new Error('Nota precisa de content_text ou content_structured (CHECK constraint).');
}
const uid = await getUid();
const tid = resolveTenantId();
const row = {
patient_id: payload.patient_id,
owner_id: uid,
tenant_id: tid,
session_event_id: payload.session_event_id || null,
note_type: payload.note_type,
template_id: payload.template_id || null,
title: payload.title ? String(payload.title).trim() || null : null,
content_text: payload.content_text ? String(payload.content_text).trim() || null : null,
content_structured: payload.content_structured || null,
pinned: !!payload.pinned,
is_draft: !!payload.is_draft,
created_by: uid
};
const { data, error } = await supabase.from('clinical_notes').insert([row]).select(CLINICAL_NOTE_SELECT).single();
if (error) throw error;
return flattenNoteRow(data);
}
/**
* Atualiza nota clínica. Repository injeta updated_by + updated_at é setado pelo trigger.
* Trigger cria nova versão se content/title/deleted_at mudaram.
*
* @param {string} noteId
* @param {Object} patch
* @param {Object} [opts]
* @param {string} [opts.tenantId]
*/
export async function update(noteId, patch, { tenantId } = {}) {
if (!noteId) throw new Error('ID inválido.');
const tid = resolveTenantId(tenantId);
const uid = await getUid();
if (patch?.note_type) assertNoteType(patch.note_type);
const safePatch = { ...sanitize(patch), updated_by: uid };
const { data, error } = await supabase.from('clinical_notes').update(safePatch).eq('id', noteId).eq('tenant_id', tid).select(CLINICAL_NOTE_SELECT).single();
if (error) throw error;
return flattenNoteRow(data);
}
/**
* Soft delete seta deleted_at + deleted_by. Trigger cria versão snapshot.
* Hard delete bloqueado em RLS soft.
*/
export async function softDelete(noteId, { tenantId } = {}) {
if (!noteId) throw new Error('ID inválido.');
const tid = resolveTenantId(tenantId);
const uid = await getUid();
const { error } = await supabase
.from('clinical_notes')
.update({ deleted_at: new Date().toISOString(), deleted_by: uid, updated_by: uid })
.eq('id', noteId)
.eq('tenant_id', tid);
if (error) throw error;
return true;
}
/**
* Restore clears deleted_at/deleted_by. Trigger cria versão snapshot com reason='restore'.
*/
export async function restore(noteId, { tenantId } = {}) {
if (!noteId) throw new Error('ID inválido.');
const tid = resolveTenantId(tenantId);
const uid = await getUid();
const { error } = await supabase.from('clinical_notes').update({ deleted_at: null, deleted_by: null, updated_by: uid }).eq('id', noteId).eq('tenant_id', tid);
if (error) throw error;
return true;
}
/**
* Toggle pinned (utilitário comum).
*/
export async function setPinned(noteId, pinned, { tenantId } = {}) {
return update(noteId, { pinned: !!pinned }, { tenantId });
}
/**
* Lista versões (audit trail) de uma nota. Ordem desc por version_number.
*/
export async function listVersions(noteId) {
if (!noteId) return [];
const { data, error } = await supabase.from('clinical_note_versions').select(CLINICAL_NOTE_VERSION_SELECT).eq('note_id', noteId).order('version_number', { ascending: false });
if (error) throw error;
return data || [];
}
/**
* snapshot de uma versão específica.
*/
export async function getVersion(noteId, versionNumber) {
if (!noteId) throw new Error('noteId obrigatório.');
if (!versionNumber) throw new Error('versionNumber obrigatório.');
const { data, error } = await supabase.from('clinical_note_versions').select(CLINICAL_NOTE_VERSION_SELECT).eq('note_id', noteId).eq('version_number', versionNumber).maybeSingle();
if (error) throw error;
return data || null;
}
// ─── helpers internos ────────────────────────────────────────────────────────
function sanitize(patch) {
const out = { ...patch };
if ('title' in out && typeof out.title === 'string') {
const t = out.title.trim();
out.title = t === '' ? null : t;
}
if ('content_text' in out && typeof out.content_text === 'string') {
const t = out.content_text.trim();
out.content_text = t === '' ? null : t;
}
return out;
}
@@ -0,0 +1,63 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/patients/prontuario/services/clinicalNotesSelects.js
|
| SELECTs canônicos do prontuário clínico (clinical_notes, versions, templates).
| Schema definido nas migrations 20260520000001_clinical_notes_tables.sql etc.
|--------------------------------------------------------------------------
*/
/** SELECT completo de clinical_notes (excluindo deletadas via filter no caller). */
export const CLINICAL_NOTE_SELECT = `
id, tenant_id, owner_id, patient_id, session_event_id, note_type,
template_id, title, content_text, content_structured,
pinned, is_draft, created_at, updated_at, created_by, updated_by,
deleted_at, deleted_by
`
.replace(/\s+/g, ' ')
.trim();
/** SELECT brief — pra listagens/cards sem content pesado. */
export const CLINICAL_NOTE_SELECT_BRIEF = `
id, patient_id, session_event_id, note_type, template_id, title,
pinned, is_draft, created_at, updated_at
`
.replace(/\s+/g, ' ')
.trim();
/** SELECT de versões — audit trail completo. */
export const CLINICAL_NOTE_VERSION_SELECT = `
id, note_id, tenant_id, version_number, title,
content_text, content_structured, change_reason,
created_at, created_by
`
.replace(/\s+/g, ' ')
.trim();
/** SELECT de templates. */
export const CLINICAL_NOTE_TEMPLATE_SELECT = `
id, tenant_id, owner_id, key, name, note_type, description,
structure, is_system, is_global, active, created_at, updated_at
`
.replace(/\s+/g, ' ')
.trim();
/**
* Status derivado da nota runtime, não persistido.
* draft: is_draft = true
* active: nem draft nem deletado
* deleted: deleted_at set
*/
export function deriveNoteStatus(row) {
if (!row) return 'active';
if (row.deleted_at) return 'deleted';
if (row.is_draft) return 'draft';
return 'active';
}
export function flattenNoteRow(r) {
if (!r) return r;
return { ...r, status: deriveNoteStatus(r) };
}
@@ -5,25 +5,41 @@
| Arquivo: src/features/patients/services/patientsRepository.js
| V#3 fundação: queries de patients centralizadas.
|
| Mesmo padrão de feature/agenda/services/agendaRepository.js. Pages devem
| chamar este repo em vez de fazer supabase.from('patients') direto.
| Pages e composables devem chamar este repo em vez de fazer
| supabase.from('patients') direto.
|
| Inclui também reads cross-feature em escopo de paciente (agenda_eventos,
| financial_records, documents, recurrence_rules, conversation_messages,
| patient_support_contacts) usados pelos sub-composables do prontuário.
| Quando M4 (Financeiro) / M6 (Notificações/Conversations) padronizarem
| esses domínios, as funções respectivas migram pra repositories nativos.
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { assertTenantId, getUid } from '@/features/agenda/services/_tenantGuards';
import {
PATIENTS_SELECT_BASE,
PATIENT_SESSIONS_SELECT,
PATIENT_FINANCIAL_RECORDS_SELECT,
PATIENT_DOCUMENTS_SELECT,
PATIENT_MESSAGES_SELECT,
PATIENT_RECURRENCE_RULES_SELECT,
PATIENT_SUPPORT_CONTACTS_SELECT,
PATIENT_GROUPS_SELECT,
PATIENT_GROUPS_SELECT_BRIEF,
PATIENT_TAGS_SELECT,
PATIENT_TAGS_SELECT_BRIEF
} from './patientsSelects';
const PATIENTS_SELECT_BASE = `
id, tenant_id, owner_id, user_id, responsible_member_id, therapist_member_id,
nome_completo, email_principal, email_alternativo, telefone, telefone_alternativo,
cpf, rg, data_nascimento, naturalidade, genero, estado_civil,
profissao, escolaridade, status,
cep, endereco, numero, bairro, complemento, cidade, estado, pais,
nome_responsavel, telefone_responsavel, cpf_responsavel, observacao_responsavel,
cobranca_no_responsavel,
onde_nos_conheceu, encaminhado_por, observacoes,
last_attended_at, created_at, updated_at,
risco_sinalizado_por, convenio_id, patient_scope
`;
// ─── Helpers internos ────────────────────────────────────────────────────────
function resolveTenantId(tenantIdArg) {
const tenantStore = useTenantStore();
const tenantId = tenantIdArg || tenantStore.activeTenantId || tenantStore.tenantId;
assertTenantId(tenantId);
return tenantId;
}
// ─────────────────────────────────────────────────────────────────────────
// Patients core
@@ -31,12 +47,11 @@ const PATIENTS_SELECT_BASE = `
/**
* Lista pacientes do tenant ativo. Aceita filtros opcionais.
* @param {object} opts - { tenantId, ownerId?, includeInactive?, limit? }
*/
export async function listPatients({ tenantId, ownerId = null, includeInactive = true, limit = null } = {}) {
assertTenantId(tenantId);
const tid = resolveTenantId(tenantId);
let q = supabase.from('patients').select(PATIENTS_SELECT_BASE).eq('tenant_id', tenantId);
let q = supabase.from('patients').select(PATIENTS_SELECT_BASE).eq('tenant_id', tid);
if (ownerId) q = q.eq('owner_id', ownerId);
if (!includeInactive) q = q.neq('status', 'Inativo');
if (limit) q = q.limit(limit);
@@ -49,22 +64,21 @@ export async function listPatients({ tenantId, ownerId = null, includeInactive =
export async function getPatientById(id, { tenantId } = {}) {
if (!id) throw new Error('id obrigatório');
assertTenantId(tenantId);
const { data, error } = await supabase
.from('patients')
.select(PATIENTS_SELECT_BASE)
.eq('id', id)
.eq('tenant_id', tenantId)
.maybeSingle();
const tid = resolveTenantId(tenantId);
const { data, error } = await supabase.from('patients').select(PATIENTS_SELECT_BASE).eq('id', id).eq('tenant_id', tid).maybeSingle();
if (error) throw error;
return data;
}
export async function createPatient(payload) {
const tenantId = payload?.tenant_id;
assertTenantId(tenantId);
const ownerId = payload?.owner_id || (await getUid());
const row = { ...payload, tenant_id: tenantId, owner_id: ownerId };
const tid = resolveTenantId(payload?.tenant_id);
// owner_id SEMPRE injetado do uid logado (não aceita do payload).
// Audit baseline alta (2026-05-20): aceitar do payload permitiria
// criar pacientes "de outro terapeuta". Repository é defesa em profundidade.
const ownerId = await getUid();
// eslint-disable-next-line no-unused-vars
const { owner_id: _dropped, ...rest } = payload || {};
const row = { ...rest, tenant_id: tid, owner_id: ownerId };
const { data, error } = await supabase.from('patients').insert(row).select(PATIENTS_SELECT_BASE).single();
if (error) throw error;
return data;
@@ -72,84 +86,27 @@ export async function createPatient(payload) {
export async function updatePatient(id, patch, { tenantId } = {}) {
if (!id) throw new Error('id obrigatório');
assertTenantId(tenantId);
const { data, error } = await supabase
.from('patients')
.update(patch)
.eq('id', id)
.eq('tenant_id', tenantId)
.select(PATIENTS_SELECT_BASE)
.single();
const tid = resolveTenantId(tenantId);
const { data, error } = await supabase.from('patients').update(patch).eq('id', id).eq('tenant_id', tid).select(PATIENTS_SELECT_BASE).single();
if (error) throw error;
return data;
}
export async function softDeletePatient(id, { tenantId } = {}) {
if (!id) throw new Error('id obrigatório');
assertTenantId(tenantId);
const { error } = await supabase
.from('patients')
.update({ status: 'Arquivado' })
.eq('id', id)
.eq('tenant_id', tenantId);
const tid = resolveTenantId(tenantId);
const { error } = await supabase.from('patients').update({ status: 'Arquivado' }).eq('id', id).eq('tenant_id', tid);
if (error) throw error;
}
// Pra restaurar um paciente arquivado, use `reactivatePatient` do
// composable `usePatientLifecycle` — fonte única de verdade pra toda
// transição de status (Inativo/Arquivado/Alta/Encaminhado → Ativo).
// Pra restaurar paciente arquivado, use `reactivatePatient` do `usePatientLifecycle`.
// ─────────────────────────────────────────────────────────────────────────
// Groups
// Patient Relations (groups + tags)
// -----------------------------------------------------------------------------
export async function listGroups({ tenantId, ownerId = null } = {}) {
assertTenantId(tenantId);
let q = supabase.from('patient_groups').select('id, nome, cor, is_system, owner_id, is_active').eq('tenant_id', tenantId).eq('is_active', true);
if (ownerId) q = q.or(`is_system.eq.true,owner_id.eq.${ownerId}`);
q = q.order('nome', { ascending: true });
const { data, error } = await q;
if (error) throw error;
return (data || []).map((g) => ({ id: g.id, name: g.nome, color: g.cor, isSystem: g.is_system }));
}
export async function listGroupsByPatient(patientIds, { tenantId } = {}) {
if (!patientIds?.length) return [];
assertTenantId(tenantId);
const { data, error } = await supabase
.from('patient_group_patient')
.select('patient_id, patient_group_id')
.eq('tenant_id', tenantId)
.in('patient_id', patientIds);
if (error) throw error;
return data || [];
}
// ─────────────────────────────────────────────────────────────────────────
// Tags
// -----------------------------------------------------------------------------
export async function listTags({ tenantId, ownerId = null } = {}) {
assertTenantId(tenantId);
let q = supabase.from('patient_tags').select('id, nome, cor, owner_id').eq('tenant_id', tenantId);
if (ownerId) q = q.eq('owner_id', ownerId);
const { data, error } = await q;
if (error) throw error;
return (data || []).map((t) => ({ id: t.id, name: t.nome, color: t.cor }));
}
export async function listTagsByPatient(patientIds, { tenantId } = {}) {
if (!patientIds?.length) return [];
assertTenantId(tenantId);
const { data, error } = await supabase
.from('patient_patient_tag')
.select('patient_id, tag_id')
.eq('tenant_id', tenantId)
.in('patient_id', patientIds);
if (error) throw error;
return data || [];
}
/**
* Retorna {groupIds, tagIds} de um paciente.
* Retorna {groupIds, tagIds} de um paciente (joins junior tables).
*/
export async function getPatientRelations(patientId) {
if (!patientId) return { groupIds: [], tagIds: [] };
@@ -165,48 +122,398 @@ export async function getPatientRelations(patientId) {
};
}
// ─── Groups ────────────────────────────────────────────────────────────────
export async function listGroups({ tenantId, ownerId = null } = {}) {
const tid = resolveTenantId(tenantId);
let q = supabase.from('patient_groups').select(PATIENT_GROUPS_SELECT).eq('tenant_id', tid).eq('is_active', true);
if (ownerId) q = q.or(`is_system.eq.true,owner_id.eq.${ownerId}`);
q = q.order('nome', { ascending: true });
const { data, error } = await q;
if (error) throw error;
return (data || []).map((g) => ({ id: g.id, name: g.nome, color: g.cor, isSystem: g.is_system }));
}
export async function listGroupsByPatient(patientIds, { tenantId } = {}) {
if (!patientIds?.length) return [];
const tid = resolveTenantId(tenantId);
const { data, error } = await supabase.from('patient_group_patient').select('patient_id, patient_group_id').eq('tenant_id', tid).in('patient_id', patientIds);
if (error) throw error;
return data || [];
}
/**
* grupos por ids (brief id+name). Usado em prontuário pra mostrar pílulas.
*/
export async function getGroupsByIds(ids) {
if (!ids?.length) return [];
const { data, error } = await supabase.from('patient_groups').select(PATIENT_GROUPS_SELECT_BRIEF).in('id', ids).order('nome', { ascending: true });
if (error) throw error;
return (data || []).map((g) => ({ id: g.id, name: g.nome }));
}
/**
* Substitui o grupo do paciente (1:1 sistema atual).
*/
export async function replacePatientGroup(patientId, groupId, { tenantId } = {}) {
if (!patientId) throw new Error('patientId obrigatório');
const { error: del } = await supabase.from('patient_group_patient').delete().eq('patient_id', patientId);
const tid = resolveTenantId(tenantId);
const { error: del } = await supabase.from('patient_group_patient').delete().eq('patient_id', patientId).eq('tenant_id', tid);
if (del) throw del;
if (!groupId) return;
const { error: ins } = await supabase
.from('patient_group_patient')
.insert({ patient_id: patientId, patient_group_id: groupId, tenant_id: tenantId });
const { error: ins } = await supabase.from('patient_group_patient').insert({ patient_id: patientId, patient_group_id: groupId, tenant_id: tid });
if (ins) throw ins;
}
// ─── Tags ──────────────────────────────────────────────────────────────────
export async function listTags({ tenantId, ownerId = null } = {}) {
const tid = resolveTenantId(tenantId);
let q = supabase.from('patient_tags').select(PATIENT_TAGS_SELECT).eq('tenant_id', tid);
if (ownerId) q = q.eq('owner_id', ownerId);
const { data, error } = await q;
if (error) throw error;
return (data || []).map((t) => ({ id: t.id, name: t.nome, color: t.cor }));
}
export async function listTagsByPatient(patientIds, { tenantId } = {}) {
if (!patientIds?.length) return [];
const tid = resolveTenantId(tenantId);
const { data, error } = await supabase.from('patient_patient_tag').select('patient_id, tag_id').eq('tenant_id', tid).in('patient_id', patientIds);
if (error) throw error;
return data || [];
}
/**
* Substitui as tags do paciente (lista). Limpa antigas do owner + inserta as novas.
* tags por ids (brief id+name+color). Usado em prontuário.
*/
export async function getTagsByIds(ids) {
if (!ids?.length) return [];
const { data, error } = await supabase.from('patient_tags').select(PATIENT_TAGS_SELECT_BRIEF).in('id', ids).order('nome', { ascending: true });
if (error) throw error;
return (data || []).map((t) => ({ id: t.id, name: t.nome, color: t.cor }));
}
/**
* Substitui as tags do paciente.
*/
export async function replacePatientTags(patientId, tagIds, { tenantId, ownerId } = {}) {
if (!patientId) throw new Error('patientId obrigatório');
if (!ownerId) throw new Error('ownerId obrigatório');
const { error: del } = await supabase
.from('patient_patient_tag')
.delete()
.eq('patient_id', patientId)
.eq('owner_id', ownerId);
const tid = resolveTenantId(tenantId);
const { error: del } = await supabase.from('patient_patient_tag').delete().eq('patient_id', patientId).eq('owner_id', ownerId).eq('tenant_id', tid);
if (del) throw del;
const clean = Array.from(new Set((tagIds || []).filter(Boolean)));
if (!clean.length) return;
const { error: ins } = await supabase
.from('patient_patient_tag')
.insert(clean.map((tag_id) => ({ owner_id: ownerId, patient_id: patientId, tag_id, tenant_id: tenantId })));
const { error: ins } = await supabase.from('patient_patient_tag').insert(clean.map((tag_id) => ({ owner_id: ownerId, patient_id: patientId, tag_id, tenant_id: tid })));
if (ins) throw ins;
}
// ─────────────────────────────────────────────────────────────────────────
// Sessões agregadas (V#8 — get_patient_session_counts RPC)
// Sessions (agenda_eventos) em escopo de paciente
// -----------------------------------------------------------------------------
// Cross-feature: chamadas pelo usePatientSessions. Migração futura: quando
// agendaRepository for ampliado pra exportar listByPatient, este módulo deixa
// de duplicar.
/**
* Lista sessões reais (agenda_eventos) do paciente, 100 mais recentes desc.
*/
export async function listSessionsByPatient(patientId, { tenantId } = {}) {
if (!patientId) return [];
const tid = resolveTenantId(tenantId);
const { data, error } = await supabase
.from('agenda_eventos')
.select(PATIENT_SESSIONS_SELECT)
.eq('tenant_id', tid)
.eq('patient_id', patientId)
.order('inicio_em', { ascending: false })
.limit(100);
if (error) throw error;
return data || [];
}
/**
* Cria sessão pro paciente. owner_id + tenant_id injetados pelo repository.
*
* payload aceita: inicio_em, fim_em, status?, modalidade?, tipo?, titulo?,
* titulo_custom?, observacoes?, recurrence_id?, recurrence_date?,
* determined_commitment_id?, price?
*/
export async function createPatientSession(patientId, payload) {
if (!patientId) throw new Error('patientId obrigatório');
if (!payload?.inicio_em || !payload?.fim_em) throw new Error('Início/fim obrigatórios');
const uid = await getUid();
const tid = resolveTenantId();
const row = {
patient_id: patientId,
owner_id: uid,
tenant_id: tid,
inicio_em: payload.inicio_em,
fim_em: payload.fim_em,
status: payload.status || 'agendado',
modalidade: payload.modalidade || 'presencial',
tipo: payload.tipo || 'sessao',
titulo: payload.titulo ? String(payload.titulo).trim() || null : null,
titulo_custom: payload.titulo_custom ? String(payload.titulo_custom).trim() || null : null,
observacoes: payload.observacoes ? String(payload.observacoes).trim() || null : null,
recurrence_id: payload.recurrence_id || null,
recurrence_date: payload.recurrence_date || null,
determined_commitment_id: payload.determined_commitment_id || null,
price: payload.price ?? null
};
const { data, error } = await supabase.from('agenda_eventos').insert([row]).select().single();
if (error) throw error;
return data;
}
/**
* Atualiza o status de uma sessão (mutation comum em prontuário).
*/
export async function updatePatientSessionStatus(sessionId, status, { tenantId } = {}) {
if (!sessionId) throw new Error('sessionId obrigatório');
const tid = resolveTenantId(tenantId);
const { error } = await supabase.from('agenda_eventos').update({ status }).eq('id', sessionId).eq('tenant_id', tid);
if (error) throw error;
}
/**
* Procura sessão materializada por (recurrence_id, recurrence_date).
* Usado pra decidir entre UPDATE ( existe) e INSERT (materializar virtual).
*/
export async function findSessionByRecurrence(recurrenceId, recurrenceDate) {
if (!recurrenceId || !recurrenceDate) return null;
const { data, error } = await supabase.from('agenda_eventos').select('id').eq('recurrence_id', recurrenceId).eq('recurrence_date', recurrenceDate).maybeSingle();
if (error) throw error;
return data || null;
}
// ─────────────────────────────────────────────────────────────────────────
// Financial Records em escopo de paciente
// -----------------------------------------------------------------------------
// Cross-feature: migra pra features/financeiro/services no Módulo 4.
/**
* Lista lançamentos do paciente (type=receita, 100 mais recentes).
*/
export async function listFinancialRecordsByPatient(patientId, { tenantId } = {}) {
if (!patientId) return [];
const tid = resolveTenantId(tenantId);
const { data, error } = await supabase
.from('financial_records')
.select(PATIENT_FINANCIAL_RECORDS_SELECT)
.eq('tenant_id', tid)
.eq('patient_id', patientId)
.eq('type', 'receita')
.order('created_at', { ascending: false })
.limit(100);
if (error) throw error;
return data || [];
}
/**
* Cria lançamento manual pro paciente (type=receita).
*/
export async function createFinancialRecord(patientId, payload) {
if (!patientId) throw new Error('patientId obrigatório');
if (!payload?.amount || Number.isNaN(Number(payload.amount))) {
throw new Error('Valor inválido');
}
const uid = await getUid();
const tid = resolveTenantId();
const row = {
patient_id: patientId,
owner_id: uid,
tenant_id: tid,
type: 'receita',
amount: Number(payload.amount),
due_date: payload.due_date || null,
description: payload.description ? String(payload.description).trim() || null : null,
payment_method: payload.payment_method || null,
paid_at: null
};
const { data, error } = await supabase.from('financial_records').insert([row]).select().single();
if (error) throw error;
return data;
}
/**
* Marca lançamento como pago (paid_at = now).
*/
export async function markFinancialRecordPaid(recordId, { tenantId } = {}) {
if (!recordId) throw new Error('recordId obrigatório');
const tid = resolveTenantId(tenantId);
const { error } = await supabase.from('financial_records').update({ paid_at: new Date().toISOString() }).eq('id', recordId).eq('tenant_id', tid);
if (error) throw error;
}
/**
* Reverte: remove paid_at (volta pra pendente).
*/
export async function markFinancialRecordUnpaid(recordId, { tenantId } = {}) {
if (!recordId) throw new Error('recordId obrigatório');
const tid = resolveTenantId(tenantId);
const { error } = await supabase.from('financial_records').update({ paid_at: null }).eq('id', recordId).eq('tenant_id', tid);
if (error) throw error;
}
// ─────────────────────────────────────────────────────────────────────────
// Documents em escopo de paciente (deleted_at IS NULL)
// -----------------------------------------------------------------------------
export async function listDocumentsByPatient(patientId, { tenantId } = {}) {
if (!patientId) return [];
const tid = resolveTenantId(tenantId);
const { data, error } = await supabase
.from('documents')
.select(PATIENT_DOCUMENTS_SELECT)
.eq('tenant_id', tid)
.eq('patient_id', patientId)
.is('deleted_at', null)
.order('created_at', { ascending: false })
.limit(200);
if (error) throw error;
return data || [];
}
// ─────────────────────────────────────────────────────────────────────────
// Messages (conversation_messages) em escopo de paciente
// -----------------------------------------------------------------------------
// Cross-feature: migra pra features/conversations no Módulo 6.
export async function listMessagesByPatient(patientId, { tenantId } = {}) {
if (!patientId) return [];
const tid = resolveTenantId(tenantId);
const { data, error } = await supabase
.from('conversation_messages')
.select(PATIENT_MESSAGES_SELECT)
.eq('tenant_id', tid)
.eq('patient_id', patientId)
.order('created_at', { ascending: false })
.limit(200);
if (error) throw error;
return data || [];
}
// ─────────────────────────────────────────────────────────────────────────
// Recurrence Rules em escopo de paciente
// -----------------------------------------------------------------------------
// Cross-feature: migra pra agendaRepository quando padronizado.
export async function listRecurrencesByPatient(patientId, { tenantId } = {}) {
if (!patientId) return [];
const tid = resolveTenantId(tenantId);
const { data, error } = await supabase
.from('recurrence_rules')
.select(PATIENT_RECURRENCE_RULES_SELECT)
.eq('tenant_id', tid)
.eq('patient_id', patientId)
.order('start_date', { ascending: false });
if (error) throw error;
return data || [];
}
/**
* Atualiza status da regra de recorrência (cancelado/ativo).
*/
export async function updateRecurrenceStatus(ruleId, status, { tenantId } = {}) {
if (!ruleId) throw new Error('ruleId obrigatório');
const tid = resolveTenantId(tenantId);
const { error } = await supabase.from('recurrence_rules').update({ status, updated_at: new Date().toISOString() }).eq('id', ruleId).eq('tenant_id', tid);
if (error) throw error;
}
// ─────────────────────────────────────────────────────────────────────────
// Support Contacts (patient_support_contacts)
// -----------------------------------------------------------------------------
export async function listSupportContactsByPatient(patientId, { tenantId } = {}) {
if (!patientId) return [];
const tid = resolveTenantId(tenantId);
const { data, error } = await supabase.from('patient_support_contacts').select(PATIENT_SUPPORT_CONTACTS_SELECT).eq('tenant_id', tid).eq('patient_id', patientId).order('is_primario', { ascending: false });
if (error) throw error;
return data || [];
}
/**
* Substitui todos os contatos do paciente (delete-then-insert).
* Pattern original do composable.
*
* @param {Array} contacts - rows mapeadas (sem patient_id/tenant_id/owner_id injetados aqui)
*/
export async function replacePatientSupportContacts(patientId, contacts, { tenantId, ownerId } = {}) {
if (!patientId) throw new Error('patientId obrigatório');
if (!ownerId) throw new Error('ownerId obrigatório');
const tid = resolveTenantId(tenantId);
const { error: del } = await supabase.from('patient_support_contacts').delete().eq('patient_id', patientId).eq('owner_id', ownerId).eq('tenant_id', tid);
if (del) throw del;
if (!contacts?.length) return;
const rows = contacts.map((c) => ({
...c,
patient_id: patientId,
owner_id: ownerId,
tenant_id: tid
}));
const { error: ins } = await supabase.from('patient_support_contacts').insert(rows);
if (ins) throw ins;
}
// ─────────────────────────────────────────────────────────────────────────
// Patient Intake Requests (convert flow)
// -----------------------------------------------------------------------------
// Cross-feature: usado pelos 2 callers de "Cadastros recebidos"
// (CadastrosRecebidosPage + MelissaCadastrosRecebidos). Centraliza a
// transição intake → patient pra eliminar duplicação (Fase 2 — Graphify hotspot).
/**
* Marca um patient_intake_request como convertido em paciente.
* Caller deve ter criado o paciente via createPatient() antes.
*
* @param {string} intakeId
* @param {string} patientId - id do paciente recém-criado
* @param {Object} [opts]
* @param {string} [opts.tenantId]
*/
export async function markIntakeConverted(intakeId, patientId, { tenantId } = {}) {
if (!intakeId) throw new Error('intakeId obrigatório.');
if (!patientId) throw new Error('patientId obrigatório.');
// tenant_id no patient_intake_requests pode ser nullable (intake público sem tenant)
// — só filtramos se passado explícito.
let q = supabase
.from('patient_intake_requests')
.update({
status: 'converted',
converted_patient_id: patientId,
updated_at: new Date().toISOString()
})
.eq('id', intakeId);
if (tenantId) {
const tid = resolveTenantId(tenantId);
q = q.eq('tenant_id', tid);
}
const { error } = await q;
if (error) throw error;
}
// ─────────────────────────────────────────────────────────────────────────
// Sessions count aggregate (RPC já existente)
// -----------------------------------------------------------------------------
/**
* Retorna contagem + última sessão por paciente. Usa RPC SECURITY DEFINER.
* @param {string[]} patientIds
* @returns {Array<{patient_id, session_count, last_session_at}>}
*/
export async function getSessionCounts(patientIds) {
if (!patientIds?.length) return [];
@@ -0,0 +1,83 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/patients/services/patientsSelects.js
|
| Fonte única de SELECTs do feature patients. Inclui também SELECTs de
| tabelas relacionadas (agenda_eventos, financial_records, etc) quando
| usadas SOB ESCOPO de paciente composables fazem listing por paciente
| e precisam de SELECTs estáveis.
|
| Quando os módulos M4 (Financeiro) / M6 (Notificações/Conversations)
| forem padronizados, os SELECTs cross-feature listados aqui podem migrar
| pra repositories nativos. Por ora, centralizar no patients é pragmático.
|--------------------------------------------------------------------------
*/
/** SELECT base de patients — usado em listPatients, getPatientById, etc. */
export const PATIENTS_SELECT_BASE = `
id, tenant_id, owner_id, user_id, responsible_member_id, therapist_member_id,
nome_completo, email_principal, email_alternativo, telefone, telefone_alternativo,
cpf, rg, data_nascimento, naturalidade, genero, estado_civil,
profissao, escolaridade, status,
cep, endereco, numero, bairro, complemento, cidade, estado, pais,
nome_responsavel, telefone_responsavel, cpf_responsavel, observacao_responsavel,
cobranca_no_responsavel,
onde_nos_conheceu, encaminhado_por, observacoes,
last_attended_at, created_at, updated_at,
risco_sinalizado_por, convenio_id, patient_scope
`
.replace(/\s+/g, ' ')
.trim();
// ─── Cross-feature reads em escopo de paciente ──────────────────────────────
/** Sessões (agenda_eventos) listadas por paciente. */
export const PATIENT_SESSIONS_SELECT = `
id, inicio_em, fim_em, status, modalidade, tipo, titulo, titulo_custom,
observacoes, patient_id, recurrence_id, recurrence_date
`
.replace(/\s+/g, ' ')
.trim();
/** Lançamentos financeiros do paciente. */
export const PATIENT_FINANCIAL_RECORDS_SELECT = `
id, type, amount, due_date, paid_at, description, payment_method, category, created_at
`
.replace(/\s+/g, ' ')
.trim();
/** Documentos do paciente — KPI/timeline (campos leves). */
export const PATIENT_DOCUMENTS_SELECT = `
id, tipo_documento, created_at, status_revisao, tamanho_bytes
`
.replace(/\s+/g, ' ')
.trim();
/** Mensagens recentes do paciente. */
export const PATIENT_MESSAGES_SELECT = `
id, body, direction, created_at, channel, kanban_status
`
.replace(/\s+/g, ' ')
.trim();
/** Regras de recorrência — composable usa todos os campos (UI rica). */
export const PATIENT_RECURRENCE_RULES_SELECT = '*';
/** Contatos de suporte (responsável, parente, etc) — formulário usa todos os campos. */
export const PATIENT_SUPPORT_CONTACTS_SELECT = '*';
// ─── Patient-native selects ─────────────────────────────────────────────────
/** Grupos de pacientes (estrutura completa) — listGroups. */
export const PATIENT_GROUPS_SELECT = 'id, nome, cor, is_system, owner_id, is_active';
/** Grupos — versão brief (id+nome) usada em getGroupsByIds. */
export const PATIENT_GROUPS_SELECT_BRIEF = 'id, nome';
/** Tags do paciente — completa com owner. */
export const PATIENT_TAGS_SELECT = 'id, nome, cor, owner_id';
/** Tags — brief com cor (display em pílulas). */
export const PATIENT_TAGS_SELECT_BRIEF = 'id, nome, cor';
@@ -0,0 +1,101 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/tenantship/composables/useTenantInvites.js
|
| Thin wrapper sobre tenantInvitesRepository. Segue
| blueprints/composable-blueprint.md (Tipo A thin wrapper default).
|--------------------------------------------------------------------------
*/
import { ref } from 'vue';
import { listForTenant, getByToken, sendInvite, revokeInvite, acceptInvite } from '@/features/tenantship/services/tenantInvitesRepository';
export function useTenantInvites() {
const rows = ref([]);
const loading = ref(false);
const error = ref('');
async function loadForTenant({ tenantId, includeInactive } = {}) {
loading.value = true;
error.value = '';
try {
rows.value = await listForTenant({ tenantId, includeInactive });
} catch (e) {
error.value = e?.message || 'Falha ao carregar convites.';
rows.value = [];
} finally {
loading.value = false;
}
}
async function send(payload) {
loading.value = true;
error.value = '';
try {
const created = await sendInvite(payload);
// Inserir/replace na lista local sem re-fetch
const idx = rows.value.findIndex((r) => r.id === created.id);
if (idx >= 0) rows.value[idx] = created;
else rows.value = [created, ...rows.value];
return created;
} catch (e) {
error.value = e?.message || 'Falha ao enviar convite.';
throw e;
} finally {
loading.value = false;
}
}
async function revoke(inviteId, opts) {
loading.value = true;
error.value = '';
try {
const updated = await revokeInvite(inviteId, opts);
const idx = rows.value.findIndex((r) => r.id === updated.id);
if (idx >= 0) rows.value[idx] = updated;
return updated;
} catch (e) {
error.value = e?.message || 'Falha ao revogar convite.';
throw e;
} finally {
loading.value = false;
}
}
/**
* STUB depende de RPC ainda não criada. Joga erro PT-BR explicando.
* Ver tenantInvitesRepository.acceptInvite.
*/
async function accept(token) {
loading.value = true;
error.value = '';
try {
return await acceptInvite(token);
} catch (e) {
error.value = e?.message || 'Falha ao aceitar convite.';
throw e;
} finally {
loading.value = false;
}
}
/**
* Read público pelo token (anonymous). Não atualiza `rows`
* usado no fluxo de aceitar (link externo).
*/
async function fetchByToken(token) {
loading.value = true;
error.value = '';
try {
return await getByToken(token);
} catch (e) {
error.value = e?.message || 'Falha ao carregar convite.';
return null;
} finally {
loading.value = false;
}
}
return { rows, loading, error, loadForTenant, send, revoke, accept, fetchByToken };
}
@@ -0,0 +1,93 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/tenantship/composables/useTenantMembers.js
|
| Thin wrapper sobre tenantMembersRepository. Segue
| blueprints/composable-blueprint.md (Tipo A thin wrapper default).
|--------------------------------------------------------------------------
*/
import { ref } from 'vue';
import { listForTenant, getById, updateMemberRole, updateMemberStatus, removeMember } from '@/features/tenantship/services/tenantMembersRepository';
export function useTenantMembers() {
const rows = ref([]);
const loading = ref(false);
const error = ref('');
async function loadForTenant({ tenantId, status } = {}) {
loading.value = true;
error.value = '';
try {
rows.value = await listForTenant({ tenantId, status });
} catch (e) {
error.value = e?.message || 'Falha ao carregar membros.';
rows.value = [];
} finally {
loading.value = false;
}
}
async function fetchById(memberId, opts) {
loading.value = true;
error.value = '';
try {
return await getById(memberId, opts);
} catch (e) {
error.value = e?.message || 'Falha ao carregar membro.';
return null;
} finally {
loading.value = false;
}
}
async function updateRole(memberId, role, opts) {
loading.value = true;
error.value = '';
try {
const updated = await updateMemberRole(memberId, role, opts);
const idx = rows.value.findIndex((r) => r.id === updated.id);
if (idx >= 0) rows.value[idx] = { ...rows.value[idx], role: updated.role };
return updated;
} catch (e) {
error.value = e?.message || 'Falha ao atualizar papel.';
throw e;
} finally {
loading.value = false;
}
}
async function updateStatus(memberId, status, opts) {
loading.value = true;
error.value = '';
try {
const updated = await updateMemberStatus(memberId, status, opts);
const idx = rows.value.findIndex((r) => r.id === updated.id);
if (idx >= 0) rows.value[idx] = { ...rows.value[idx], status: updated.status };
return updated;
} catch (e) {
error.value = e?.message || 'Falha ao atualizar status.';
throw e;
} finally {
loading.value = false;
}
}
async function remove(memberId, opts) {
loading.value = true;
error.value = '';
try {
await removeMember(memberId, opts);
rows.value = rows.value.filter((r) => r.id !== memberId);
return true;
} catch (e) {
error.value = e?.message || 'Falha ao remover membro.';
throw e;
} finally {
loading.value = false;
}
}
return { rows, loading, error, loadForTenant, fetchById, updateRole, updateStatus, remove };
}
@@ -0,0 +1,44 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/tenantship/services/_tenantGuards.js
|
| Guards compartilhados entre repositories do feature tenantship.
| Cópia canônica do pattern extraído de features/agenda/services/_tenantGuards.js
| (ver blueprints/repository-blueprint.md seção 3).
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
export function assertTenantId(tenantId) {
if (!tenantId || tenantId === 'null' || tenantId === 'undefined') {
throw new Error('Tenant ativo inválido. Selecione a clínica/tenant antes de operar.');
}
}
export async function getUid() {
const { data, error } = await supabase.auth.getUser();
if (error) throw error;
const uid = data?.user?.id;
if (!uid) throw new Error('Usuário não autenticado.');
return uid;
}
export function assertEmail(email) {
if (!email || typeof email !== 'string') {
throw new Error('E-mail inválido.');
}
const trimmed = email.trim().toLowerCase();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
throw new Error('E-mail em formato inválido.');
}
return trimmed;
}
export function assertRole(role) {
if (!['therapist', 'secretary'].includes(role)) {
throw new Error("Role inválida. Aceitos: 'therapist' ou 'secretary'.");
}
return role;
}
@@ -0,0 +1,148 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/tenantship/services/tenantInvitesRepository.js
|
| Repository de tenant_invites. Pure functions seguindo
| blueprints/repository-blueprint.md.
|
| A tabela tenant_invites existe no schema (tenants_multi_tenant.sql:100)
| com role CHECK ['therapist','secretary'], token uuid auto, expires_at default
| now()+7d, accepted_at/by, revoked_at/by.
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { assertTenantId, getUid, assertEmail, assertRole } from './_tenantGuards';
import { TENANT_INVITE_SELECT, flattenInviteRow } from './tenantInvitesSelects';
function resolveTenantId(tenantIdArg) {
const tenantStore = useTenantStore();
const tenantId = tenantIdArg || tenantStore.activeTenantId;
assertTenantId(tenantId);
return tenantId;
}
/**
* Lista convites de um tenant. Ordem: pending primeiro (mais recentes), depois resto.
*
* @param {Object} [opts]
* @param {string} [opts.tenantId]
* @param {boolean} [opts.includeInactive=false] - se true, inclui revoked/accepted/expired
*/
export async function listForTenant({ tenantId, includeInactive = false } = {}) {
const tid = resolveTenantId(tenantId);
let q = supabase.from('tenant_invites').select(TENANT_INVITE_SELECT).eq('tenant_id', tid);
if (!includeInactive) {
q = q.is('accepted_at', null).is('revoked_at', null).gt('expires_at', new Date().toISOString());
}
q = q.order('created_at', { ascending: false });
const { data, error } = await q;
if (error) throw error;
return (data || []).map(flattenInviteRow);
}
/**
* Busca convite por token. Usado no fluxo de aceitar (read público).
* NOTA: política RLS deve permitir SELECT por token sem auth a ser configurada.
*
* @param {string} token - uuid do convite
*/
export async function getByToken(token) {
if (!token) throw new Error('Token inválido.');
const { data, error } = await supabase.from('tenant_invites').select(TENANT_INVITE_SELECT).eq('token', token).maybeSingle();
if (error) throw error;
return data ? flattenInviteRow(data) : null;
}
/**
* Envia novo convite (cria row). Idempotente por (tenant_id, email) ativo
* se existe convite pending pro mesmo email, retorna o existente.
*
* TODO (Módulo 6 Notificações): após criar a row, disparar email/WhatsApp
* com o link `/aceitar-convite?token=${row.token}`. Hoje insere.
*
* @param {Object} payload
* @param {string} payload.email
* @param {'therapist'|'secretary'} payload.role
* @param {string} [payload.tenantId]
* @returns {Promise<Object>} row do convite (novo ou existente)
*/
export async function sendInvite({ email, role, tenantId } = {}) {
const tid = resolveTenantId(tenantId);
const uid = await getUid();
const safeEmail = assertEmail(email);
const safeRole = assertRole(role);
// Idempotência: se já existe pending pro mesmo (tenant, email), retorna existente
const { data: existing } = await supabase
.from('tenant_invites')
.select(TENANT_INVITE_SELECT)
.eq('tenant_id', tid)
.eq('email', safeEmail)
.is('accepted_at', null)
.is('revoked_at', null)
.gt('expires_at', new Date().toISOString())
.maybeSingle();
if (existing) return flattenInviteRow(existing);
const { data, error } = await supabase
.from('tenant_invites')
.insert([{ tenant_id: tid, email: safeEmail, role: safeRole, invited_by: uid }])
.select(TENANT_INVITE_SELECT)
.single();
if (error) throw error;
return flattenInviteRow(data);
}
/**
* Revoga convite (soft registra revoked_at + revoked_by, não deleta a row).
*
* @param {string} inviteId
* @param {Object} [opts]
* @param {string} [opts.tenantId]
*/
export async function revokeInvite(inviteId, { tenantId } = {}) {
if (!inviteId) throw new Error('ID inválido.');
const tid = resolveTenantId(tenantId);
const uid = await getUid();
const { data, error } = await supabase
.from('tenant_invites')
.update({ revoked_at: new Date().toISOString(), revoked_by: uid })
.eq('id', inviteId)
.eq('tenant_id', tid)
.select(TENANT_INVITE_SELECT)
.single();
if (error) throw error;
return flattenInviteRow(data);
}
/**
* Aceita convite cria tenant_members + marca accepted_at no invite (atomicamente via RPC).
*
* RPC `accept_tenant_invite(p_token uuid)` (migration 20260520000005):
* - SECURITY DEFINER (auth.uid() do caller é o aceitador)
* - Lock FOR UPDATE no invite (anti-race)
* - Idempotente: re-aceitar não cria duplicata
* - Retorna jsonb { ok, tenant_id, role } em sucesso
* - Throw com mensagem PT-BR em erros (revogado, expirado, aceito, sem sessão)
*
* @param {string} token - uuid do invite
* @returns {Promise<{ok: boolean, tenant_id: string, role: string}>}
*/
export async function acceptInvite(token) {
if (!token) throw new Error('Token inválido.');
const { data, error } = await supabase.rpc('accept_tenant_invite', { p_token: token });
if (error) throw error;
return data;
}
@@ -0,0 +1,38 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/tenantship/services/tenantInvitesSelects.js
|
| Fonte única do SELECT de tenant_invites.
|--------------------------------------------------------------------------
*/
export const TENANT_INVITE_SELECT = `
id, tenant_id, email, role, token,
invited_by, created_at, expires_at,
accepted_at, accepted_by,
revoked_at, revoked_by
`.trim();
/**
* Computa status derivado do invite (sem campo no banco calculado em runtime).
*
* @param {Object} row
* @returns {'pending'|'expired'|'accepted'|'revoked'}
*/
export function deriveInviteStatus(row) {
if (!row) return 'pending';
if (row.revoked_at) return 'revoked';
if (row.accepted_at) return 'accepted';
if (row.expires_at && new Date(row.expires_at) < new Date()) return 'expired';
return 'pending';
}
/**
* Achata a row adicionando o status derivado.
*/
export function flattenInviteRow(r) {
if (!r) return r;
return { ...r, status: deriveInviteStatus(r) };
}
@@ -0,0 +1,152 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/tenantship/services/tenantMembersRepository.js
|
| Repository de tenant_members. Pure functions seguindo
| blueprints/repository-blueprint.md.
|
| Tabela: tenant_members (id, tenant_id, user_id, role, status='active', created_at)
| View enriched: v_tenant_members_with_profiles (inclui full_name e email)
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { assertTenantId, getUid } from './_tenantGuards';
import { TENANT_MEMBER_PROFILE_SELECT, TENANT_MEMBER_RAW_SELECT, flattenMemberRow } from './tenantMembersSelects';
const VALID_ROLES = ['tenant_admin', 'therapist', 'secretary'];
const VALID_STATUSES = ['active', 'inactive', 'suspended'];
function resolveTenantId(tenantIdArg) {
const tenantStore = useTenantStore();
const tenantId = tenantIdArg || tenantStore.activeTenantId;
assertTenantId(tenantId);
return tenantId;
}
/**
* Lista membros de um tenant com profile (full_name, email).
*
* @param {Object} [opts]
* @param {string} [opts.tenantId]
* @param {string} [opts.status] - filtra por status (active/inactive/suspended)
*/
export async function listForTenant({ tenantId, status } = {}) {
const tid = resolveTenantId(tenantId);
let q = supabase.from('v_tenant_members_with_profiles').select(TENANT_MEMBER_PROFILE_SELECT).eq('tenant_id', tid).order('created_at', { ascending: false });
if (status) q = q.eq('status', status);
const { data, error } = await q;
if (error) throw error;
return (data || []).map(flattenMemberRow);
}
/**
* o tenant_member ativo do usuário LOGADO no tenant ativo (ou no tenantId passado).
* Retorna `{ id, tenant_id, role, status }` ou null.
*
* Útil pra criar entidades que precisam de `responsible_member_id` (ex: patients).
*
* @param {Object} [opts]
* @param {string} [opts.tenantId]
*/
export async function getMyActiveMember({ tenantId } = {}) {
const tid = resolveTenantId(tenantId);
const uid = await getUid();
const { data, error } = await supabase.from('tenant_members').select(TENANT_MEMBER_RAW_SELECT).eq('user_id', uid).eq('tenant_id', tid).eq('status', 'active').order('created_at', { ascending: false }).limit(1).maybeSingle();
if (error) throw error;
return data || null;
}
/**
* um member pela id (raw, sem profile join útil pra verificações rápidas).
*
* @param {string} memberId
* @param {Object} [opts]
* @param {string} [opts.tenantId]
*/
export async function getById(memberId, { tenantId } = {}) {
if (!memberId) throw new Error('ID inválido.');
const tid = resolveTenantId(tenantId);
const { data, error } = await supabase.from('tenant_members').select(TENANT_MEMBER_RAW_SELECT).eq('id', memberId).eq('tenant_id', tid).maybeSingle();
if (error) throw error;
return data || null;
}
/**
* Atualiza role de um member. Tenant_admin não pode rebaixar a si mesmo
* a UI deve bloquear, mas RLS no banco também deve garantir.
*
* @param {string} memberId
* @param {'tenant_admin'|'therapist'|'secretary'} role
* @param {Object} [opts]
* @param {string} [opts.tenantId]
*/
export async function updateMemberRole(memberId, role, { tenantId } = {}) {
if (!memberId) throw new Error('ID inválido.');
if (!VALID_ROLES.includes(role)) {
throw new Error(`Role inválida. Aceitos: ${VALID_ROLES.join(', ')}.`);
}
const tid = resolveTenantId(tenantId);
const { data, error } = await supabase.from('tenant_members').update({ role }).eq('id', memberId).eq('tenant_id', tid).select(TENANT_MEMBER_RAW_SELECT).single();
if (error) throw error;
return data;
}
/**
* Atualiza status (ativar/inativar/suspender) sem remover.
*
* @param {string} memberId
* @param {'active'|'inactive'|'suspended'} status
* @param {Object} [opts]
* @param {string} [opts.tenantId]
*/
export async function updateMemberStatus(memberId, status, { tenantId } = {}) {
if (!memberId) throw new Error('ID inválido.');
if (!VALID_STATUSES.includes(status)) {
throw new Error(`Status inválido. Aceitos: ${VALID_STATUSES.join(', ')}.`);
}
const tid = resolveTenantId(tenantId);
const { data, error } = await supabase.from('tenant_members').update({ status }).eq('id', memberId).eq('tenant_id', tid).select(TENANT_MEMBER_RAW_SELECT).single();
if (error) throw error;
return data;
}
/**
* Remove member (hard delete da tabela tenant_members).
* Os dados do user em outras tabelas (pacientes, agenda, etc) PERMANECEM
* a remoção é apenas do vínculo membership.
*
* @param {string} memberId
* @param {Object} [opts]
* @param {string} [opts.tenantId]
*/
export async function removeMember(memberId, { tenantId } = {}) {
if (!memberId) throw new Error('ID inválido.');
const tid = resolveTenantId(tenantId);
// Defesa: nunca permitir o próprio user se remover via essa função
// (causa lockout). UI deve bloquear; aqui só sanity-check.
const uid = await getUid();
const target = await getById(memberId, { tenantId: tid });
if (target && target.user_id === uid) {
throw new Error('Não é permitido remover a si mesmo. Peça a outro admin do tenant.');
}
const { error } = await supabase.from('tenant_members').delete().eq('id', memberId).eq('tenant_id', tid);
if (error) throw error;
return true;
}
@@ -0,0 +1,37 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/tenantship/services/tenantMembersSelects.js
|
| SELECTs canônicos pra tenant_members + view v_tenant_members_with_profiles.
|--------------------------------------------------------------------------
*/
/**
* SELECT direto da tabela tenant_members (sem join).
* Use quando precisa dos campos crus.
*/
export const TENANT_MEMBER_RAW_SELECT = `
id, tenant_id, user_id, role, status, created_at
`.trim();
/**
* SELECT enriched via view v_tenant_members_with_profiles.
* Inclui full_name e email (joins com profiles e auth.users).
* Use pra listagens da UI.
*/
export const TENANT_MEMBER_PROFILE_SELECT = `
tenant_member_id, tenant_id, user_id, role, status, created_at,
full_name, email
`.trim();
/**
* Normaliza row da view pra usar `id` no lugar de `tenant_member_id`.
*/
export function flattenMemberRow(r) {
if (!r) return r;
const out = { ...r };
if (r.tenant_member_id && !r.id) out.id = r.tenant_member_id;
return out;
}
+10 -1
View File
@@ -42,6 +42,7 @@ function toggleAjuda() {
}
import { useUserSettingsPersistence } from '@/composables/useUserSettingsPersistence';
import { useTopbarDevMenuExtras } from '@/composables/useTopbarDevMenuExtras';
import { applyThemeEngine } from '@/theme/theme.options';
import { fetchAllNotices } from '@/features/notices/noticeService';
@@ -415,6 +416,14 @@ const planMenuModel = computed(() => {
return [header, subInfo, { separator: true }, ...items];
});
// Extras do menu DEV (layout switcher + atalhos M1)
const { devExtrasModel } = useTopbarDevMenuExtras();
const combinedDevMenuModel = computed(() => [
...planMenuModel.value,
{ separator: true },
...devExtrasModel.value
]);
async function openPlanMenu(event) {
if (!showPlanDevMenu.value) return;
@@ -598,7 +607,7 @@ onMounted(async () => {
<i class="pi pi-sliders-h" />
</Button>
<Menu ref="planMenu" :model="planMenuModel" popup appendTo="body" :baseZIndex="3000" />
<Menu ref="planMenu" :model="combinedDevMenuModel" popup appendTo="body" :baseZIndex="3000" />
<!-- Notificações -->
<div class="relative">
+12 -2
View File
@@ -655,9 +655,19 @@ const fcOptions = computed(() => ({
// REAL sem cobrança ainda ('none'). Virtuais com 'none' (saldo,
// sem pacote, ou virtuais limpas) ficam SEM badge só virtuais
// herdando 'pending' de pacote upfront mostram o badge.
const wantBadge = isSessao && ext.patient_id && ext.paymentState !== 'paid' && (
// Sessão encerrada (cancelado/faltou) NÃO ganha badge mesmo com
// state='none' (records cancelled filtrados) sessão não rolou,
// cobrança nova não cabe. Multa pendente vem com state='pending'
// e aí entra pelo ramo anterior, ok.
// Sessão com pacote ativo (saldo OU upfront) com state='none' também
// NÃO ganha badge billing é via pacote, não cobrança avulsa solta.
// Pra saldo: aguarda "Usar"; pra upfront: já coberta pelo contrato.
const statusLower = String(ext.status || '').toLowerCase();
const sessaoEncerrada = statusLower === 'cancelado' || statusLower === 'cancelada' || statusLower === 'faltou';
const hasPacoteTied = !!ext.contract;
const wantBadge = isSessao && ext.patient_id && ext.paymentState !== 'paid' && !sessaoEncerrada && (
ext.paymentState === 'pending' || ext.paymentState === 'overdue' ||
(!ext.is_occurrence && (ext.paymentState === 'none' || !ext.paymentState))
(!ext.is_occurrence && (ext.paymentState === 'none' || !ext.paymentState) && !hasPacoteTied)
);
if (wantBadge) {
payBadgeHtml = `<span class="mc-fc-event__paybadge" title="Cobrança pendente"><i class="pi pi-dollar"></i></span>`;
@@ -17,6 +17,8 @@ import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
// Fase 2 (Graphify hotspot): convertToPatient duplicado em 2 pages extração pro repository.
import { createPatient, markIntakeConverted } from '@/features/patients/services/patientsRepository';
import { brToISO, isoToBR } from '@/utils/dateBR';
// Dialog/Textarea/Button auto-imported via PrimeVueResolver
@@ -429,16 +431,12 @@ async function convertToPatient() {
if (patientPayload[k] === undefined) delete patientPayload[k];
});
const { data: created, error: insErr } = await supabase.from('patients')
.insert(patientPayload).select('id').single();
if (insErr) throw insErr;
// Repository chamadas (Fase 2 convertToPatient de-dup).
const created = await createPatient(patientPayload);
const patientId = created?.id;
if (!patientId) throw new Error('Falha ao obter ID do paciente.');
const { error: upErr } = await supabase.from('patient_intake_requests')
.update({ status: 'converted', converted_patient_id: patientId, updated_at: new Date().toISOString() })
.eq('id', item.id);
if (upErr) throw upErr;
await markIntakeConverted(item.id, patientId);
toast.add({ severity: 'success', summary: 'Convertido em paciente', life: 2500 });
closeDlg();
+73 -11
View File
@@ -25,10 +25,12 @@ const props = defineProps({
const emit = defineEmits([
'close',
'agendar',
'concluir',
'faltou',
'cancelar',
'remarcar',
'revogar-antecipacao',
'edit-sessao', // botão dedicado ao lado das horas AgendaEventDialog
'edit-paciente', // botão "Editar" do grupo Outras opções PatientCadastroDialog
'abrir-prontuario',
@@ -134,6 +136,20 @@ const isSessaoComPaciente = computed(
() => ev.value.tipo === 'sessao' && (ev.value.patient_id || ev.value.pacienteNome)
);
// Sessão "encerrada" não rolou (cancelada ou paciente faltou).
// Bloqueia: editar sessão (dados não cabem mais) + transições de status
// pra realizado/faltou/cancelar (não faz sentido marcar um cancelado como
// "faltou"). Mantém SÓ "Agendada" funcional como caminho de recuperação
// caso tenha sido marcado por engano.
const isSessaoEncerrada = computed(() => statusSlug.value === 'cancelado' || statusSlug.value === 'faltou');
// "Antecipação ativa": sessão ainda agendada (não rolou) com cobrança paga.
// O paid não veio de Realizada veio de "Antecipar pagamento" que adianta
// o pagamento. Nesse estado, o botão "Antecipar pagamento" vira "Revogar
// pagamento" pra desfazer caso o user tenha errado. Após Realizada, o paid
// vira pagamento normal da sessão (estorno via /financeiro).
const isAntecipacaoAtiva = computed(() => statusSlug.value === 'agendado' && ev.value.paymentState === 'paid');
// Estado de pagamento vem anotado pelo useMelissaAgenda via bulk-query
// em financial_records. 'paid' | 'pending' | 'none'. Renderiza linha
// curta abaixo do horário pra sessão com paciente (espelha os 3 canais
@@ -159,11 +175,12 @@ const paymentIcon = computed(() => {
});
const paymentLabel = computed(() => {
const state = ev.value.paymentState;
// Pra estado 'paid', usar o VALOR REAL pago (paymentAmount, vem do
// financial_record). Em pacote upfront, é o package_price total
// o evento.price pode ter sido editado depois e divergir. Em outros
// estados, fallback pro price/insurance_value do evento.
const valor = state === 'paid' && ev.value.paymentAmount != null
// Pra estados 'paid' e 'pending' usar o VALOR REAL do record (paymentAmount).
// Necessário pra cobrir caso de multa: original cancelled R$ 200 + multa
// pending R$ 30 state='pending' mas paymentAmount=30 (não R$ 200 do ev.price).
// Em pacote upfront paid, é o package_price total. Só cai no fallback de
// ev.price/insurance_value quando state='none' (sem record ativo).
const valor = (state === 'paid' || state === 'pending') && ev.value.paymentAmount != null
? ev.value.paymentAmount
: (ev.value.price ?? ev.value.insurance_value);
const valorFmt = (valor != null && !Number.isNaN(Number(valor)))
@@ -175,7 +192,18 @@ const paymentLabel = computed(() => {
if (state === 'pending') {
return valorFmt ? `A receber ${valorFmt} (cobrança pendente)` : 'Cobrança pendente';
}
// 'none' sessão sem cobrança gerada ainda
// 'none' sessão sem cobrança ativa
// Quando status='cancelado'/'faltou' + sem record ativo, deixa claro
// que não há cobrança em aberto (em vez de "A cobrar R$ X" enganoso).
const slug = String(ev.value.status || '').toLowerCase();
if (slug === 'cancelado' || slug === 'cancelada') return 'Sessão cancelada · sem cobrança ativa';
if (slug === 'faltou') return 'Sessão não realizada · sem cobrança ativa';
// Pacote tied não cabe "A cobrar R$ X" solto porque o billing é via
// pacote (saldo: aguarda Usar; upfront: já está coberto pelo contrato).
// Contract row já mostra o status do pacote.
const cInfo = ev.value.contract;
if (cInfo?.style === 'saldo') return 'Aguardando uso do pacote';
if (cInfo?.style === 'upfront') return 'Coberta pelo pacote (upfront)';
return valorFmt ? `A cobrar ${valorFmt}` : 'Cobrança ainda não gerada';
});
@@ -236,6 +264,7 @@ function modalidadeIcon(mod) {
</span>
<div class="evento-row__edit-stack">
<button
v-if="!isSessaoEncerrada"
type="button"
class="evento-row__edit evento-row__edit--primary"
:disabled="busy"
@@ -283,7 +312,7 @@ function modalidadeIcon(mod) {
sessions_used) gerar fatura solta aqui criaria
cobrança duplicada e dessincronizaria o saldo. -->
<button
v-if="paymentVariant === 'none' && !ev.is_occurrence && !contractInfo"
v-if="paymentVariant === 'none' && !ev.is_occurrence && !contractInfo && statusSlug !== 'cancelado' && statusSlug !== 'faltou'"
type="button"
class="evento-row__pay-action"
:disabled="busy"
@@ -358,10 +387,20 @@ function modalidadeIcon(mod) {
<section v-if="isSessaoComPaciente" class="evento-actions__section">
<div class="evento-actions__label">Marcar sessão como:</div>
<div class="evento-actions__group">
<button
class="evento-act evento-act--info"
:class="{ 'is-current': statusSlug === 'agendado' || statusSlug === '' }"
:disabled="busy"
@click="emit('agendar')"
>
<i class="pi pi-calendar" />
<span class="evento-act__label">Agendada</span>
</button>
<button
class="evento-act evento-act--ok"
:class="{ 'is-current': statusSlug === 'realizado' }"
:disabled="busy"
:disabled="busy || isSessaoEncerrada"
v-tooltip.top="isSessaoEncerrada ? 'Sessão encerrada — use Agendada pra reativar antes' : null"
@click="emit('concluir')"
>
<i class="pi pi-check-circle" />
@@ -370,7 +409,8 @@ function modalidadeIcon(mod) {
<button
class="evento-act evento-act--warn"
:class="{ 'is-current': statusSlug === 'faltou' }"
:disabled="busy"
:disabled="busy || isSessaoEncerrada"
v-tooltip.top="isSessaoEncerrada ? 'Sessão encerrada — use Agendada pra reativar antes' : null"
@click="emit('faltou')"
>
<i class="pi pi-user-minus" />
@@ -379,7 +419,8 @@ function modalidadeIcon(mod) {
<button
class="evento-act"
:class="{ 'is-current': statusSlug === 'remarcar' || statusSlug === 'remarcado' }"
:disabled="busy"
:disabled="busy || isSessaoEncerrada"
v-tooltip.top="isSessaoEncerrada ? 'Sessão encerrada — use Agendada pra reativar antes' : null"
@click="emit('remarcar')"
>
<i class="pi pi-calendar-clock" />
@@ -388,7 +429,8 @@ function modalidadeIcon(mod) {
<button
class="evento-act evento-act--danger"
:class="{ 'is-current': statusSlug === 'cancelado' }"
:disabled="busy"
:disabled="busy || isSessaoEncerrada"
v-tooltip.top="isSessaoEncerrada ? 'Sessão encerrada — use Agendada pra reativar antes' : null"
@click="emit('cancelar')"
>
<i class="pi pi-ban" />
@@ -456,6 +498,7 @@ function modalidadeIcon(mod) {
<span class="evento-act__label">Lançamentos</span>
</button>
<button
v-if="!isAntecipacaoAtiva"
class="evento-act"
:disabled="busy"
v-tooltip.top="'Paciente quer pagar antes da sessão (pacote saldo)'"
@@ -464,6 +507,16 @@ function modalidadeIcon(mod) {
<i class="pi pi-money-bill" />
<span class="evento-act__label">Antecipar pagamento</span>
</button>
<button
v-else
class="evento-act evento-act--danger"
:disabled="busy"
v-tooltip.top="'Desfazer o pagamento antecipado — cancela o lançamento e libera pra antecipar de novo'"
@click="emit('revogar-antecipacao')"
>
<i class="pi pi-times-circle" />
<span class="evento-act__label">Revogar pagamento</span>
</button>
</div>
</section>
@@ -905,6 +958,10 @@ html.app-dark .evento-row__pay-action--revogar {
opacity: 0.45;
cursor: not-allowed;
}
.evento-act--info:hover:not(:disabled) {
color: rgb(56, 189, 248);
background: rgba(56, 189, 248, 0.10);
}
.evento-act--ok:hover:not(:disabled) {
color: rgb(16, 185, 129);
background: rgba(16, 185, 129, 0.15);
@@ -925,6 +982,11 @@ html.app-dark .evento-row__pay-action--revogar {
background: rgba(255, 255, 255, 0.12);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.25);
}
.evento-act--info.is-current {
color: rgb(56, 189, 248);
background: rgba(56, 189, 248, 0.18);
box-shadow: inset 0 0 0 1px rgba(56, 189, 248, 0.55);
}
.evento-act--ok.is-current {
color: rgb(16, 185, 129);
background: rgba(16, 185, 129, 0.18);
+131 -5
View File
@@ -106,6 +106,7 @@ import { useNotifications } from '@/composables/useNotifications';
import { useNotificationStore } from '@/stores/notificationStore';
import { useAjuda } from '@/composables/useAjuda';
import { useTopbarPlanMenu } from '@/composables/useTopbarPlanMenu';
import { useTopbarDevMenuExtras } from '@/composables/useTopbarDevMenuExtras';
// Pacientes + eventos do dia.
//
@@ -665,6 +666,33 @@ function fecharEvento() {
eventoBusy.value = false;
}
// Mantém eventoSelecionado em sincronia com a lista reativa M.eventos.
// Sem isso, eventoSelecionado.value é snapshot do clique e não acompanha
// updates do _paymentStateMap pós refetch (caso típico: revogar/antecipar
// pagamento record muda mas popover continuava mostrando estado antigo).
// Lookup em 2 etapas:
// 1) match por id (caso comum evento real persistente)
// 2) match por recurrence_id+recurrence_date (caso virtual materializada:
// id muda de `rec::rule::date` pra uuid real após antecipar/Usar/etc).
// Sem o 2o lookup, popover ficava preso na versão virtual após o evento
// virar real exemplo: revoguei antecipação, "Usar" não aparecia porque
// ev ainda era a versão virtual stale.
watch(() => M.eventos.value, (novos) => {
const sel = eventoSelecionado.value;
if (!sel?.id) return;
let fresh = novos.find((e) => e.id === sel.id);
if (!fresh && sel.recurrence_id && sel.recurrence_date) {
fresh = novos.find((e) =>
!e.is_occurrence &&
e.recurrence_id === sel.recurrence_id &&
e.recurrence_date === sel.recurrence_date
);
}
if (fresh && fresh !== sel) {
eventoSelecionado.value = fresh;
}
}, { flush: 'post' });
// Actions do MelissaEventoPanel
// Fase 5 (2026-05-14): TODOS os status (realizado/faltou/cancelado/etc)
// passam por M.onUpdateSeriesEvent que abre o AgendaStatusChangeConfirmDialog
@@ -708,6 +736,7 @@ async function updateEventoStatus(novoStatus, msgSucesso) {
}
}
function onAgendar() { updateEventoStatus('agendado', 'Sessão marcada como agendada'); }
function onConcluir() { updateEventoStatus('realizado', 'Sessão marcada como realizada'); }
function onFaltou() { updateEventoStatus('faltou', 'Marcada como falta'); }
function onCancelar() { updateEventoStatus('cancelado', 'Evento cancelado'); }
@@ -762,7 +791,13 @@ async function confirmAnteciparPagamento() {
anteciparBusy.value = true;
try {
const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
const ownerId = ev.owner_id || ev.terapeuta_id || null;
// ownerId: ev.owner_id é prioridade. Fallback pra M.ownerId (composable
// que conhece o user logado). Pra virtuais ou snapshots stale, ev pode
// não trazer owner_id; M.ownerId é fonte autoritativa.
const ownerId = ev.owner_id || ev.terapeuta_id || ev._raw?.owner_id || M?.ownerId?.value || null;
if (!ownerId) {
throw new Error('Não foi possível identificar o terapeuta da sessão.');
}
const settlement = anteciparMethod.value;
const amount = Number(ev.price) || 0;
const dueIso = ev.inicio_em ? new Date(ev.inicio_em).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
@@ -810,12 +845,17 @@ async function confirmAnteciparPagamento() {
}
}
// 2) Verifica se já tem financial_record vinculado
// 2) Verifica se já tem financial_record vinculado.
// IMPORTANTE: filtra cancelled caso típico após revogar a antecipação
// (record vira cancelled) e user re-antecipa. Sem o filtro, o handler
// reusava o record cancelled atualizando pra paid, mantendo notes
// da revogação no audit trail (confuso).
const { data: existRec } = await supabase
.from('financial_records')
.select('id, status')
.eq('agenda_evento_id', eventoId)
.is('deleted_at', null)
.neq('status', 'cancelled')
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
@@ -868,9 +908,10 @@ async function confirmAnteciparPagamento() {
life: 4000
});
anteciparDialogOpen.value = false;
M.refetch();
await M.refetch();
refetchEventosHoje();
fecharEvento();
// Não fecha o popover watch em eventos sincroniza o ev pro novo
// estado (paymentState='paid' agora). Botão alterna pra "Revogar".
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao antecipar pagamento.', life: 5000 });
} finally {
@@ -878,6 +919,81 @@ async function confirmAnteciparPagamento() {
}
}
// Revogar antecipação de pagamento (C12): desfaz o `onAnteciparPagamento`.
// Cancela o record paid + nota de auditoria em notes. Só disponível pra
// sessão em status='agendado' (após Realizada o paid vira pagamento normal
// e estorno é via /financeiro).
async function onRevogarAntecipacao() {
const ev = eventoSelecionado.value;
if (!ev?.id || eventoBusy.value) return;
const isVirtualId = typeof ev.id === 'string' && ev.id.startsWith('rec::');
if (isVirtualId) {
toast.add({ severity: 'warn', summary: 'Sessão virtual', detail: 'Sessão sem antecipação ativa.', life: 3000 });
return;
}
// Confirma com user paid é sensível
const ok = await new Promise((resolve) => {
confirm.require({
message: 'Revogar o pagamento antecipado desta sessão? O lançamento financeiro será cancelado e poderá antecipar de novo.',
header: 'Revogar antecipação?',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Revogar pagamento',
rejectLabel: 'Cancelar',
acceptClass: 'p-button-danger',
accept: () => resolve(true),
reject: () => resolve(false),
onHide: () => resolve(false)
});
});
if (!ok) return;
eventoBusy.value = true;
try {
// Acha o paid record vinculado
const { data: paidRec, error: fetchErr } = await supabase
.from('financial_records')
.select('id, notes, payment_method, final_amount, amount')
.eq('agenda_evento_id', ev.id)
.eq('status', 'paid')
.order('paid_at', { ascending: false })
.limit(1)
.maybeSingle();
if (fetchErr) throw fetchErr;
if (!paidRec?.id) {
toast.add({ severity: 'info', summary: 'Nada a revogar', detail: 'Esta sessão não tem pagamento antecipado.', life: 3500 });
return;
}
const today = new Date().toISOString().slice(0, 10);
const reason = `Antecipação revogada em ${today}`;
const noteEntry = `[${today}] ${reason}`;
const noteText = paidRec.notes ? `${paidRec.notes}\n${noteEntry}` : noteEntry;
const { error: cancelErr } = await supabase
.from('financial_records')
.update({
status: 'cancelled',
notes: noteText,
updated_at: new Date().toISOString()
})
.eq('id', paidRec.id);
if (cancelErr) throw cancelErr;
toast.add({
severity: 'success',
summary: 'Antecipação revogada',
detail: `Cobrança de R$ ${Number(paidRec.final_amount || paidRec.amount || 0).toFixed(2).replace('.', ',')} cancelada.`,
life: 4000
});
await M.refetch();
refetchEventosHoje();
// Não fecha popover watch em eventos sincroniza paymentState='none'.
// Botão alterna de volta pra "Antecipar pagamento".
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao revogar antecipação.', life: 5000 });
} finally {
eventoBusy.value = false;
}
}
async function onVerLancamentos() {
const ev = eventoSelecionado.value;
if (!ev?.id) return;
@@ -1704,6 +1820,14 @@ const {
openPlanMenu
} = useTopbarPlanMenu();
// Extras DEV (layout switcher + atalhos M1) que aparecem no MESMO menu do botão sliders.
const { devExtrasModel } = useTopbarDevMenuExtras();
const combinedDevMenuModel = computed(() => [
...planMenuModel.value,
{ separator: true },
...devExtrasModel.value
]);
// Recebíveis derivados de agenda_eventos.{price,billed}: aproximação MVP.
// `billed=true` é o flag de "marcado como pago/cobrado" no agenda não
// é a fonte de verdade financeira (essa é financial_records.status='paid'),
@@ -2490,6 +2614,7 @@ function onKeydown(e) {
:evento="eventoSelecionado"
:busy="eventoBusy"
@close="fecharEvento"
@agendar="onAgendar"
@concluir="onConcluir"
@faltou="onFaltou"
@cancelar="onCancelar"
@@ -2502,6 +2627,7 @@ function onKeydown(e) {
@revogar-sessao="onRevogarSessao"
@ver-lancamentos="onVerLancamentos"
@antecipar-pagamento="onAnteciparPagamento"
@revogar-antecipacao="onRevogarAntecipacao"
@edit-paciente="onEditPaciente"
@abrir-prontuario="onAbrirProntuario"
@whatsapp="onWhatsapp"
@@ -3110,7 +3236,7 @@ function onKeydown(e) {
<NotificationDrawer />
<!-- Plan menu DEV popup ancorado no botão da topbar -->
<Menu ref="planMenu" :model="planMenuModel" popup appendTo="body" :baseZIndex="3000" />
<Menu ref="planMenu" :model="combinedDevMenuModel" popup appendTo="body" :baseZIndex="3000" />
</div>
</template>
@@ -145,6 +145,10 @@ function normalizeForMelissa(r, paymentStateMap = null, paymentAmountMap = null,
tipo,
status: r.status || (isOccurrence ? 'agendado' : ''),
titulo: r.titulo || r.titulo_custom || '',
owner_id: r.owner_id ?? null,
tenant_id: r.tenant_id ?? null,
terapeuta_id: r.terapeuta_id ?? null,
billing_contract_id: r.billing_contract_id ?? null,
patient_id: r.patient_id ?? r.paciente_id ?? null,
pacienteNome: pacNome,
modalidade: r.modalidade || '',
@@ -853,6 +857,7 @@ function _buildHandlers(deps) {
dialogOpen, dialogEventRow, dialogStartISO, dialogEndISO,
occDialogOpen, occDialogEventRow, occDialogStartISO, occDialogEndISO,
_openStatusDialog,
_reloadRange,
bloqueioCobrindo,
dialogBlockOverlap
} = deps;
@@ -1215,8 +1220,12 @@ function _buildHandlers(deps) {
async function onUpdateSeriesEvent(arg) {
const { id, status, recurrence_date, inicio_em, fim_em, is_virtual, row: callerRow, onReject } = arg || {};
try {
const needsDialog = ['realizado', 'faltou', 'cancelado'].includes(status);
if (!needsDialog) {
// realizado/faltou/cancelado abrem dialog forward.
// agendado (reverse transition) abre dialog se houver artefatos
// pendentes a desfazer: cobrança pendente, multa, saldo consumido.
const isForward = ['realizado', 'faltou', 'cancelado'].includes(status);
const isReverse = status === 'agendado';
if (!isForward && !isReverse) {
await _applyStatusUpdateOnly({ id, status, recurrence_date, inicio_em, fim_em, is_virtual, row: callerRow });
return;
}
@@ -1238,7 +1247,9 @@ function _buildHandlers(deps) {
billingContract: ctx.billingContract,
billingContractStyle: ctx.billingContract?.charging_style ?? null,
pendingRecord: ctx.pendingRecord,
sessionPrice: Number(row.price ?? 0)
existingPaidRecord: ctx.existingPaidRecord || null,
sessionPrice: Number(row.price ?? 0),
reverseArtifacts: ctx.reverseArtifacts || null
});
if (!decision) {
// User cancelou — reverte status no form do AgendaEventDialog
@@ -1258,6 +1269,12 @@ function _buildHandlers(deps) {
ctx,
decision
});
// 3) Reload do range pra propagar paymentState/Amount atualizados
// pro FullCalendar + popover. Sem isso, badge $ e label "A receber"
// ficam stale até trocar de view ou F5. Caso típico: faltou+multa
// mostra R$ original (cancelled) em vez do R$ multa novo.
await _reloadRange();
} catch (e) {
toast.add({ severity: 'warn', summary: 'Erro', detail: e?.message || 'Falha ao atualizar status.', life: 3000 });
}
@@ -1403,6 +1420,67 @@ function _buildHandlers(deps) {
}
}
// 3b) Paid record pré-existente (caso C12: antecipar pagamento).
// Quando user antecipou paga ANTES de marcar Realizada, o record paid
// já existe ao tempo do status change. Dialog precisa saber pra:
// - Não oferecer "Gerar cobrança nova" (geraria duplicidade)
// - Ainda incrementar sessions_used (a sessão consome saldo do pacote)
if (eventoId) {
try {
const { data } = await supabase
.from('financial_records')
.select('id, status, amount, final_amount, paid_at, payment_method')
.eq('agenda_evento_id', eventoId)
.eq('status', 'paid')
.order('paid_at', { ascending: false })
.limit(1)
.maybeSingle();
ctx.existingPaidRecord = data ?? null;
} catch (e) {
console.warn('[Fase5] erro existing paid record:', e?.message);
}
}
// 4) Reverse transition (status novo='agendado'): carrega artefatos
// a desfazer — current status + ALL records ativos + saldo consumido.
// Sem isso, voltar pra agendado deixa multa/record/saldo órfão.
if (status === 'agendado' && eventoId) {
ctx.reverseArtifacts = {
previousStatus: row?.status || null,
activeRecords: [],
saldoConsumed: false
};
try {
// Status atual do DB (fonte autoritativa, row pode estar stale)
const { data: evRow } = await supabase
.from('agenda_eventos')
.select('status, billing_contract_id')
.eq('id', eventoId)
.maybeSingle();
if (evRow) {
ctx.reverseArtifacts.previousStatus = evRow.status;
}
// Todos records NÃO cancelled vinculados (pending + overdue + paid)
const { data: recs } = await supabase
.from('financial_records')
.select('id, status, amount, final_amount, description, paid_at, payment_method')
.eq('agenda_evento_id', eventoId)
.neq('status', 'cancelled')
.order('created_at', { ascending: false });
ctx.reverseArtifacts.activeRecords = recs || [];
// Detecta saldo consumido: evento pertence a pacote saldo e
// está em status que tipicamente consome (realizado, ou faltou/
// cancelado se default_consume_on_miss=true e foi aplicado).
// Heurística simples: se billing_contract_id está set + style=saldo
// + status anterior ≠ 'agendado', assume consumido. Se for falso
// positivo, user pode escolher "não devolver" no dialog.
const wasInActiveState = evRow?.billing_contract_id && evRow.status && evRow.status !== 'agendado';
ctx.reverseArtifacts.saldoConsumed = wasInActiveState && ctx.billingContract?.charging_style === 'saldo';
} catch (e) {
console.warn('[Fase5] erro reverse artifacts:', e?.message);
}
}
return ctx;
}
@@ -1411,6 +1489,7 @@ function _buildHandlers(deps) {
function _needsConfirmDialog(status, ctx) {
const isFaltouOrCancel = status === 'faltou' || status === 'cancelado';
const isRealizado = status === 'realizado';
const isAgendado = status === 'agendado';
const hasRegraComCobranca = ctx.regraExcecao && ctx.regraExcecao.charge_mode !== 'none';
const isPacoteSaldo = ctx.billingContract?.charging_style === 'saldo';
const isPacoteUpfront = ctx.billingContract?.charging_style === 'upfront';
@@ -1424,32 +1503,138 @@ function _buildHandlers(deps) {
// Mostra se há pending (avulsa) ou pacote saldo (cobrança nova)
return hasPending || isPacoteSaldo;
}
if (isAgendado) {
// Reverse transition: mostra se há artefatos a desfazer
const r = ctx.reverseArtifacts;
if (!r) return false;
const hasActiveRecords = (r.activeRecords?.length || 0) > 0;
return hasActiveRecords || r.saldoConsumed;
}
return false;
}
// Aplica as decisões do dialog (saldo, multa, paid, cobrança pacote).
// Aplica as decisões do dialog (saldo, multa, paid, cobrança pacote, reverse).
async function _applyStatusDecisions({ eventoId, row, novoStatus, ctx, decision }) {
const tenantId = clinicTenantId.value;
const uid = ownerId.value;
const patientId = row.patient_id ?? row.paciente_id ?? null;
const tasks = [];
// ─── REVERSE TRANSITION (novoStatus='agendado' com artefatos a desfazer) ──
// Tratado antes dos blocos forward porque a lógica é distinta —
// cancelar records, devolver saldo, sem multa nova. Status já foi
// atualizado pelo _applyStatusUpdateOnly antes desta função.
if (novoStatus === 'agendado' && ctx.reverseArtifacts) {
const r = ctx.reverseArtifacts;
// 1) Cancelar records pending/overdue (se decidiu)
if (decision.reverseCancelPending && (r.activeRecords?.length || 0) > 0) {
const pendingIds = r.activeRecords.filter((rec) => rec.status === 'pending' || rec.status === 'overdue').map((rec) => rec.id);
if (pendingIds.length > 0) {
try {
const today = new Date().toISOString().slice(0, 10);
const reason = `Cancelada via reversão de status (${r.previousStatus} → agendado) em ${today}`;
// Cancela um por um pra capturar erro individual; alternativa
// seria UPDATE em batch com IN, mas notes precisa preservar
// o que tinha antes per-row. Aqui priorizamos clareza.
for (const id of pendingIds) {
const { error: cErr } = await supabase
.from('financial_records')
.update({
status: 'cancelled',
notes: `[${today}] ${reason}`,
updated_at: new Date().toISOString()
})
.eq('id', id);
if (cErr) throw cErr;
}
} catch (e) {
console.error('[Fase5/reverse] erro cancelando records:', e?.message);
toast.add({ severity: 'warn', summary: 'Cobrança', detail: e?.message || 'Falha ao cancelar cobranças', life: 5000 });
}
}
}
// 2) Devolver saldo ao pacote (se decidiu)
// Refetch sessions_used FRESH antes de decrementar pra evitar
// race condition com flows que rodaram entre _loadStatusChangeContext
// e este ponto (ex: Realizada+gerar imediatamente seguido de Agendada).
if (decision.reverseRestoreSaldo && r.saldoConsumed && ctx.billingContract?.id) {
try {
const { data: freshContract, error: fetchErr } = await supabase
.from('billing_contracts')
.select('sessions_used, total_sessions, status')
.eq('id', ctx.billingContract.id)
.maybeSingle();
if (fetchErr) throw fetchErr;
const currentUsed = freshContract?.sessions_used ?? 0;
const totalSessions = freshContract?.total_sessions ?? 0;
const newUsed = Math.max(0, currentUsed - 1);
const patch = { sessions_used: newUsed };
// Se contrato estava 'completed' (atingiu total) e voltou abaixo, reativa.
if (currentUsed >= totalSessions) {
patch.status = 'active';
}
console.log('[Fase5/reverse] decrementando saldo:', { from: currentUsed, to: newUsed, contractId: ctx.billingContract.id });
const { error: dErr } = await supabase.from('billing_contracts').update(patch).eq('id', ctx.billingContract.id);
if (dErr) throw dErr;
} catch (e) {
console.error('[Fase5/reverse] erro decrementando saldo:', e?.message);
toast.add({ severity: 'warn', summary: 'Saldo do pacote', detail: e?.message || 'Falha ao devolver saldo', life: 5000 });
}
}
// 3) Desamarrar billing_contract_id do evento (evento agora está
// agendado, conceitualmente sem vínculo ativo até user reusar).
// Só desamarrar se devolveu saldo — se manteve consumido,
// deixa o vínculo pra rastreabilidade.
if (decision.reverseRestoreSaldo && r.saldoConsumed) {
try {
await supabase.from('agenda_eventos').update({ billing_contract_id: null, updated_at: new Date().toISOString() }).eq('id', eventoId);
} catch (e) {
console.warn('[Fase5/reverse] erro desamarrando billing_contract_id:', e?.message);
}
}
toast.add({ severity: 'success', summary: 'Sessão reativada', detail: 'Status revertido pra agendada.', life: 2500 });
return; // pula blocos forward
}
// 1) Consumir saldo (pacote saldo + faltou/cancelado + decisão sim)
// ⚠ billing_contracts NÃO tem coluna updated_at — passar esse campo
// causa "column does not exist" silenciosamente em Promise.allSettled.
// Amarração de billing_contract_id no evento é feita em 1b) universal.
if (decision.consumeSaldo && ctx.billingContract?.id) {
tasks.push(
supabase
.from('billing_contracts')
.update({
sessions_used: (ctx.billingContract.sessions_used ?? 0) + 1,
updated_at: new Date().toISOString()
sessions_used: (ctx.billingContract.sessions_used ?? 0) + 1
})
.eq('id', ctx.billingContract.id)
);
}
// 2) Aplicar multa (cria financial_record avulsa)
// 1b) Amarra evento ao contrato — universal pra forward em pacote.
// Antes só rodava em consumeSaldo / generatePackageCharge. Faltou+multa
// SEM consume era exceção: evento ficava sem billing_contract_id,
// impedindo o reverse de detectar o vínculo depois. Fix: amarrar
// sempre que há contract envolvido + status forward + eventoId real.
const isForwardStatus = novoStatus === 'realizado' || novoStatus === 'faltou' || novoStatus === 'cancelado';
if (isForwardStatus && ctx.billingContract?.id && eventoId) {
tasks.push(
supabase
.from('agenda_eventos')
.update({ billing_contract_id: ctx.billingContract.id, updated_at: new Date().toISOString() })
.eq('id', eventoId)
);
}
// 2) Aplicar multa (cria financial_record avulsa). Description leva
// data da sessão pra paciente identificar na fatura mesmo após cancel.
if (decision.applyFine && decision.fineAmount > 0) {
const dueIso = row.inicio_em ? new Date(row.inicio_em).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
const sessaoLabel = row.inicio_em ? new Date(row.inicio_em).toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: '2-digit' }) : '';
const fineDesc = novoStatus === 'faltou' ? `Multa por falta · sessão ${sessaoLabel}` : `Taxa de cancelamento tardio · sessão ${sessaoLabel}`;
const finePayload = {
owner_id: uid,
tenant_id: tenantId,
@@ -1457,7 +1642,7 @@ function _buildHandlers(deps) {
agenda_evento_id: eventoId,
amount: decision.fineAmount,
final_amount: decision.fineAmount,
description: novoStatus === 'faltou' ? 'Multa por falta (no-show)' : 'Taxa de cancelamento',
description: fineDesc.trim(),
status: 'pending',
due_date: dueIso,
type: 'receita'
@@ -1475,6 +1660,35 @@ function _buildHandlers(deps) {
);
}
// 2b) Cancelar cobrança original (faltou/cancelado + pendingRecord).
// A sessão não aconteceu/foi cancelada → original substituída pela
// multa (se aplicada) ou simplesmente cancelada. Sem isso cobrava
// dobrado: original R$200 pending + multa R$30 = R$230. Audit trail
// preserva original em notes.
const isFaltouOuCancelado = novoStatus === 'faltou' || novoStatus === 'cancelado';
if (isFaltouOuCancelado && ctx.pendingRecord?.id) {
const reasonText = decision.applyFine
? novoStatus === 'faltou'
? 'Cancelada — substituída por multa de no-show'
: 'Cancelada — substituída por taxa de cancelamento tardio'
: novoStatus === 'faltou'
? 'Cancelada — sessão não realizada (paciente faltou)'
: 'Cancelada — sessão cancelada';
const today = new Date().toISOString().slice(0, 10);
const noteEntry = `[${today}] ${reasonText}`;
const noteText = ctx.pendingRecord.notes ? `${ctx.pendingRecord.notes}\n${noteEntry}` : noteEntry;
tasks.push(
supabase
.from('financial_records')
.update({
status: 'cancelled',
notes: noteText,
updated_at: new Date().toISOString()
})
.eq('id', ctx.pendingRecord.id)
);
}
// 3) Realizado avulsa pendente: marcar pendingRecord como pago (ou só status)
if (decision.markPaid && ctx.pendingRecord?.id) {
tasks.push(
@@ -1490,31 +1704,97 @@ function _buildHandlers(deps) {
);
}
// 4) Realizado em pacote saldo: cria cobrança individual + incrementa sessions_used
// 4-pre) Realizado em pacote saldo + paid pré-existente (C12: antecipou)
// Sessão já paga via "Antecipar pagamento" anteriormente. Realizada
// agora não deve gerar record novo (duplicaria cobrança) — só
// amarrar contrato (via tasks 1b) + incrementar saldo. Rodamos os
// tasks pendentes antes do incremento pra não perder o link.
const hasAnticipatedPayment = novoStatus === 'realizado' && ctx.billingContract?.charging_style === 'saldo' && ctx.existingPaidRecord?.id;
if (hasAnticipatedPayment) {
// Roda tasks acumulados (basicamente 1b amarra) antes do incremento
if (tasks.length > 0) {
const results = await Promise.allSettled(tasks);
const failed = results.filter((r) => r.status === 'rejected');
if (failed.length > 0) {
console.warn('[Fase5/realizada-paid] tasks com falha:', failed.map((f) => f.reason?.message));
}
}
try {
const { data: freshContract, error: fetchErr } = await supabase
.from('billing_contracts')
.select('sessions_used, total_sessions, status')
.eq('id', ctx.billingContract.id)
.maybeSingle();
if (fetchErr) throw fetchErr;
const currentUsed = freshContract?.sessions_used ?? 0;
const newUsed = currentUsed + 1;
const patch = { sessions_used: newUsed };
if (newUsed >= (freshContract?.total_sessions ?? 0)) {
patch.status = 'completed';
}
const { error: incErr } = await supabase.from('billing_contracts').update(patch).eq('id', ctx.billingContract.id);
if (incErr) throw incErr;
toast.add({ severity: 'success', summary: 'Sessão consumida', detail: `Saldo: ${newUsed}/${freshContract?.total_sessions ?? '?'}. Pagamento já estava registrado.`, life: 4000 });
} catch (e) {
console.error('[Fase5/realizada-paid] erro consumindo saldo:', e?.message);
toast.add({ severity: 'warn', summary: 'Saldo do pacote', detail: e?.message || 'Falha ao consumir saldo', life: 5000 });
}
return;
}
// 4) Realizado em pacote saldo: amarra contract + cria cobrança + incrementa saldo
// Refatorado pra usar AWAITS SEQUENCIAIS (igual onUsarSessao do MelissaLayout).
// Antes era Promise.allSettled paralelo que escondia falhas silenciosas
// — durante teste C11/A o sessions_used não incrementava + agenda_evento
// ficava sem billing_contract_id. Ambos os updates não rodavam mas o
// toast warn não aparecia. Agora cada step tem error explícito.
if (decision.generatePackageCharge && ctx.billingContract?.id) {
const amount = Number(row.price ?? 0);
const amount = Number(row.price ?? (ctx.billingContract.total_sessions > 0 ? (Number(ctx.billingContract.package_price) || 0) / ctx.billingContract.total_sessions : 0));
const dueIso = row.inicio_em ? new Date(row.inicio_em).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
// Cria record
tasks.push(
supabase.rpc('create_financial_record_for_session', {
// 4a) Amarra agenda_evento ao contrato. Pra virtual recém-materializada,
// _applyStatusUpdateOnly criou o evento SEM billing_contract_id —
// precisa update separado aqui.
try {
const { error: linkErr } = await supabase.from('agenda_eventos').update({ billing_contract_id: ctx.billingContract.id, updated_at: new Date().toISOString() }).eq('id', eventoId);
if (linkErr) throw linkErr;
} catch (e) {
console.error('[Fase5] erro amarrando billing_contract_id:', e?.message);
toast.add({ severity: 'warn', summary: 'Pacote — amarração', detail: e?.message || 'Falha ao amarrar sessão ao pacote', life: 5000 });
}
// 4b) Cria financial_record (RPC tolera idempotência)
try {
const { error: rpcErr } = await supabase.rpc('create_financial_record_for_session', {
p_tenant_id: tenantId,
p_owner_id: uid,
p_patient_id: patientId,
p_agenda_evento_id: eventoId,
p_amount: amount,
p_due_date: dueIso
})
);
// Incrementa saldo usado
tasks.push(
supabase
.from('billing_contracts')
.update({
sessions_used: (ctx.billingContract.sessions_used ?? 0) + 1,
updated_at: new Date().toISOString()
})
.eq('id', ctx.billingContract.id)
);
});
if (rpcErr) throw rpcErr;
} catch (e) {
console.error('[Fase5] erro RPC create_financial_record_for_session:', e?.message);
toast.add({ severity: 'error', summary: 'Cobrança', detail: e?.message || 'Falha ao gerar cobrança do pacote', life: 7000 });
}
// 4c) Incrementa sessions_used + completa contract se atingir total
// ⚠ billing_contracts NÃO tem coluna updated_at — passar esse
// campo causa "column does not exist" silenciosamente em
// Promise.allSettled (era o root cause do saldo não incrementar).
try {
const newUsed = (ctx.billingContract.sessions_used ?? 0) + 1;
const patchContract = { sessions_used: newUsed };
if (newUsed >= (ctx.billingContract.total_sessions ?? 0)) {
patchContract.status = 'completed';
}
const { error: incErr } = await supabase.from('billing_contracts').update(patchContract).eq('id', ctx.billingContract.id);
if (incErr) throw incErr;
} catch (e) {
console.error('[Fase5] erro incrementando sessions_used:', e?.message);
toast.add({ severity: 'warn', summary: 'Saldo do pacote', detail: e?.message || 'Falha ao atualizar saldo do pacote', life: 6000 });
}
}
// Roda tudo em paralelo (falha parcial é tolerável — toast warn)
@@ -1528,8 +1808,12 @@ function _buildHandlers(deps) {
toast.add({ severity: 'success', summary: 'Status atualizado', detail: 'Decisões aplicadas.', life: 2500 });
}
// Pós: se gerou cobrança via link Asaas, marcar payment_method='asaas'
if (decision.generatePackageCharge && decision.paymentMethod === 'link' && eventoId) {
// Pós-processamento do record gerado pelo pacote saldo. Agora o
// decision tem markPaid explícito:
// - markPaid=true → vira paid + payment_method=PIX/dinheiro/etc
// - markPaid=false + paymentMethod='link' → pending + payment_method='asaas'
// - markPaid=false + paymentMethod='pending' → pending sem método (default)
if (decision.generatePackageCharge && eventoId) {
try {
const { data: newRec } = await supabase
.from('financial_records')
@@ -1539,29 +1823,24 @@ function _buildHandlers(deps) {
.limit(1)
.single();
if (newRec?.id) {
await supabase.from('financial_records').update({ payment_method: 'asaas', updated_at: new Date().toISOString() }).eq('id', newRec.id);
}
} catch { /* silencioso */ }
} else if (decision.generatePackageCharge && decision.paymentMethod !== 'link' && eventoId) {
// Já recebi → marca como paid
try {
const { data: newRec } = await supabase
.from('financial_records')
.select('id')
.eq('agenda_evento_id', eventoId)
.order('created_at', { ascending: false })
.limit(1)
.single();
if (newRec?.id) {
await supabase
.from('financial_records')
.update({
status: 'paid',
paid_at: new Date().toISOString(),
payment_method: decision.paymentMethod,
updated_at: new Date().toISOString()
})
.eq('id', newRec.id);
if (decision.markPaid) {
await supabase
.from('financial_records')
.update({
status: 'paid',
paid_at: new Date().toISOString(),
payment_method: decision.paymentMethod,
updated_at: new Date().toISOString()
})
.eq('id', newRec.id);
} else if (decision.paymentMethod === 'link') {
await supabase
.from('financial_records')
.update({ payment_method: 'asaas', updated_at: new Date().toISOString() })
.eq('id', newRec.id);
}
// markPaid=false + paymentMethod='pending' → não faz nada
// (record já criado como pending pelo RPC, sem payment_method)
}
} catch { /* silencioso */ }
}
+10
View File
@@ -177,6 +177,16 @@ export default {
component: () => import('@/views/pages/auth/SecurityPage.vue')
},
// ======================================================
// 👥 MEMBROS & CONVITES
// ======================================================
{
path: 'members',
name: 'admin-members',
component: () => import('@/views/pages/admin/MembersPage.vue'),
meta: { roles: ['tenant_admin', 'clinic_admin'] }
},
// ======================================================
// 🔒 MÓDULO PRO — Online Scheduling
// ======================================================
+112
View File
@@ -0,0 +1,112 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/services/specialtiesService.js
|
| Service pra catálogo de especialidades + manage as escolhidas do profile.
| ROADMAP item #9 (Compliance CFP).
|
| Schema: ver migrations/20260521000004_specialties.sql + seed_050.
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
const SPECIALTY_SELECT = 'id, key, name, category, is_system, active';
const PROFILE_SPECIALTY_SELECT = 'profile_id, specialty_id, other_label, created_at';
/**
* Lista catálogo de especialidades ativas. Ordem: category, name.
*
* @param {Object} [opts]
* @param {string} [opts.category] - filtra por categoria (psicologia, abordagem, publico, tema, outro)
*/
export async function listSpecialties({ category } = {}) {
let q = supabase.from('specialties').select(SPECIALTY_SELECT).eq('active', true).order('category', { ascending: true }).order('name', { ascending: true });
if (category) q = q.eq('category', category);
const { data, error } = await q;
if (error) throw error;
return data || [];
}
/**
* especialidades do profile passado (default: user logado via auth.uid()).
*
* @param {string} [profileId]
*/
export async function getProfileSpecialties(profileId = null) {
let pid = profileId;
if (!pid) {
const { data: userData } = await supabase.auth.getUser();
pid = userData?.user?.id;
if (!pid) throw new Error('Sessão inválida.');
}
const { data, error } = await supabase
.from('profile_specialties')
.select(`${PROFILE_SPECIALTY_SELECT}, specialty:specialties (${SPECIALTY_SELECT})`)
.eq('profile_id', pid);
if (error) throw error;
return (data || []).map((r) => ({
...r,
// flatten — UI espera campos do specialty direto
key: r.specialty?.key,
name: r.specialty?.name,
category: r.specialty?.category
}));
}
/**
* Substitui completamente as especialidades do user (delete + insert pattern).
* Other label preenchido se key === 'outra'.
*
* @param {Array<{specialty_id, other_label?}>} specialties
* @param {string} [profileId]
*/
export async function setProfileSpecialties(specialties, profileId = null) {
let pid = profileId;
if (!pid) {
const { data: userData } = await supabase.auth.getUser();
pid = userData?.user?.id;
if (!pid) throw new Error('Sessão inválida.');
}
// 1. Delete existentes
const { error: delErr } = await supabase.from('profile_specialties').delete().eq('profile_id', pid);
if (delErr) throw delErr;
// 2. Insert novos (skip se array vazio)
if (!specialties?.length) return [];
const rows = specialties.map((s) => ({
profile_id: pid,
specialty_id: s.specialty_id,
other_label: s.other_label ? String(s.other_label).trim() || null : null
}));
const { data, error } = await supabase.from('profile_specialties').insert(rows).select(PROFILE_SPECIALTY_SELECT);
if (error) throw error;
return data || [];
}
/**
* Lista profiles que têm uma especialidade específica (perfil público / busca).
* Use pra contextos onde tenant_admin/saas tem permissão de ver outros profiles.
*
* @param {string} specialtyKey - ex: 'tcc', 'psicanalise'
*/
export async function listProfilesBySpecialty(specialtyKey) {
if (!specialtyKey) return [];
// 1. Pega specialty.id pela key
const { data: spec } = await supabase.from('specialties').select('id').eq('key', specialtyKey).eq('active', true).maybeSingle();
if (!spec) return [];
// 2. Lê profile_specialties + join profiles
const { data, error } = await supabase.from('profile_specialties').select('profile_id, profiles!profile_id (id, full_name, avatar_url, bio, professional_registration_type, professional_registration_number, professional_registration_uf)').eq('specialty_id', spec.id);
if (error) throw error;
return (data || []).map((r) => r.profiles).filter(Boolean);
}
+98
View File
@@ -0,0 +1,98 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/services/tenantFeatureAdminService.js
|
| Service de admin SaaS pra gestão de features por tenant. Substitui supabase
| direto que estava em SaasTenantFeaturesPage.vue (audit alta 4 queries
| inline + 1 RPC).
|
| Diferente de `tenantFeaturesStore` (que gerencia features do user/tenant ATIVO),
| este service opera em QUALQUER tenant selecionado pelo SaaS admin.
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client';
// SELECTs canônicos
const TENANT_LIST_SELECT = 'id, name';
const FEATURE_CATALOG_SELECT = 'id, key, name, descricao';
const ENTITLEMENT_SELECT = 'feature_key';
const TENANT_FEATURE_SELECT = 'feature_key, enabled';
const SUBSCRIPTION_SELECT = 'plan_key';
const EXCEPTIONS_LOG_SELECT = 'feature_key, enabled, reason, created_by, created_at';
/**
* Lista todos os tenants (apenas SaaS admin tem acesso via RLS).
*/
export async function listTenants() {
const { data, error } = await supabase.from('tenants').select(TENANT_LIST_SELECT).order('name', { ascending: true });
if (error) throw error;
return data || [];
}
/**
* Lista o catálogo completo de features do sistema.
*/
export async function listFeatureCatalog() {
const { data, error } = await supabase.from('features').select(FEATURE_CATALOG_SELECT).order('key', { ascending: true });
if (error) throw error;
return data || [];
}
/**
* Carrega o estado completo de features de um tenant: entitlements via plano,
* overrides (exceções), plano ativo, e log das últimas 50 exceções.
*
* @param {string} tenantId
* @returns {Promise<{planAllowed: Set, planKey: string|null, overrides: Object, exceptionsLog: Array}>}
*/
export async function loadTenantFeatureState(tenantId) {
if (!tenantId) {
return { planAllowed: new Set(), planKey: null, overrides: {}, exceptionsLog: [] };
}
const [{ data: ent, error: e1 }, { data: ovr, error: e2 }, { data: sub, error: e3 }, { data: log, error: e4 }] = await Promise.all([
supabase.from('v_tenant_entitlements').select(ENTITLEMENT_SELECT).eq('tenant_id', tenantId),
supabase.from('tenant_features').select(TENANT_FEATURE_SELECT).eq('tenant_id', tenantId),
supabase.from('v_tenant_active_subscription').select(SUBSCRIPTION_SELECT).eq('tenant_id', tenantId).maybeSingle(),
supabase.from('tenant_feature_exceptions_log').select(EXCEPTIONS_LOG_SELECT).eq('tenant_id', tenantId).order('created_at', { ascending: false }).limit(50)
]);
if (e1) throw e1;
if (e2) throw e2;
if (e3) throw e3;
if (e4) throw e4;
const planAllowed = new Set();
for (const r of ent || []) planAllowed.add(r.feature_key);
const overrides = {};
for (const r of ovr || []) overrides[r.feature_key] = !!r.enabled;
return {
planAllowed,
planKey: sub?.plan_key || null,
overrides,
exceptionsLog: log || []
};
}
/**
* Aplica exceção comercial (force enable/disable) numa feature de um tenant.
* RPC `set_tenant_feature_exception` faz UPSERT em tenant_features +
* INSERT em tenant_feature_exceptions_log.
*/
export async function setFeatureException(tenantId, featureKey, enabled, reason = null) {
if (!tenantId) throw new Error('tenantId obrigatório.');
if (!featureKey) throw new Error('featureKey obrigatória.');
const { error } = await supabase.rpc('set_tenant_feature_exception', {
p_tenant_id: tenantId,
p_feature_key: featureKey,
p_enabled: enabled,
p_reason: reason ? String(reason).trim() || null : null
});
if (error) throw error;
}
+1 -12
View File
@@ -4,6 +4,7 @@ import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { supabase } from '../../lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { TEST_ACCOUNTS } from '@/config/devTestAccounts';
const router = useRouter();
const tenant = useTenantStore();
@@ -20,18 +21,6 @@ const storageTenantId = ref(null);
const storageTenant = ref(null);
const storageCurrentTenantId = ref(null);
const TEST_ACCOUNTS = {
clinic_admin: { email: 'clinica3@agenciapsi.com.br', password: 'Teste@123' },
therapist: { email: 'terapeuta@agenciapsi.com.br', password: 'Teste@123' },
supervisor: { email: 'supervisor@agenciapsi.com.br', password: 'Teste@123' },
patient: { email: 'paciente@agenciapsi.com.br', password: 'Teste@123' },
saas: { email: 'saas@agenciapsi.com.br', password: 'Teste@123' },
editor: { email: 'editor@agenciapsi.com.br', password: 'Teste@123' },
therapist2: { email: 'therapist2@agenciapsi.com.br', password: 'Teste@123' },
therapist3: { email: 'therapist3@agenciapsi.com.br', password: 'Teste@123' },
secretary: { email: 'secretary@agenciapsi.com.br', password: 'Teste@123' }
};
const PROFILE_CARDS = [
{
key: 'patient',
+336
View File
@@ -0,0 +1,336 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI MembersPage.vue
|--------------------------------------------------------------------------
| Gestão de membros e convites do tenant ativo. Usa services do tenantship
| (0.5.D scaffold). Cobre: listar membros ativos, mudar role, remover,
| listar convites pendentes, enviar novo convite, revogar.
|
| Aceitar convite ainda é STUB no repository (precisa RPC
| `accept_tenant_invite(p_token uuid)`). Página de aceitar (via link
| /aceitar-convite?token=...) fica pra sessão dedicada.
|
| Rota sugerida (registrar manualmente em routes.clinic.js ou routes.saas.js):
| { path: 'members', name: 'AdminMembers', component: () => import('@/views/pages/admin/MembersPage.vue') }
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, onMounted, computed } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useTenantStore } from '@/stores/tenantStore';
import { useTenantMembers } from '@/features/tenantship/composables/useTenantMembers';
import { useTenantInvites } from '@/features/tenantship/composables/useTenantInvites';
const toast = useToast();
const tenantStore = useTenantStore();
const members = useTenantMembers();
const invites = useTenantInvites();
const tenantId = computed(() => tenantStore.activeTenantId || tenantStore.tenantId || null);
// Estado UI
const inviteDialogOpen = ref(false);
const inviteForm = ref({ email: '', role: 'therapist' });
const inviteSaving = ref(false);
const roleEditDialogOpen = ref(false);
const roleEditTarget = ref(null);
const roleEditNewRole = ref('therapist');
const roleEditSaving = ref(false);
const removeConfirmOpen = ref(false);
const removeTarget = ref(null);
const removing = ref(false);
const ROLE_LABELS = {
tenant_admin: 'Administrador',
therapist: 'Terapeuta',
secretary: 'Secretária'
};
const ROLE_OPTIONS = [
{ value: 'therapist', label: 'Terapeuta' },
{ value: 'secretary', label: 'Secretária' }
// tenant_admin não é atribuível via UI promoção manual.
];
const INVITE_ROLE_OPTIONS = [
{ value: 'therapist', label: 'Terapeuta' },
{ value: 'secretary', label: 'Secretária' }
];
// Computeds
const activeMembers = computed(() => members.rows.value.filter((m) => m.status === 'active'));
const pendingInvites = computed(() => invites.rows.value.filter((i) => i.status === 'pending'));
// Lifecycle
onMounted(async () => {
if (!tenantId.value) {
toast.add({ severity: 'warn', summary: 'Sem tenant ativo', detail: 'Selecione um tenant antes de gerenciar membros.', life: 4000 });
return;
}
await refreshAll();
});
async function refreshAll() {
await Promise.all([members.loadForTenant({ tenantId: tenantId.value }), invites.loadForTenant({ tenantId: tenantId.value })]);
}
// Convites
function openInviteDialog() {
inviteForm.value = { email: '', role: 'therapist' };
inviteDialogOpen.value = true;
}
async function submitInvite() {
const email = String(inviteForm.value.email || '').trim().toLowerCase();
if (!email) {
toast.add({ severity: 'warn', summary: 'E-mail obrigatório', life: 3000 });
return;
}
inviteSaving.value = true;
try {
await invites.send({ email, role: inviteForm.value.role, tenantId: tenantId.value });
toast.add({
severity: 'success',
summary: 'Convite enviado',
detail: `Token gerado pra ${email}. (Envio de e-mail/WhatsApp pendente — Módulo 6.)`,
life: 4500
});
inviteDialogOpen.value = false;
} catch (e) {
toast.add({
severity: 'error',
summary: 'Falha ao enviar convite',
detail: e?.message || 'Erro desconhecido.',
life: 4500
});
} finally {
inviteSaving.value = false;
}
}
async function revokeInvite(invite) {
try {
await invites.revoke(invite.id, { tenantId: tenantId.value });
toast.add({ severity: 'success', summary: 'Convite revogado', life: 2500 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Falha ao revogar', detail: e?.message, life: 4500 });
}
}
function copyInviteLink(invite) {
const baseUrl = window.location.origin;
const link = `${baseUrl}/aceitar-convite?token=${invite.token}`;
navigator.clipboard
.writeText(link)
.then(() => toast.add({ severity: 'info', summary: 'Link copiado', detail: link, life: 3000 }))
.catch(() => toast.add({ severity: 'error', summary: 'Falha ao copiar', life: 3000 }));
}
// Members
function openRoleEdit(member) {
roleEditTarget.value = member;
roleEditNewRole.value = member.role;
roleEditDialogOpen.value = true;
}
async function submitRoleEdit() {
if (!roleEditTarget.value) return;
roleEditSaving.value = true;
try {
await members.updateRole(roleEditTarget.value.id, roleEditNewRole.value, { tenantId: tenantId.value });
toast.add({ severity: 'success', summary: 'Papel atualizado', life: 2500 });
roleEditDialogOpen.value = false;
} catch (e) {
toast.add({ severity: 'error', summary: 'Falha ao atualizar papel', detail: e?.message, life: 4500 });
} finally {
roleEditSaving.value = false;
}
}
function openRemoveConfirm(member) {
removeTarget.value = member;
removeConfirmOpen.value = true;
}
async function submitRemove() {
if (!removeTarget.value) return;
removing.value = true;
try {
await members.remove(removeTarget.value.id, { tenantId: tenantId.value });
toast.add({ severity: 'success', summary: 'Membro removido', life: 2500 });
removeConfirmOpen.value = false;
} catch (e) {
toast.add({ severity: 'error', summary: 'Falha ao remover', detail: e?.message, life: 4500 });
} finally {
removing.value = false;
}
}
function fmtDate(iso) {
if (!iso) return '—';
return new Date(iso).toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
</script>
<template>
<div class="p-4 max-w-[1100px] mx-auto">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-semibold">Membros & Convites</h1>
<p class="text-sm text-[var(--text-color-secondary)]">Gestão de quem tem acesso à clínica.</p>
</div>
<Button label="Convidar membro" icon="pi pi-user-plus" @click="openInviteDialog" />
</div>
<!-- Aviso se sem tenant -->
<div v-if="!tenantId" class="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-4 text-yellow-800">
Selecione um tenant ativo no menu pra gerenciar membros.
</div>
<!-- Loading state -->
<div v-else-if="members.loading.value || invites.loading.value" class="text-center py-8 text-[var(--text-color-secondary)]">
<i class="pi pi-spin pi-spinner mr-2" /> Carregando
</div>
<template v-else>
<!-- Membros ativos -->
<section class="mb-8">
<h2 class="text-lg font-medium mb-3 flex items-center gap-2">
<i class="pi pi-users text-blue-500" />
Membros ativos
<span class="text-sm text-[var(--text-color-secondary)] font-normal">({{ activeMembers.length }})</span>
</h2>
<div v-if="!activeMembers.length" class="text-center py-6 text-[var(--text-color-secondary)] border border-dashed border-[var(--surface-border)] rounded-lg">
Nenhum membro ativo.
</div>
<DataTable v-else :value="activeMembers" stripedRows class="text-sm">
<Column field="full_name" header="Nome">
<template #body="{ data }">
<div class="font-medium">{{ data.full_name || '—' }}</div>
<div class="text-xs text-[var(--text-color-secondary)]">{{ data.email }}</div>
</template>
</Column>
<Column field="role" header="Papel">
<template #body="{ data }">
<span class="px-2 py-0.5 rounded-full text-xs bg-blue-100 text-blue-700">
{{ ROLE_LABELS[data.role] || data.role }}
</span>
</template>
</Column>
<Column field="created_at" header="Desde">
<template #body="{ data }">{{ fmtDate(data.created_at) }}</template>
</Column>
<Column header="Ações" :style="{ width: '180px' }">
<template #body="{ data }">
<div class="flex gap-2">
<Button v-if="data.role !== 'tenant_admin'" icon="pi pi-pencil" severity="secondary" text rounded v-tooltip.top="'Alterar papel'" @click="openRoleEdit(data)" />
<Button v-if="data.role !== 'tenant_admin'" icon="pi pi-times" severity="danger" text rounded v-tooltip.top="'Remover'" @click="openRemoveConfirm(data)" />
<span v-else class="text-xs text-[var(--text-color-secondary)] italic">admin</span>
</div>
</template>
</Column>
</DataTable>
</section>
<!-- Convites pendentes -->
<section>
<h2 class="text-lg font-medium mb-3 flex items-center gap-2">
<i class="pi pi-envelope text-amber-500" />
Convites pendentes
<span class="text-sm text-[var(--text-color-secondary)] font-normal">({{ pendingInvites.length }})</span>
</h2>
<div v-if="!pendingInvites.length" class="text-center py-6 text-[var(--text-color-secondary)] border border-dashed border-[var(--surface-border)] rounded-lg">
Nenhum convite pendente.
</div>
<DataTable v-else :value="pendingInvites" stripedRows class="text-sm">
<Column field="email" header="E-mail" />
<Column field="role" header="Papel convidado">
<template #body="{ data }">
<span class="px-2 py-0.5 rounded-full text-xs bg-blue-100 text-blue-700">
{{ ROLE_LABELS[data.role] || data.role }}
</span>
</template>
</Column>
<Column field="created_at" header="Enviado em">
<template #body="{ data }">{{ fmtDate(data.created_at) }}</template>
</Column>
<Column field="expires_at" header="Expira em">
<template #body="{ data }">{{ fmtDate(data.expires_at) }}</template>
</Column>
<Column header="Ações" :style="{ width: '200px' }">
<template #body="{ data }">
<div class="flex gap-2">
<Button icon="pi pi-link" severity="secondary" text rounded v-tooltip.top="'Copiar link'" @click="copyInviteLink(data)" />
<Button icon="pi pi-times" severity="danger" text rounded v-tooltip.top="'Revogar'" @click="revokeInvite(data)" />
</div>
</template>
</Column>
</DataTable>
</section>
</template>
<!-- Dialog: Convidar -->
<Dialog v-model:visible="inviteDialogOpen" modal :draggable="false" header="Convidar membro" :style="{ width: '480px', maxWidth: '94vw' }">
<div class="flex flex-col gap-4 pt-2">
<FloatLabel variant="on">
<InputText id="inv-email" v-model="inviteForm.email" type="email" class="w-full" autofocus />
<label for="inv-email">E-mail *</label>
</FloatLabel>
<FloatLabel variant="on">
<Select id="inv-role" v-model="inviteForm.role" :options="INVITE_ROLE_OPTIONS" optionLabel="label" optionValue="value" class="w-full" />
<label for="inv-role">Papel</label>
</FloatLabel>
<div class="text-xs text-[var(--text-color-secondary)]">
O convite gera um link com token de 7 dias. Envio automático de e-mail/WhatsApp será adicionado no Módulo 6 por enquanto copie o link manualmente.
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" text @click="inviteDialogOpen = false" />
<Button label="Enviar convite" icon="pi pi-send" :loading="inviteSaving" @click="submitInvite" />
</template>
</Dialog>
<!-- Dialog: Editar papel -->
<Dialog v-model:visible="roleEditDialogOpen" modal :draggable="false" header="Alterar papel" :style="{ width: '420px', maxWidth: '94vw' }">
<div class="flex flex-col gap-3 pt-2">
<div class="text-sm">Membro: <strong>{{ roleEditTarget?.full_name || roleEditTarget?.email }}</strong></div>
<FloatLabel variant="on">
<Select id="role-new" v-model="roleEditNewRole" :options="ROLE_OPTIONS" optionLabel="label" optionValue="value" class="w-full" />
<label for="role-new">Novo papel</label>
</FloatLabel>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" text @click="roleEditDialogOpen = false" />
<Button label="Salvar" :loading="roleEditSaving" @click="submitRoleEdit" />
</template>
</Dialog>
<!-- Dialog: Confirmar remoção -->
<Dialog v-model:visible="removeConfirmOpen" modal :draggable="false" header="Remover membro" :style="{ width: '420px', maxWidth: '94vw' }">
<div class="pt-2">
<p>
Tem certeza que quer remover
<strong>{{ removeTarget?.full_name || removeTarget?.email }}</strong>
do tenant?
</p>
<p class="text-xs text-[var(--text-color-secondary)] mt-2">Os dados criados por essa pessoa (pacientes, sessões, prontuários) permanecem apenas o vínculo é desfeito.</p>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" text @click="removeConfirmOpen = false" />
<Button label="Remover" icon="pi pi-times" severity="danger" :loading="removing" @click="submitRemove" />
</template>
</Dialog>
</div>
</template>
+21 -42
View File
@@ -16,7 +16,13 @@
-->
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { supabase } from '@/lib/supabase/client';
// Audit alta (2026-05-20): supabase direto extraído pra service module.
import {
listTenants as svcListTenants,
listFeatureCatalog as svcListFeatures,
loadTenantFeatureState as svcLoadTenantState,
setFeatureException as svcSetFeatureException
} from '@/services/tenantFeatureAdminService';
import Select from 'primevue/select';
import DataTable from 'primevue/datatable';
@@ -92,21 +98,19 @@ function statusSeverity(s) {
}
async function loadTenants() {
const { data, error } = await supabase.from('tenants').select('id, name').order('name', { ascending: true });
if (error) {
toast.add({ severity: 'error', summary: 'Erro', detail: 'Falha ao carregar clínicas', life: 4000 });
return;
try {
tenants.value = await svcListTenants();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar clínicas', life: 4000 });
}
tenants.value = data || [];
}
async function loadFeatures() {
const { data, error } = await supabase.from('features').select('id, key, name, descricao').order('key', { ascending: true });
if (error) {
toast.add({ severity: 'error', summary: 'Erro', detail: 'Falha ao carregar catálogo', life: 4000 });
return;
try {
features.value = await svcListFeatures();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar catálogo', life: 4000 });
}
features.value = data || [];
}
async function loadTenantState(tenantId) {
@@ -119,30 +123,12 @@ async function loadTenantState(tenantId) {
}
loading.value = true;
try {
const [{ data: ent, error: e1 }, { data: ovr, error: e2 }, { data: sub, error: e3 }, { data: log, error: e4 }] = await Promise.all([
supabase.from('v_tenant_entitlements').select('feature_key').eq('tenant_id', tenantId),
supabase.from('tenant_features').select('feature_key, enabled').eq('tenant_id', tenantId),
supabase.from('v_tenant_active_subscription').select('plan_key').eq('tenant_id', tenantId).maybeSingle(),
supabase.from('tenant_feature_exceptions_log').select('feature_key, enabled, reason, created_by, created_at').eq('tenant_id', tenantId).order('created_at', { ascending: false }).limit(50)
]);
if (e1) throw e1;
if (e2) throw e2;
if (e3) throw e3;
if (e4) throw e4;
const set = new Set();
for (const r of ent || []) set.add(r.feature_key);
planAllowed.value = set;
const map = {};
for (const r of ovr || []) map[r.feature_key] = !!r.enabled;
overrides.value = map;
planKey.value = sub?.plan_key || null;
exceptionsLog.value = log || [];
const state = await svcLoadTenantState(tenantId);
planAllowed.value = state.planAllowed;
planKey.value = state.planKey;
overrides.value = state.overrides;
exceptionsLog.value = state.exceptionsLog;
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao carregar tenant', detail: e?.message || 'falha', life: 4000 });
} finally {
@@ -173,14 +159,7 @@ async function confirmChange() {
saving.value = true;
try {
const { error } = await supabase.rpc('set_tenant_feature_exception', {
p_tenant_id: selectedTenantId.value,
p_feature_key: feature.key,
p_enabled: nextEnabled,
p_reason: reason || null
});
if (error) throw error;
await svcSetFeatureException(selectedTenantId.value, feature.key, nextEnabled, reason);
toast.add({
severity: 'success',
@@ -0,0 +1,86 @@
/*
|--------------------------------------------------------------------------
| Agência PSI Edge Function: asaas-cancel-payment
|--------------------------------------------------------------------------
| Cancela cobrança Asaas. Atualização do financial_record fica pro webhook
| (PAYMENT_DELETED) single source of truth.
|
| STUB chamada real ao Asaas marcada TODO.
|
| Input: { tenant_id, asaas_payment_id }
| Output: { ok: true } ou { ok: false, error }
|--------------------------------------------------------------------------
*/
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
'Access-Control-Allow-Methods': 'POST, OPTIONS'
};
function json(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
});
}
Deno.serve(async (req: Request) => {
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders });
if (req.method !== 'POST') return json({ ok: false, error: 'method_not_allowed' }, 405);
try {
const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
if (!body) return json({ ok: false, error: 'invalid_body' }, 400);
const tenantId = String(body.tenant_id || '');
const asaasPaymentId = String(body.asaas_payment_id || '');
if (!tenantId || !asaasPaymentId) return json({ ok: false, error: 'missing_fields' }, 400);
const supa = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!);
// 1. Lê config + API key
const { data: settings } = await supa.from('payment_settings').select('asaas_enabled, asaas_environment, asaas_api_key_sandbox, asaas_api_key_prod').eq('tenant_id', tenantId).maybeSingle();
if (!settings?.asaas_enabled) return json({ ok: false, error: 'gateway_not_enabled' }, 403);
const environment = settings.asaas_environment || 'sandbox';
const apiKey = environment === 'prod' ? settings.asaas_api_key_prod : settings.asaas_api_key_sandbox;
const apiUrl = environment === 'prod'
? Deno.env.get('ASAAS_API_URL_PROD') || 'https://api.asaas.com/v3'
: Deno.env.get('ASAAS_API_URL_SANDBOX') || 'https://sandbox.asaas.com/api/v3';
if (!apiKey) return json({ ok: false, error: 'api_key_missing' }, 403);
// 2. Verifica que payment pertence ao tenant
const { data: payment } = await supa.from('asaas_payments').select('id, status, cancelled_at').eq('tenant_id', tenantId).eq('asaas_payment_id', asaasPaymentId).eq('environment', environment).maybeSingle();
if (!payment) return json({ ok: false, error: 'payment_not_found' }, 404);
if (payment.cancelled_at) return json({ ok: true, already_cancelled: true });
// 3. DELETE /payments/:id no Asaas
// TODO Fase B:
// const resp = await fetch(`${apiUrl}/payments/${asaasPaymentId}`, {
// method: 'DELETE',
// headers: { 'access_token': apiKey }
// });
// const asaasResult = await resp.json();
// if (!resp.ok) throw new Error(asaasResult.errors?.[0]?.description || 'asaas_delete_failed');
// 4. UPDATE asaas_payments.cancelled_at
// (webhook PAYMENT_DELETED também vai chegar, mas update aqui é mais rápido pra UI)
return json({
ok: false,
error: 'STUB_not_implemented_TODO_fase_B',
note: 'Edge Function estruturada — implementar DELETE call quando Asaas configurado.',
api_url: apiUrl,
environment
}, 501);
} catch (err) {
console.error('[asaas-cancel-payment] fatal:', err);
return json({ ok: false, error: String(err) }, 500);
}
});
@@ -0,0 +1,171 @@
/*
|--------------------------------------------------------------------------
| Agência PSI Edge Function: asaas-create-payment-record
|--------------------------------------------------------------------------
| Cria cobrança Asaas pra um financial_record existente.
|
| STUB FOUNDATION chamadas reais ao Asaas API marcadas TODO.
| Fluxo completo descrito em DESIGN_ASAAS_GATEWAY.md §6.
|
| Input (body JSON):
| {
| tenant_id: uuid,
| financial_record_id: uuid,
| billing_type: 'PIX' | 'BOLETO' | 'CREDIT_CARD',
| due_date?: 'YYYY-MM-DD' // default = financial_record.due_date
| }
|
| Output (JSON):
| {
| ok: true,
| payment: {
| asaas_payment_id, payment_url, pix_qr_code?, pix_copy_paste?, bank_slip_url?
| }
| }
|
| Errors:
| 400 input inválido
| 403 gateway não habilitado pro tenant
| 404 record não encontrado
| 500 erro Asaas
|--------------------------------------------------------------------------
*/
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
'Access-Control-Allow-Methods': 'POST, OPTIONS'
};
function json(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
});
}
Deno.serve(async (req: Request) => {
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders });
if (req.method !== 'POST') return json({ ok: false, error: 'method_not_allowed' }, 405);
try {
const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
if (!body) return json({ ok: false, error: 'invalid_body' }, 400);
const tenantId = String(body.tenant_id || '');
const recordId = String(body.financial_record_id || '');
const billingType = String(body.billing_type || 'PIX');
const dueDateOverride = body.due_date ? String(body.due_date) : null;
if (!tenantId || !recordId) return json({ ok: false, error: 'missing_fields' }, 400);
if (!['PIX', 'BOLETO', 'CREDIT_CARD'].includes(billingType)) {
return json({ ok: false, error: 'invalid_billing_type' }, 400);
}
const supa = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!);
// 1. Verifica gateway habilitado + lê API key do tenant
const { data: settings } = await supa
.from('payment_settings')
.select('asaas_enabled, asaas_environment, asaas_api_key_sandbox, asaas_api_key_prod')
.eq('tenant_id', tenantId)
.maybeSingle();
if (!settings?.asaas_enabled) {
return json({ ok: false, error: 'gateway_not_enabled_for_tenant' }, 403);
}
const environment = settings.asaas_environment || 'sandbox';
const apiKey = environment === 'prod' ? settings.asaas_api_key_prod : settings.asaas_api_key_sandbox;
const apiUrl = environment === 'prod'
? Deno.env.get('ASAAS_API_URL_PROD') || 'https://api.asaas.com/v3'
: Deno.env.get('ASAAS_API_URL_SANDBOX') || 'https://sandbox.asaas.com/api/v3';
if (!apiKey) {
return json({ ok: false, error: 'api_key_missing_for_environment', environment }, 403);
}
// 2. Lê financial_record + patient
const { data: record } = await supa
.from('financial_records')
.select('id, tenant_id, patient_id, amount, due_date, description, status, deleted_at, agenda_evento_id')
.eq('id', recordId)
.eq('tenant_id', tenantId)
.is('deleted_at', null)
.maybeSingle();
if (!record) return json({ ok: false, error: 'record_not_found' }, 404);
if (record.status !== 'pending') {
return json({ ok: false, error: `record_not_pending (status=${record.status})` }, 409);
}
if (!record.patient_id) return json({ ok: false, error: 'record_has_no_patient' }, 400);
const { data: patient } = await supa
.from('patients')
.select('id, nome_completo, email_principal, telefone, cpf')
.eq('id', record.patient_id)
.maybeSingle();
if (!patient) return json({ ok: false, error: 'patient_not_found' }, 404);
if (!patient.cpf) return json({ ok: false, error: 'patient_missing_cpf_required_by_asaas' }, 400);
// 3. Garante customer no Asaas (chama interna asaas-create-customer-patient OU inline)
// TODO Fase B: chamar Edge Function asaas-create-customer-patient ou inline upsert.
// Por ora, busca cache local — se não existe, retorna erro.
let { data: customer } = await supa
.from('asaas_customers')
.select('id, asaas_customer_id')
.eq('tenant_id', tenantId)
.eq('patient_id', patient.id)
.eq('environment', environment)
.is('deleted_at', null)
.maybeSingle();
if (!customer) {
// TODO Fase B: criar customer via POST /customers no Asaas
return json({
ok: false,
error: 'customer_not_cached_TODO_create_via_asaas_api',
hint: 'Implementar inline ou via chamada asaas-create-customer-patient'
}, 501);
}
// 4. POST /payments no Asaas
// TODO Fase B: fetch real à Asaas API
const asaasPayload = {
customer: customer.asaas_customer_id,
billingType,
value: Number(record.amount),
dueDate: dueDateOverride || record.due_date,
description: record.description || `Sessão #${record.id.slice(0, 8)}`,
externalReference: record.id
};
// TODO Fase B: substituir mock por fetch real
// const asaasResp = await fetch(`${apiUrl}/payments`, {
// method: 'POST',
// headers: { 'Content-Type': 'application/json', 'access_token': apiKey },
// body: JSON.stringify(asaasPayload)
// });
// const asaasPayment = await asaasResp.json();
return json({
ok: false,
error: 'STUB_not_implemented_TODO_fase_B',
note: 'Edge Function pronta na estrutura, fetch ao Asaas marcado TODO. Ver DESIGN_ASAAS_GATEWAY.md §9 (Phasing).',
would_send_to_asaas: asaasPayload,
api_url: apiUrl,
environment
}, 501);
// 5. INSERT asaas_payments
// TODO Fase B: salvar resposta + extras (QR code pra PIX)
// 6. UPDATE financial_records.payment_link
// TODO Fase B
} catch (err) {
console.error('[asaas-create-payment-record] fatal:', err);
return json({ ok: false, error: String(err) }, 500);
}
});
@@ -0,0 +1,69 @@
/*
|--------------------------------------------------------------------------
| Agência PSI Edge Function: asaas-sync-payment
|--------------------------------------------------------------------------
| Consulta status atual de um pagamento Asaas e força atualização local.
| Use quando suspeitar que webhook falhou (paciente diz que pagou mas
| record fica pending no banco).
|
| STUB chamada real ao Asaas marcada TODO.
|--------------------------------------------------------------------------
*/
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
const corsHeaders = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
'Access-Control-Allow-Methods': 'POST, OPTIONS'
};
function json(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { ...corsHeaders, 'Content-Type': 'application/json' }
});
}
Deno.serve(async (req: Request) => {
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders });
if (req.method !== 'POST') return json({ ok: false, error: 'method_not_allowed' }, 405);
try {
const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
if (!body) return json({ ok: false, error: 'invalid_body' }, 400);
const tenantId = String(body.tenant_id || '');
const asaasPaymentId = String(body.asaas_payment_id || '');
if (!tenantId || !asaasPaymentId) return json({ ok: false, error: 'missing_fields' }, 400);
const supa = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!);
const { data: settings } = await supa.from('payment_settings').select('asaas_enabled, asaas_environment, asaas_api_key_sandbox, asaas_api_key_prod').eq('tenant_id', tenantId).maybeSingle();
if (!settings?.asaas_enabled) return json({ ok: false, error: 'gateway_not_enabled' }, 403);
const environment = settings.asaas_environment || 'sandbox';
const apiKey = environment === 'prod' ? settings.asaas_api_key_prod : settings.asaas_api_key_sandbox;
const apiUrl = environment === 'prod'
? Deno.env.get('ASAAS_API_URL_PROD') || 'https://api.asaas.com/v3'
: Deno.env.get('ASAAS_API_URL_SANDBOX') || 'https://sandbox.asaas.com/api/v3';
if (!apiKey) return json({ ok: false, error: 'api_key_missing' }, 403);
// TODO Fase B:
// const resp = await fetch(`${apiUrl}/payments/${asaasPaymentId}`, {
// headers: { 'access_token': apiKey }
// });
// const asaasPayment = await resp.json();
// // map asaasPayment.status → financial_records.status, UPDATE asaas_payments + financial_records.
return json({
ok: false,
error: 'STUB_not_implemented_TODO_fase_B',
note: 'Edge Function estruturada — GET /payments/:id pra Asaas marcada TODO.',
api_url: apiUrl,
environment
}, 501);
} catch (err) {
console.error('[asaas-sync-payment] fatal:', err);
return json({ ok: false, error: String(err) }, 500);
}
});