Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ce22dd236 | |||
| cd67f7e9f5 | |||
| de3898878a | |||
| ee2967a075 | |||
| 0956e4facc | |||
| fbfb95648e | |||
| 388e9a4186 | |||
| 1c2a2b6e19 | |||
| 27467bbb68 | |||
| f94a4ae97f | |||
| 5b345c5598 | |||
| 4da0bc2e11 | |||
| f83315baba | |||
| 7d2a405d05 | |||
| b5e00a7022 | |||
| 272c804335 | |||
| 00c4168393 | |||
| 9ead3fdc42 | |||
| 5965b05378 | |||
| 45984e885b | |||
| 3f3f2acc70 | |||
| 5684297243 | |||
| 16dfa02bd1 | |||
| 079509e001 | |||
| 7dc7dcede0 | |||
| 1e74a115de | |||
| 753182cfad | |||
| 3caf5792f8 | |||
| d6423da9b4 |
+76
-12
@@ -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`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
@@ -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** Só `/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
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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" só 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 ────────────────────────────────── -->
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 só renderiza o menu se `showPlanDevMenu`
|
||||
| já 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 };
|
||||
}
|
||||
@@ -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 — só 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>> 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>< 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>SÓ <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) já 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 já 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 já 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>
|
||||
já 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 já 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, já 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 || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Lê 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 já 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:
|
||||
* agendado→realizado | agendado→faltou | agendado→cancelado
|
||||
* realizado→agendado (REVERSE) | realizado→faltou (CROSS)
|
||||
* faltou→agendado (REVERSE) | cancelado→agendado (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. Lê 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 || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Lê 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).
|
||||
* Lê 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). Lê 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lê 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
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Lê 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 || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Lê 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 já
|
||||
* 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 || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Lê 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 (já 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 — só 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();
|
||||
@@ -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 || [];
|
||||
}
|
||||
|
||||
@@ -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 só 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) — só o owner
|
||||
|
|
||||
| RLS bloqueia INSERT/UPDATE/DELETE de templates is_system — só 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 só 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 || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Lê 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lê 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lê 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 — só 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 || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Lê 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 || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Lê 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.
|
||||
* Lê 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 só 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 (já 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 já 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 já 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 já 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 já 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 só 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, já 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lê 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lê 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 só 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;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ======================================================
|
||||
|
||||
@@ -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 || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Lê 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 só 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user