Compare commits
102 Commits
ec0a24f5c8
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b0b636c660 | |||
| 701d9f4fcc | |||
| 34412c6883 | |||
| 3a42b0696d | |||
| 7dd8cde8b4 | |||
| ec56f9429b | |||
| 89bf181742 | |||
| 342defecde | |||
| fff70e4a71 | |||
| 550c4ade44 | |||
| 473e0f026e | |||
| 9f3a047d6d | |||
| 8bf992910d | |||
| fa2b431a56 | |||
| eb42759979 | |||
| c17c547ed2 | |||
| 4f05c2cf1b | |||
| 512bcc979c | |||
| 61bb0d9c26 | |||
| 6c39c58dc8 | |||
| 4e1ebeba13 | |||
| 51c33e73b9 | |||
| 682840f355 | |||
| c6105df98a | |||
| 402def7539 | |||
| 5dc91614ad | |||
| 597f8c05d5 | |||
| 79425a3c9a | |||
| 87a1ac1358 | |||
| 6860628087 | |||
| 134f562a1f | |||
| bbbb08ba9d | |||
| 17f114f32f | |||
| c9afe8f009 | |||
| c7e311b851 | |||
| 0aabea7753 | |||
| 80cce772db | |||
| f1c24242e0 | |||
| b821db6438 | |||
| 0fafc28581 | |||
| 75e67eae5d | |||
| 9a6eb56827 | |||
| 652571da69 | |||
| 30367392ff | |||
| b40116fe5d | |||
| ffd8eab72d | |||
| dee89ccd84 | |||
| 6a8ee52ad8 | |||
| 7516468f78 | |||
| 20d2b3aee4 | |||
| ae1e1388b9 | |||
| 4024469952 | |||
| 661790d577 | |||
| 6807b447cb | |||
| 034c2c0f3d | |||
| 87833d4ec6 | |||
| 049dd91b9b | |||
| e7e3d1beb1 | |||
| aa587e849c | |||
| ee117eafe6 | |||
| b7f3c23ad6 | |||
| 9c518a2b44 | |||
| d7cd2541e4 | |||
| b1e8e010c0 | |||
| 2dae4a11ae | |||
| e7a9bdab5f | |||
| 36402cd0bf | |||
| 6ae651a8ae | |||
| 114d755f84 | |||
| 19caa42f3b | |||
| 4e42881d5e | |||
| 934c620295 | |||
| 8601ac0d70 | |||
| 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,210 @@ Chronological, append-only record of everything that's happened in this wiki.
|
||||
|
||||
---
|
||||
|
||||
## [2026-05-22 dia] session | Melissa UX overhaul + 5 docs saas (Fases 2-5)
|
||||
Touched: none codigo durable; 5 docs saas novas em development/saas-docs/
|
||||
|
||||
Sessao longa (~12 commits codigo + 5 docs). 2 grandes blocos:
|
||||
|
||||
BLOCO 1 — Melissa UI overhaul (manha):
|
||||
- Tray no canto inf. direito (substitui topbar band do topo): busca +
|
||||
plan-DEV + bell + ajuda + cog. Sibling de .melissa-dock (fora de
|
||||
.win11-summary) pra ficar interativa com secao aberta. Em <md (768px)
|
||||
collapse parcial — bell/help/cog/plan-DEV viram popup vertical no
|
||||
botao ⋮; dot vermelho no ⋮ quando ha notificacoes nao lidas.
|
||||
- Busca global unificada: MelissaBusca ganha parser de data (hoje/
|
||||
amanha/ontem/DD/MM/YYYY) + card azul "Ir para [data]" + emit
|
||||
goto-date. Popover da agenda (MelissaAgendaSearchPopover) deletado;
|
||||
Ctrl+K so vive na MelissaBusca. Lupa unica fica so na .melissa-tray
|
||||
(removida das toolbars de secoes pra evitar pollution mobile).
|
||||
- Dock: 4 builtins (Agenda · Pacientes · WhatsApp · Financeiro). MRU
|
||||
oculto em <md via @media (utility 'hidden' do tailwind perdia pro
|
||||
.dock-pin{display:grid} por carga).
|
||||
- Hero resumo: contagem "(x foi cancelado, x foi remarcado)" depois
|
||||
do chip atendimentos com gramatica plural.
|
||||
- Settings + Ajuda fecham ao clicar fora (mousedown capture + watch
|
||||
open). Cog ref + data-ajuda-toggle ignoram trigger pra evitar
|
||||
close+reopen.
|
||||
- Cronometro: pre-selecao paciente + autostart quando aberto via
|
||||
botao ⏱ na timeline (sessao em curso) ou card "Proximo paciente".
|
||||
abrir(opts) com { pacienteId, autostart, sessionPlan }. sessionPlan
|
||||
exibe "Programado: HH:MM – HH:MM" + badge "atrasada X min"; NAO
|
||||
desconta atraso auto. Confirm fechar quando ha sessao rodando/
|
||||
decorrido sem salvar. Chip minimizado oculta nome do paciente em
|
||||
<md (so icone + tempo).
|
||||
- Documents: linkage document_generated.documento_id agora preenchido
|
||||
no INSERT (era sempre NULL). Modo edit in-place via editingDocId:
|
||||
busca template+dados_preenchidos via loadGeneratedFromDocId, popula
|
||||
vars, pula pra step 'edit'; save substitui PDF no Storage e
|
||||
atualiza documents (preserva id+audit). Header amber "Editar
|
||||
documento" + botao "Substituir documento". Backfill SQL pra docs
|
||||
antigos (3 linkados, 5 orfaos no DB local).
|
||||
- DocumentPreviewDialog: wire-up dos 5 botoes da sidebar (download/
|
||||
editar/share/sign/delete) que estavam caindo no vazio.
|
||||
|
||||
BLOCO 2 — saas-docs (tarde):
|
||||
Padrao igual da 01-busca-global-melissa.json — JSON-fonte +
|
||||
SQL de import direto via $HTML$/$FAQ$ dollar quoting. 5 docs novas
|
||||
(03 a 07), cada uma com 12 FAQ itens:
|
||||
|
||||
- 03 Documentos do paciente — pagina_path /melissa/paciente,
|
||||
categoria Documentos
|
||||
- 04 Templates de documentos — pagina_path /melissa/documentos-
|
||||
templates, categoria Documentos
|
||||
- 05 Assinatura eletronica — pagina_path /melissa/paciente,
|
||||
categoria Documentos
|
||||
- 06 Recibo profissional — pagina_path /melissa/agenda, categoria
|
||||
Financeiro (cobre fluxo do AgendaEventoFinanceiroPanel)
|
||||
- 07 Relatorios e exportacao — pagina_path /melissa/relatorios,
|
||||
categoria Relatorios
|
||||
|
||||
Todas importadas no DB local via docker exec psql. Total acumulado:
|
||||
7 docs ativas em saas_docs (busca + cronometro + os 5 novos).
|
||||
|
||||
PROXIMA SESSAO (retomar 23/05):
|
||||
- Fase 6 RESTANTE: C12 UX iter (cronometro/sessao antecipar pgto —
|
||||
flow DB ja ok, UX obscura adiada em 20/05). Unico item de codigo
|
||||
da lista de ontem.
|
||||
- Fase 7 RESTANTE: Regressao Agenda C7-C13 (validacao manual; eu
|
||||
nao executo, so listo plano de teste se quiser).
|
||||
- Antes/depois: olhada no ROADMAP.md canonico pra panorama MVP
|
||||
real. Itens visiveis ainda no horizonte: #12 papel timbrado
|
||||
(bloqueado, codigo no UniaoApp), #15 NFS-e (esforco L), §1.5
|
||||
Sentry+qualidade, Asaas Fase B (bloqueado), M4 cutover billing
|
||||
(depende decisoes #2/#3/#6), validacao centralizada CPF/CNPJ/tel.
|
||||
|
||||
ITENS TESTADOS HOJE (✅): tray + busca unificada + cronometro
|
||||
evento-aware + edicao in-place de docs gerados + Fase 2.7-2.9
|
||||
(gerar PDF, vars CRP/UF, tipo_documento='outro').
|
||||
|
||||
PUSH: 12 commits pushados (c17c547..701d9f4) usando workaround SSL
|
||||
(git -c http.sslVerify=false push). Credenciais pediram 1x, depois
|
||||
cacheou pra sessao toda.
|
||||
|
||||
## [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 +1280,365 @@ 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.
|
||||
|
||||
## [2026-05-21 afternoon] session | Compliance CFP #6 + #7 fechados
|
||||
Touched: none (durable em development/02-auditoria/PADRONIZACAO.md + memoria padronizacao_sweep)
|
||||
|
||||
Fechou Fase 1.2 Compliance basico BR do ROADMAP. 5 commits tematicos.
|
||||
|
||||
#6 (consent forms) — biblioteca de templates LGPD-compliant:
|
||||
- Migration 20260521000005 estende CHECK constraint de document_templates.tipo
|
||||
com 'termo_lgpd' + 'autorizacao_gravacao'
|
||||
- Seed seed_060 insere 2 templates globais novos (Consentimento LGPD com
|
||||
Art. 18 direitos do titular + Autorizacao para Gravacao de Sessao) +
|
||||
UPDATE no tcle_online amend cláusula LGPD explicita
|
||||
- Biblioteca completa pos-amend: TCLE, tcle_online (telehealth),
|
||||
autorizacao_menor (TCLE menores), termo_sigilo, termo_lgpd, autorizacao_
|
||||
gravacao + 9 outros tipos existentes
|
||||
|
||||
#7 (assinatura eletronica no portal) — fluxo end-to-end:
|
||||
- Migration 20260521000006: 3 RPCs (sign_document_by_signature_id +
|
||||
sign_document_by_token + get_signable_document_by_token). IP/UA
|
||||
capturados SERVER-SIDE via inet_client_addr() e current_setting
|
||||
('request.headers') — anti-spoof. Hash SHA-256 vem do cliente
|
||||
pra integridade
|
||||
- Migration 20260521000007: RPC list_my_signatures que cruza auth.uid()
|
||||
por 3 caminhos (signatario_id, signatario_email, patient.user_id)
|
||||
- DocumentSignatures.service ganha 4 wrappers: signByPortal,
|
||||
signByToken, getSignableDocumentByToken, listMySignatures
|
||||
- useDocumentSignatures composable novo (Tipo A blueprint)
|
||||
- PortalDocumentos.vue (nova) — lista pendencias do paciente logado
|
||||
com KPIs + filtro + botao "Assinar agora" que aponta pra share link
|
||||
- portal.menu.js ganha item "Documentos > Para assinar"
|
||||
- SharedDocumentPage.vue estendida: painel azul abaixo do preview
|
||||
com aviso LGPD/CFP + checkbox aceite + selecao multi-signatario
|
||||
+ botoes Assinar/Recusar com loading + computa SHA-256 do PDF
|
||||
baixado antes do sign
|
||||
- DocumentSignatureDialog (terapeuta-side, ja existia) ganha
|
||||
checkbox "Gerar link publico para assinatura" (default ON) +
|
||||
select validade (24h/3d/7d/30d) + bloco emerald com URL + copy
|
||||
|
||||
Fluxo end-to-end: terapeuta cria signature requests + share_link
|
||||
no dialog -> copia URL -> envia via WA/email -> paciente abre
|
||||
/shared/document/:token -> visualiza doc -> aceite -> assinatura
|
||||
registrada via RPC sign_document_by_token (IP/UA/timestamp/hash
|
||||
gravados server-side em document_signatures).
|
||||
|
||||
Pos-MVP nice-to-have: notificacao automatica do paciente quando
|
||||
signature criada (depende de Modulo 6 notifications WA/email
|
||||
channel factory). Por ora, terapeuta envia link manualmente.
|
||||
|
||||
PROXIMO: outras 5 secoes do ROADMAP Fase 1 (Asaas Fase B bloqueada,
|
||||
UX §1.3, Fiscal §1.4, Qualidade §1.5).
|
||||
|
||||
## [2026-05-21 evening] session | ROADMAP #14 Recibo profissional PDF
|
||||
Touched: none (durable em development/02-auditoria/PADRONIZACAO.md + memoria)
|
||||
|
||||
Fecha §1.4 Fiscal minimo (parcial — #15 NFS-e fica pra depois).
|
||||
|
||||
src/utils/valorExtenso.js — helper pt-BR completo ate 999 milhoes.
|
||||
"R$ 1.234,56" -> "mil duzentos e trinta e quatro reais e cinquenta
|
||||
e seis centavos".
|
||||
|
||||
DocumentGenerate.service estendido:
|
||||
- loadTherapistData puxa professional_registration_type/numero/uf
|
||||
(#5 migration) e auto-formata terapeuta_registro: "CRP 12345/SP".
|
||||
terapeuta_crp legacy mantido pra compat.
|
||||
- loadClinicData formata tenants.cpf_cnpj (11 dig CPF, 14 dig CNPJ).
|
||||
- loadAllVariables aceita extras + computa valor + valor_extenso +
|
||||
forma_pagamento.
|
||||
- saveGeneratedDocument ganha templateTipo + mapping
|
||||
TEMPLATE_TYPE_TO_DOC_TYPE (recibo_pagamento -> 'recibo', laudo ->
|
||||
'laudo' etc). Antes era hardcoded 'laudo' pra TUDO — bug.
|
||||
- emitirReciboParaSessao(eventoId, opts) — quick path: busca
|
||||
template, carrega vars, gera PDF, salva, download. One-call.
|
||||
|
||||
Migration 20260521000008 substitui no template recibo_pagamento
|
||||
"Psicologo(a) - CRP {{terapeuta_crp}}" por "{{terapeuta_registro}}".
|
||||
Universal pra qualquer conselho (CRP/CRM/CRFa/CREFITO/CRESS/CRN).
|
||||
|
||||
DocumentTemplates.service.TEMPLATE_VARIABLES ganha 4 entries de
|
||||
registro profissional. useDocumentGenerate passa templateTipo.
|
||||
|
||||
AgendaEventoFinanceiroPanel ganha botao "Emitir recibo" outlined
|
||||
quando record.status === 'paid'. Toast + loading state.
|
||||
|
||||
PROXIMO: UX §1.3 (busca global + recently viewed + papel timbrado
|
||||
+ relatorios export) OU sweep residual (M4 cutover billing decisoes
|
||||
#2/#3/#6).
|
||||
|
||||
## [2026-05-21 evening] session | ROADMAP §1.3 UX 3/4 (#10/#11/#13)
|
||||
Touched: none
|
||||
|
||||
3 commits fechando 3 dos 4 itens da Fase 1.3 UX:
|
||||
|
||||
#10 Busca global topbar — GlobalSearch.vue ja estava feito no Rail/
|
||||
classic. **MelissaBusca promovida** de preview client-side pra RPC
|
||||
search_global com debounce 200ms + searchSeq pra ignorar respostas
|
||||
obsoletas. 3 grupos novos exibidos quando RPC retorna: sessoes,
|
||||
documentos, cadastros recebidos. @paciente no MelissaLayout
|
||||
corrigido (antes ignorava payload — bug). Emits novos: documento,
|
||||
intake.
|
||||
|
||||
#11 Recently viewed — composables/useRecentPatients.js (localStorage
|
||||
por user_id, max 5, dedup, eventos CustomEvent + 'storage' pra sync
|
||||
entre instancias no mesmo browser). registerPatientVisit chamado
|
||||
em MelissaPaciente.loadAll e PatientProntuario.loadDetail. Grupo
|
||||
"Acessados recentemente" no GlobalSearch.vue + MelissaBusca.vue
|
||||
quando query vazia. Decisao: localStorage > tabela user_recent_access
|
||||
por simplicidade + zero round-trip por visita.
|
||||
|
||||
#13 Relatorios export PDF/Excel — services/reportExport.service.js
|
||||
com exportSessionsToPDF (pdf.service HTML→PDF + KPIs + tabela A4),
|
||||
exportSessionsToXLSX (exceljs com import dinamico, frozen header,
|
||||
alternating rows, branded), exportSessionsToCSV (vanilla, BOM UTF-8,
|
||||
separador ';'). 3 botoes pi-file-pdf/pi-file-excel/pi-table em
|
||||
RelatoriosPage.vue (therapist) + MelissaRelatorios.vue. Respeita
|
||||
filtro de status da tabela.
|
||||
|
||||
#12 Papel timbrado — BLOQUEADO: codigo no UniaoApp. Quando user
|
||||
importar, plugar como cabecalho_html/rodape_html global em
|
||||
document_templates ou setting tenants.letterhead_html.
|
||||
|
||||
PROXIMO: NFS-e (#15, esforco L), §1.5 Sentry (#18 nao-teste),
|
||||
sweep residual (M4 cutover billing — bloqueado decisoes #2/#3/#6),
|
||||
ou agenda Fase 4 residual.
|
||||
|
||||
## [2026-05-21 night] session | agenda Fase 4: C12 UX iter + utils extract
|
||||
Touched: none (durable em memoria project_c12_antecipar_iterar atualizada)
|
||||
|
||||
Iniciou agenda Fase 4 residual. Auditoria revelou: popover snapshot
|
||||
e reverse transition trava JA estavam done de fato (commits f83315b
|
||||
+ 5684297 durante C11). Pendentes reais: C12 UX, replicacao Rail/
|
||||
Clinica, doc ajuda.
|
||||
|
||||
3 commits:
|
||||
|
||||
1) agenda C12 UX: "Trocar metodo" em vez de Revogar+Antecipar
|
||||
MelissaEventoPanel ganha 2 botoes quando isAntecipacaoAtiva
|
||||
(antes era so "Revogar"). MelissaLayout: anteciparMode ref +
|
||||
onTrocarMetodoAntecipacao pre-seleciona metodo atual. confirm
|
||||
Antecipar Pagamento ramifica: mode='update' faz UPDATE no paid
|
||||
existente (sem cancel cycle). Result: trocar metodo gera 0
|
||||
records cancelled.
|
||||
|
||||
2) agenda C12 UX: filtrar cancelled do dialog Lancamentos
|
||||
lancamentosShowHistory ref (default false) + lancamentosFiltered
|
||||
computed. UI: badge "{N} cancelado(s) ocultos" + toggle
|
||||
Mostrar/Ocultar historico. Cards cancelled atenuados (opacity
|
||||
0.55, border-dashed, line-through na desc) quando expandidos.
|
||||
Combina com Trocar metodo — caso 99% so ve ativos.
|
||||
|
||||
3) agenda Fase A: extrai utils puros pra features/agenda/utils
|
||||
Decomposicao em prep pra Rail/Clinica adotarem. 4 arquivos novos:
|
||||
eventoTipo.js + dbFields.js + timeHelpers.js + colors.js.
|
||||
useMelissaAgenda.js: 2863L -> 2792L (-71L), imports via aliases
|
||||
pra nao mexer em 70+ callsites internos. Zero impacto comportamental.
|
||||
|
||||
C12 UX iter 3 (validar antecipar->Realizada nao duplica record) JA
|
||||
estava implementado em commits 00c4168 + f83315b — comentario no
|
||||
codigo de _loadStatusChangeContext confirma "ctx.existingPaidRecord"
|
||||
pra evitar oferecer "Gerar cobranca nova".
|
||||
|
||||
PENDENTE replicacao Rail/Clinica:
|
||||
- Fase B (service de billing): extrair _loadStatusChangeContext,
|
||||
_applyStatusDecisions, _createPackageContract, _materializeAndCharge
|
||||
PerSession num service reusavel. ~2-3h, risco medio (precisa nao
|
||||
quebrar 7 ciclos da agenda C7-C13).
|
||||
- Fase C/D: adapter em AgendaTerapeutaPage/AgendaClinicaPage.
|
||||
|
||||
ATUAL: decidir entre Fase B agora ou pausar replicacao + atacar
|
||||
outro residual (NFS-e, sweep, etc).
|
||||
|
||||
## [2026-05-21 late night] session | agenda Fase B (B1+B2) — agendaBilling.service
|
||||
Touched: none
|
||||
|
||||
Continua decomposicao da agenda pra Rail/Clinica. 2 commits cobrindo
|
||||
Fase B inteira (read-only + mutations):
|
||||
|
||||
Fase B1 (e7e3d1b): agendaBilling.service nasce com
|
||||
- computeSeriePrice (puro)
|
||||
- generateOccurrenceDates (puro)
|
||||
- needsStatusConfirmDialog (puro)
|
||||
- loadStatusChangeContext (read-only, 5 deps)
|
||||
useMelissaAgenda: 2792L -> 2593L (-199L)
|
||||
|
||||
Fase B2 (049dd91): adiciona mutations
|
||||
- applyStatusDecisions (~330L — todas as decisoes do dialog)
|
||||
- createPackageContract (~140L — upfront/saldo)
|
||||
- materializeAndChargePerSession (~90L — per_session)
|
||||
useMelissaAgenda: 2593L -> 2042L (-551L)
|
||||
|
||||
TOTAL fases A+B1+B2: 3033L -> 2042L (-991L extraidas, ~33% reducao).
|
||||
3 pages (Melissa/Rail/Clinica) agora podem reusar mesmo billing
|
||||
core. Comportamento Melissa identico — codigo movido linha-a-linha,
|
||||
so refactor de signature pra receber deps explicitas em vez de
|
||||
closure.
|
||||
|
||||
Pendente: Fase C (adapter Rail) + Fase D (adapter Clinica) +
|
||||
doc ajuda.
|
||||
|
||||
## [2026-05-21 dawn] session | migrations + seeds aplicados no banco local
|
||||
Touched: none
|
||||
|
||||
Aplicou todas as 13 migrations pendentes do dia (clinical_notes
|
||||
tables/rls/versioning + documents link + accept_invite RPC + asaas
|
||||
tables/rls + profiles registration + specialties + document_templates
|
||||
consent types + sign_document RPCs + list_my_signatures + recibo
|
||||
amend) + 3 seeds novos (seed_040 clinical_note_templates 6 entries +
|
||||
seed_050 specialties 34 entries + seed_060 consent forms 2 templates
|
||||
LGPD/Gravacao + amend tcle_online).
|
||||
|
||||
Gotcha re-validado (memoria atualizada): migration 20260521000005
|
||||
estendendo CHECK dt_tipo_check foi marcada aplicada pelo db.cjs mas
|
||||
silenciosamente ROLLBACK (postgres nao e owner de document_templates).
|
||||
Detectado quando seed_060 falhou com violates check constraint.
|
||||
Re-rodada via `docker exec -i ... sh -c 'psql -U supabase_admin -h
|
||||
127.0.0.1 -d postgres'` (trust pra 127.0.0.1/32 em pg_hba.conf).
|
||||
|
||||
db.config.json estendido com os 3 seeds novos (system group, ordem
|
||||
seed_040 -> seed_050 -> seed_060) pra setup do zero rodar tudo.
|
||||
|
||||
Sanity check pos-aplicacao:
|
||||
- 5 RPCs novas (accept_tenant_invite + 3 sign + list_my_signatures)
|
||||
- 8 tabelas novas (clinical_notes + versions + templates + asaas
|
||||
customers/payments/webhook + profile_specialties + specialties)
|
||||
- 17 document_templates global (15 existentes + 2 LGPD/Gravacao)
|
||||
- 34 specialties seedadas
|
||||
- 6 clinical_note_templates seedados
|
||||
- 3 colunas professional_registration_* em profiles
|
||||
- Backup automatico criado em backups/2026-05-21/
|
||||
|
||||
## [2026-05-21 deep night] session | agenda Fases C + D — Rail+Clinica adotam billing core
|
||||
Touched: none
|
||||
|
||||
Replicacao Rail/Clinica fechada via composable reusavel
|
||||
useAgendaStatusChange (Tipo A wrapper sobre agendaBilling.service).
|
||||
|
||||
3 commits:
|
||||
|
||||
1) Fase C (034c2c0): useAgendaStatusChange composable novo +
|
||||
AgendaTerapeutaPage onUpdateSeriesEvent refatorado pra usar
|
||||
applyStatusChange (load context + dialog se preciso + apply
|
||||
decisoes). AgendaStatusChangeConfirmDialog plugado no template.
|
||||
|
||||
Antes: Rail fazia so update(id, { status }) cru. Zero das
|
||||
features C7-C13.
|
||||
|
||||
Depois: Rail tem feature parity com Melissa pra status change.
|
||||
Multa por falta, taxa cancelamento tardio, consumir saldo,
|
||||
gerar cobranca pacote saldo, reverse transition trava.
|
||||
|
||||
2) Fase D (6807b44): AgendaClinicaPage espelha Fase C usando o
|
||||
mesmo composable. Diferencas adaptadas (updateClinic + createClinic
|
||||
recebem tenantId arg explicito).
|
||||
|
||||
3) Pendente residual:
|
||||
- Indicadores visuais (3 canais: barra esquerda verde / badge $
|
||||
amber / neutro) ainda nao replicados no Rail/Clinica — sao
|
||||
custom event classNames do FullCalendar, requerem _payment
|
||||
StateMap.
|
||||
- Antecipar/Revogar/Trocar metodo no popover do Rail — Rail
|
||||
nao tem popover separado, usa AgendaEventDialog direto;
|
||||
precisa refactor maior pra acomodar.
|
||||
- Doc ajuda completa.
|
||||
|
||||
ESTADO: agenda Fase 4 residual 70% fechada. C7-C13 core flow
|
||||
(status change com billing) agora cobre os 3 layouts. UI fina
|
||||
(popover antecipar, indicadores visuais) fica pra iter incremental
|
||||
sob demanda.
|
||||
|
||||
TOTAL DA SESSAO (24/05 - 25/05, ~24 commits):
|
||||
- CFP #6/#7 (Compliance Fase 1.2 ✅)
|
||||
- #14 Recibo profissional PDF
|
||||
- §1.3 UX 3/4 (#10 #11 #13)
|
||||
- C12 UX iter (Trocar metodo + filtro cancelled)
|
||||
- Agenda decomposicao A+B1+B2: -991L em useMelissaAgenda (~33%)
|
||||
- Agenda Fases C+D: Rail+Clinica adotam billing core
|
||||
- useAgendaStatusChange composable novo
|
||||
|
||||
## [2026-05-21 23:00] session | Melissa Fase 2 UX iter + bug isFinite(null)
|
||||
Touched: feedback_isfinite_strict, feedback_teleport_body_styles
|
||||
Detalhes:
|
||||
|
||||
Sessao de testes manuais Fase 2 (templates + paciente.documentos).
|
||||
4 ajustes UX + 1 bug funcional resolvido. 5 commits, 0 push (SSL
|
||||
self-signed Gitea — user faz manual amanha).
|
||||
|
||||
1) MelissaPatientDocuments (4e1ebeb, 6c39c58):
|
||||
Aba Documentos no /melissa/paciente?id=X foi convertida de embed
|
||||
<DocumentsListPage> pra pagina nativa 2-col Melissa. Drawer mobile
|
||||
bugava (transform/filter em ancestrais trapando position:fixed).
|
||||
Fix:
|
||||
- <Teleport to="body"> no drawer + backdrop pra escapar stacking
|
||||
- styles do drawer movidos pra <style> nao-scoped (teleport perde
|
||||
data-v attrs do scoped)
|
||||
- wrapper teleportado recebe class "win11-root" pra herdar vars
|
||||
--m-* (definidas nesse escopo no MelissaLayout)
|
||||
- cascata --mpd-bg/border/text: --m-* -> --p-* -> hardcoded
|
||||
|
||||
2) DocumentGenerateDialog (61bb0d9, 512bcc9):
|
||||
Inputs trocados pra FloatLabel variant="on". Adicionado map de
|
||||
ORIGEM dos campos (TEMPLATE_VARIABLES.source) — hint embaixo de
|
||||
cada campo vazio explica onde cadastrar (ex: "Perfil -> Registro
|
||||
Profissional"). Banner verde/amber no topo conta preenchidos.
|
||||
|
||||
3) Bug critico (4f05c2c) — RAIZ do "campos vem vazio mesmo com
|
||||
profile preenchido":
|
||||
loadAllVariables crashava com TypeError "Cannot read properties
|
||||
of null (reading toFixed)" quando NAO havia sessao vinculada
|
||||
(agendaEventoId=null) E sem extras.valor. Toda a Promise
|
||||
estourava, variables zerava.
|
||||
|
||||
Causa: isFinite(null) global retorna TRUE (Number(null)===0),
|
||||
entrava no branch valorNum.toFixed e crashava.
|
||||
|
||||
Fix: trocar por Number.isFinite (strict, nao coerce).
|
||||
Salvo como memoria feedback_isfinite_strict.
|
||||
|
||||
PROXIMA SESSAO (retomar amanha 22/05):
|
||||
- Continuar Fase 2: 2.7-2.9 (gerar PDF dentro da aba Documentos
|
||||
do paciente, conferir vars CRP/UF preenchem, doc aparece como
|
||||
tipo_documento='outro')
|
||||
- Gerar JSON docs Fase 2 (#6 + templates page)
|
||||
- Fase 3: Portal assinatura #7
|
||||
- Fase 4: Recibo profissional #14 testes
|
||||
- Fase 5: Relatorios export #13
|
||||
- Fase 6: C12 UX iter (deferred 20/05)
|
||||
- Fase 7: Regressao Agenda C7-C13
|
||||
|
||||
PUSH PENDENTE: 35 commits ahead of origin/main; SSL self-signed
|
||||
do Gitea exige `git -c http.sslVerify=false push origin main`
|
||||
+ credenciais (user faz manual).
|
||||
|
||||
@@ -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`
|
||||
@@ -22,7 +22,10 @@
|
||||
"seed_015_document_templates.sql",
|
||||
"seed_030_dev_phases_items.sql",
|
||||
"seed_031_dev_auditoria.sql",
|
||||
"seed_032_dev_competitors.sql"
|
||||
"seed_032_dev_competitors.sql",
|
||||
"seed_040_clinical_note_templates.sql",
|
||||
"seed_050_specialties.sql",
|
||||
"seed_060_consent_forms_extra.sql"
|
||||
],
|
||||
"test_data": [
|
||||
"seed_020_test_data.sql"
|
||||
|
||||
@@ -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,44 @@
|
||||
-- ============================================================================
|
||||
-- Compliance CFP #6 — Tipos de consent form (LGPD + Gravação)
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Estende o CHECK constraint de document_templates.tipo para acomodar dois
|
||||
-- novos tipos de consent form exigidos pela LGPD e pela prática clínica:
|
||||
-- • termo_lgpd — Consentimento de tratamento de dados pessoais
|
||||
-- • autorizacao_gravacao — Autorização de gravação de sessão (áudio/vídeo)
|
||||
--
|
||||
-- ROADMAP item #1.2 #6 (Biblioteca de consent forms editáveis).
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE public.document_templates
|
||||
DROP CONSTRAINT IF EXISTS dt_tipo_check;
|
||||
|
||||
ALTER TABLE public.document_templates
|
||||
ADD CONSTRAINT dt_tipo_check CHECK (
|
||||
tipo = ANY (ARRAY[
|
||||
'declaracao_comparecimento',
|
||||
'atestado_psicologico',
|
||||
'relatorio_acompanhamento',
|
||||
'recibo_pagamento',
|
||||
'termo_consentimento',
|
||||
'encaminhamento',
|
||||
'contrato_servicos',
|
||||
'tcle',
|
||||
'autorizacao_menor',
|
||||
'laudo_psicologico',
|
||||
'parecer_psicologico',
|
||||
'termo_sigilo',
|
||||
'declaracao_inicio_tratamento',
|
||||
'termo_alta',
|
||||
'tcle_online',
|
||||
'termo_lgpd',
|
||||
'autorizacao_gravacao',
|
||||
'outro'
|
||||
])
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN public.document_templates.tipo IS
|
||||
'Tipo do template. Inclui consent forms (tcle, tcle_online, autorizacao_menor, termo_sigilo, termo_lgpd, autorizacao_gravacao).';
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,251 @@
|
||||
-- ============================================================================
|
||||
-- Compliance CFP #7 — RPCs de assinatura eletrônica
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Cria 2 RPCs que registram assinatura capturando IP server-side (anti-spoof)
|
||||
-- via inet_client_addr() e user-agent via request headers do Supabase.
|
||||
--
|
||||
-- • sign_document_by_signature_id — paciente logado assina via portal
|
||||
-- • sign_document_by_token — terceiro assina via share link público
|
||||
--
|
||||
-- ROADMAP item #1.2 #7 (Assinatura eletrônica pelo paciente no portal,
|
||||
-- simples, com IP+timestamp). Não usa ICP-Brasil — é assinatura simples
|
||||
-- com audit trail (IP, UA, timestamp, hash SHA-256 do documento).
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- 1. sign_document_by_signature_id
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Para signatários LOGADOS no portal/sistema. SECURITY INVOKER — a RLS de
|
||||
-- document_signatures continua aplicando (signatario_id = auth.uid() ou
|
||||
-- tenant_members). RPC só serve pra centralizar captura de IP + UA + hash.
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION public.sign_document_by_signature_id(
|
||||
p_signature_id uuid,
|
||||
p_hash_documento text DEFAULT NULL
|
||||
) RETURNS public.document_signatures
|
||||
LANGUAGE plpgsql
|
||||
SECURITY INVOKER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_row public.document_signatures;
|
||||
v_ip inet;
|
||||
v_ua text;
|
||||
BEGIN
|
||||
IF p_signature_id IS NULL THEN
|
||||
RAISE EXCEPTION 'p_signature_id obrigatório' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
-- Captura IP e UA do request (best-effort — pode vir NULL em alguns ambientes)
|
||||
v_ip := inet_client_addr();
|
||||
BEGIN
|
||||
v_ua := current_setting('request.headers', true)::json ->> 'user-agent';
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
v_ua := NULL;
|
||||
END;
|
||||
|
||||
UPDATE public.document_signatures
|
||||
SET status = 'assinado',
|
||||
ip = v_ip,
|
||||
user_agent = v_ua,
|
||||
assinado_em = now(),
|
||||
hash_documento = COALESCE(p_hash_documento, hash_documento),
|
||||
atualizado_em = now()
|
||||
WHERE id = p_signature_id
|
||||
AND status IN ('pendente', 'enviado')
|
||||
RETURNING * INTO v_row;
|
||||
|
||||
IF v_row.id IS NULL THEN
|
||||
RAISE EXCEPTION 'Assinatura não encontrada ou já processada' USING ERRCODE = 'P0002';
|
||||
END IF;
|
||||
|
||||
RETURN v_row;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.sign_document_by_signature_id(uuid, text) IS
|
||||
'Assinatura via portal logado. Captura IP/UA server-side. RLS aplica (SECURITY INVOKER).';
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.sign_document_by_signature_id(uuid, text) TO authenticated;
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- 2. sign_document_by_token
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Para signatários NÃO LOGADOS via share link público. SECURITY DEFINER —
|
||||
-- bypassa RLS. Valida o share_link (token, ativo, expira_em, usos_max),
|
||||
-- localiza o signatário PENDENTE associado ao documento (signatario_email
|
||||
-- opcional p/ desambiguar quando há múltiplos), assina, incrementa usos.
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION public.sign_document_by_token(
|
||||
p_token text,
|
||||
p_signature_id uuid DEFAULT NULL,
|
||||
p_hash_documento text DEFAULT NULL
|
||||
) RETURNS public.document_signatures
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_link public.document_share_links;
|
||||
v_sig public.document_signatures;
|
||||
v_ip inet;
|
||||
v_ua text;
|
||||
BEGIN
|
||||
IF p_token IS NULL OR length(p_token) < 32 THEN
|
||||
RAISE EXCEPTION 'Token inválido' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
-- Valida share_link
|
||||
SELECT * INTO v_link
|
||||
FROM public.document_share_links
|
||||
WHERE token = p_token
|
||||
AND ativo = true
|
||||
AND expira_em > now()
|
||||
AND usos < usos_max
|
||||
LIMIT 1;
|
||||
|
||||
IF v_link.id IS NULL THEN
|
||||
RAISE EXCEPTION 'Link expirado, inválido ou esgotado' USING ERRCODE = 'P0002';
|
||||
END IF;
|
||||
|
||||
-- Localiza a signature pendente do documento. Se p_signature_id veio,
|
||||
-- é desambiguação (multi-signatário); senão pega a primeira pendente
|
||||
-- por ordem.
|
||||
IF p_signature_id IS NOT NULL THEN
|
||||
SELECT * INTO v_sig
|
||||
FROM public.document_signatures
|
||||
WHERE id = p_signature_id
|
||||
AND documento_id = v_link.documento_id
|
||||
AND status IN ('pendente', 'enviado')
|
||||
LIMIT 1;
|
||||
ELSE
|
||||
SELECT * INTO v_sig
|
||||
FROM public.document_signatures
|
||||
WHERE documento_id = v_link.documento_id
|
||||
AND status IN ('pendente', 'enviado')
|
||||
ORDER BY ordem ASC, criado_em ASC
|
||||
LIMIT 1;
|
||||
END IF;
|
||||
|
||||
IF v_sig.id IS NULL THEN
|
||||
RAISE EXCEPTION 'Nenhuma assinatura pendente para este documento' USING ERRCODE = 'P0002';
|
||||
END IF;
|
||||
|
||||
-- Captura IP/UA
|
||||
v_ip := inet_client_addr();
|
||||
BEGIN
|
||||
v_ua := current_setting('request.headers', true)::json ->> 'user-agent';
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
v_ua := NULL;
|
||||
END;
|
||||
|
||||
-- Assina
|
||||
UPDATE public.document_signatures
|
||||
SET status = 'assinado',
|
||||
ip = v_ip,
|
||||
user_agent = v_ua,
|
||||
assinado_em = now(),
|
||||
hash_documento = COALESCE(p_hash_documento, hash_documento),
|
||||
atualizado_em = now()
|
||||
WHERE id = v_sig.id
|
||||
RETURNING * INTO v_sig;
|
||||
|
||||
-- Incrementa contador de usos do share_link
|
||||
UPDATE public.document_share_links
|
||||
SET usos = usos + 1
|
||||
WHERE id = v_link.id;
|
||||
|
||||
RETURN v_sig;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.sign_document_by_token(text, uuid, text) IS
|
||||
'Assinatura via share link público. SECURITY DEFINER — valida token, captura IP/UA, incrementa usos. p_signature_id é opcional pra desambiguar multi-signatário.';
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.sign_document_by_token(text, uuid, text) TO anon, authenticated;
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- 3. get_signable_document_by_token
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- View helper que retorna info do documento + signatários pendentes via token,
|
||||
-- sem assinar. Permite a página pública renderizar antes do click.
|
||||
-- SECURITY DEFINER porque share_link tem RLS pública mas documents+signatures
|
||||
-- têm RLS por owner/tenant.
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
CREATE OR REPLACE FUNCTION public.get_signable_document_by_token(
|
||||
p_token text
|
||||
) RETURNS jsonb
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_link public.document_share_links;
|
||||
v_doc public.documents;
|
||||
v_sigs jsonb;
|
||||
BEGIN
|
||||
IF p_token IS NULL OR length(p_token) < 32 THEN
|
||||
RAISE EXCEPTION 'Token inválido' USING ERRCODE = '22023';
|
||||
END IF;
|
||||
|
||||
SELECT * INTO v_link
|
||||
FROM public.document_share_links
|
||||
WHERE token = p_token
|
||||
AND ativo = true
|
||||
AND expira_em > now()
|
||||
AND usos < usos_max
|
||||
LIMIT 1;
|
||||
|
||||
IF v_link.id IS NULL THEN
|
||||
RETURN jsonb_build_object('valid', false, 'error', 'expired_or_invalid');
|
||||
END IF;
|
||||
|
||||
SELECT * INTO v_doc
|
||||
FROM public.documents
|
||||
WHERE id = v_link.documento_id
|
||||
AND deleted_at IS NULL
|
||||
LIMIT 1;
|
||||
|
||||
IF v_doc.id IS NULL THEN
|
||||
RETURN jsonb_build_object('valid', false, 'error', 'document_not_found');
|
||||
END IF;
|
||||
|
||||
SELECT jsonb_agg(
|
||||
jsonb_build_object(
|
||||
'id', s.id,
|
||||
'signatario_tipo', s.signatario_tipo,
|
||||
'signatario_nome', s.signatario_nome,
|
||||
'signatario_email', s.signatario_email,
|
||||
'ordem', s.ordem,
|
||||
'status', s.status,
|
||||
'assinado_em', s.assinado_em
|
||||
) ORDER BY s.ordem
|
||||
) INTO v_sigs
|
||||
FROM public.document_signatures s
|
||||
WHERE s.documento_id = v_doc.id;
|
||||
|
||||
RETURN jsonb_build_object(
|
||||
'valid', true,
|
||||
'document', jsonb_build_object(
|
||||
'id', v_doc.id,
|
||||
'nome_original', v_doc.nome_original,
|
||||
'mime_type', v_doc.mime_type,
|
||||
'tamanho_bytes', v_doc.tamanho_bytes,
|
||||
'bucket_path', v_doc.bucket_path,
|
||||
'storage_bucket', v_doc.storage_bucket,
|
||||
'tipo_documento', v_doc.tipo_documento
|
||||
),
|
||||
'signatures', COALESCE(v_sigs, '[]'::jsonb),
|
||||
'expira_em', v_link.expira_em,
|
||||
'usos_restantes', v_link.usos_max - v_link.usos
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.get_signable_document_by_token(text) IS
|
||||
'Retorna documento + signatários pendentes via token. Usado pela página pública antes de assinar.';
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.get_signable_document_by_token(text) TO anon, authenticated;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,102 @@
|
||||
-- ============================================================================
|
||||
-- Compliance CFP #7 — RPC list_my_signatures (portal do paciente)
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Retorna as solicitações de assinatura do paciente logado (auth.uid()
|
||||
-- associado a patients.user_id). SECURITY DEFINER pra bypassar a RLS de
|
||||
-- document_signatures (que hoje só libera pra tenant_members).
|
||||
--
|
||||
-- Cada item já vem com o share_link.token associado, pra que o portal
|
||||
-- aponte direto pra /shared/document/:token onde o usuário vai assinar.
|
||||
-- O link público é gerado quando o terapeuta solicita a assinatura.
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.list_my_signatures(
|
||||
p_status text[] DEFAULT NULL
|
||||
) RETURNS TABLE (
|
||||
signature_id uuid,
|
||||
documento_id uuid,
|
||||
tenant_id uuid,
|
||||
signatario_tipo text,
|
||||
status text,
|
||||
ordem smallint,
|
||||
assinado_em timestamptz,
|
||||
criado_em timestamptz,
|
||||
-- Documento
|
||||
nome_original text,
|
||||
tipo_documento text,
|
||||
mime_type text,
|
||||
-- Share link (primeiro válido encontrado pro doc)
|
||||
share_token text,
|
||||
share_expira_em timestamptz
|
||||
)
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_uid uuid;
|
||||
BEGIN
|
||||
v_uid := auth.uid();
|
||||
IF v_uid IS NULL THEN
|
||||
RAISE EXCEPTION 'Sessão inválida' USING ERRCODE = '28000';
|
||||
END IF;
|
||||
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
s.id AS signature_id,
|
||||
s.documento_id AS documento_id,
|
||||
s.tenant_id AS tenant_id,
|
||||
s.signatario_tipo AS signatario_tipo,
|
||||
s.status AS status,
|
||||
s.ordem AS ordem,
|
||||
s.assinado_em AS assinado_em,
|
||||
s.criado_em AS criado_em,
|
||||
d.nome_original AS nome_original,
|
||||
d.tipo_documento AS tipo_documento,
|
||||
d.mime_type AS mime_type,
|
||||
sl.token AS share_token,
|
||||
sl.expira_em AS share_expira_em
|
||||
FROM public.document_signatures s
|
||||
JOIN public.documents d ON d.id = s.documento_id AND d.deleted_at IS NULL
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT token, expira_em
|
||||
FROM public.document_share_links
|
||||
WHERE documento_id = d.id
|
||||
AND ativo = true
|
||||
AND expira_em > now()
|
||||
AND usos < usos_max
|
||||
ORDER BY criado_em DESC
|
||||
LIMIT 1
|
||||
) sl ON true
|
||||
WHERE (
|
||||
-- signatario_id direto (quando registrado)
|
||||
s.signatario_id = v_uid
|
||||
OR
|
||||
-- Fallback: paciente pelo email (quando signatario_id veio NULL)
|
||||
s.signatario_email = (SELECT email FROM auth.users WHERE id = v_uid)
|
||||
OR
|
||||
-- Fallback: paciente pelo patient_id (documents.patient_id -> patients.user_id)
|
||||
d.patient_id IN (SELECT p.id FROM public.patients p WHERE p.user_id = v_uid)
|
||||
)
|
||||
AND (p_status IS NULL OR s.status = ANY (p_status))
|
||||
ORDER BY
|
||||
CASE s.status
|
||||
WHEN 'pendente' THEN 0
|
||||
WHEN 'enviado' THEN 1
|
||||
WHEN 'assinado' THEN 2
|
||||
WHEN 'recusado' THEN 3
|
||||
WHEN 'expirado' THEN 4
|
||||
ELSE 99
|
||||
END,
|
||||
s.criado_em DESC;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION public.list_my_signatures(text[]) IS
|
||||
'Lista signatures do paciente logado (auth.uid()) cruzando por signatario_id, email ou patient.user_id. Inclui share_token pra link de assinatura.';
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.list_my_signatures(text[]) TO authenticated;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,35 @@
|
||||
-- ============================================================================
|
||||
-- ROADMAP #1.4 #14 — Recibo profissional usa terapeuta_registro genérico
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- O template recibo_pagamento (seed_015) usa "Psicólogo(a) — CRP {{terapeuta_crp}}".
|
||||
-- Como agora suportamos múltiplos conselhos (CRP/CRM/CRFa/CREFITO/CRESS/CRN/RMS)
|
||||
-- via #5 (migration 20260521000003), o recibo precisa ser CFP-agnóstico.
|
||||
--
|
||||
-- Esta migration substitui no recibo_pagamento:
|
||||
-- "Psicólogo(a) — CRP {{terapeuta_crp}}" → "{{terapeuta_registro}}"
|
||||
-- e atualiza variaveis[] removendo terapeuta_crp + adicionando terapeuta_registro.
|
||||
--
|
||||
-- {{terapeuta_registro}} é auto-formatado server-side como "CRP 12345/SP",
|
||||
-- "CRM 12345/SP" etc, então não precisa de "Psicólogo(a) —" hardcoded.
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
UPDATE public.document_templates
|
||||
SET corpo_html = REPLACE(
|
||||
corpo_html,
|
||||
'Psicólogo(a) — CRP {{terapeuta_crp}}',
|
||||
'{{terapeuta_registro}}'
|
||||
),
|
||||
variaveis = ARRAY(
|
||||
SELECT DISTINCT v FROM (
|
||||
SELECT unnest(variaveis) v
|
||||
UNION ALL
|
||||
SELECT 'terapeuta_registro'
|
||||
) sub
|
||||
WHERE v <> 'terapeuta_crp'
|
||||
),
|
||||
updated_at = now()
|
||||
WHERE tipo = 'recibo_pagamento' AND is_global = true;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- ============================================================================
|
||||
-- Compliance CFP #5 — campo livre quando tipo de registro = 'outro'
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Migration 20260521000003 adicionou professional_registration_type com CHECK
|
||||
-- limitado a 8 valores (CRP/CRM/CRFa/CREFITO/CRESS/CRN/RMS/outro). Quando o
|
||||
-- profissional escolhe 'outro', precisa informar qual conselho/instituição
|
||||
-- (ex: associações privadas, conselhos não-listados).
|
||||
--
|
||||
-- Esta migration adiciona professional_registration_type_other (text livre),
|
||||
-- que só é preenchido quando type = 'outro'.
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE public.profiles
|
||||
ADD COLUMN IF NOT EXISTS professional_registration_type_other text;
|
||||
|
||||
COMMENT ON COLUMN public.profiles.professional_registration_type_other IS
|
||||
'Nome livre do conselho/instituição quando professional_registration_type = ''outro''. Aparece em recibos/laudos no lugar do tipo padrão.';
|
||||
|
||||
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,74 @@
|
||||
-- ============================================================================
|
||||
-- Compliance CFP #6 — Consent forms extra (LGPD + Gravação) + LGPD amend
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- Adiciona 2 templates globais novos exigidos pra completar a biblioteca de
|
||||
-- consent forms do ROADMAP item #1.2 #6:
|
||||
-- • termo_lgpd — Consentimento LGPD (tratamento de dados pessoais)
|
||||
-- • autorizacao_gravacao — Autorização de gravação de sessão
|
||||
--
|
||||
-- Também atualiza o template tcle_online existente pra incluir cláusula
|
||||
-- explícita de LGPD (estava mencionando criptografia mas não a Lei 13.709/2018
|
||||
-- nem direitos do titular).
|
||||
--
|
||||
-- Pré-requisito: migration 20260521000005_document_templates_consent_types.sql
|
||||
-- já aplicada (adiciona 'termo_lgpd' e 'autorizacao_gravacao' ao CHECK).
|
||||
-- ============================================================================
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- 1. Termo de Consentimento LGPD (tratamento de dados pessoais)
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
INSERT INTO public.document_templates (
|
||||
id, tenant_id, owner_id, nome_template, tipo, descricao,
|
||||
corpo_html, cabecalho_html, rodape_html,
|
||||
variaveis, is_global, ativo
|
||||
) VALUES (
|
||||
gen_random_uuid(), NULL, NULL,
|
||||
'Termo de Consentimento LGPD',
|
||||
'termo_lgpd',
|
||||
'Consentimento específico para tratamento de dados pessoais conforme Lei nº 13.709/2018 (LGPD).',
|
||||
E'<h2 style="text-align:center; margin-bottom:30px;">TERMO DE CONSENTIMENTO PARA TRATAMENTO DE DADOS PESSOAIS</h2>\n\n<p>Em conformidade com a <strong>Lei Geral de Proteção de Dados Pessoais — Lei nº 13.709/2018 (LGPD)</strong>, eu, <strong>{{paciente_nome}}</strong>, CPF nº <strong>{{paciente_cpf}}</strong>, declaro ter sido informado(a) e <strong>consinto livremente</strong> com o tratamento dos meus dados pessoais nos termos abaixo.</p>\n\n<h3>1. Controlador dos dados</h3>\n<p><strong>{{terapeuta_nome}}</strong>, Psicólogo(a) — CRP <strong>{{terapeuta_crp}}</strong>, atuando em <strong>{{clinica_nome}}</strong>, com endereço em <strong>{{clinica_endereco}}</strong>, atua como controlador dos dados pessoais coletados.</p>\n\n<h3>2. Dados tratados</h3>\n<p>Serão coletados e tratados os seguintes dados:</p>\n<ul>\n <li><strong>Identificação:</strong> nome, CPF, RG, data de nascimento, endereço, telefone, e-mail;</li>\n <li><strong>Dados sensíveis de saúde:</strong> histórico clínico, hipóteses diagnósticas, evolução terapêutica, prescrições, encaminhamentos (Art. 11 LGPD);</li>\n <li><strong>Dados de pagamento:</strong> valores, formas de pagamento, recibos emitidos;</li>\n <li><strong>Registros de atendimento:</strong> data, horário, modalidade e duração das sessões.</li>\n</ul>\n\n<h3>3. Finalidade e base legal</h3>\n<p>Os dados serão utilizados exclusivamente para:</p>\n<ul>\n <li><strong>Execução do contrato</strong> de prestação de serviços psicológicos (Art. 7º, V e Art. 11, II, "a" da LGPD);</li>\n <li>Cumprimento de <strong>obrigações legais e regulatórias</strong> (Resoluções CFP, retenção de prontuário por 5 anos — Art. 1º Res. CFP 001/2009);</li>\n <li>Proteção da <strong>vida e incolumidade física</strong> do titular ou de terceiros, quando necessário (Art. 11, II, "f" LGPD);</li>\n <li>Emissão de documentos solicitados (recibos, atestados, declarações).</li>\n</ul>\n\n<h3>4. Compartilhamento</h3>\n<p>Os dados <strong>não serão compartilhados</strong> com terceiros, exceto:</p>\n<ul>\n <li>Mediante <strong>autorização expressa</strong> do titular (ex: encaminhamentos);</li>\n <li>Por <strong>determinação judicial</strong> ou requisição legal de autoridade competente;</li>\n <li>Para <strong>processadores</strong> contratados (plataforma de prontuário eletrônico, serviços de armazenamento em nuvem), com cláusulas de confidencialidade e proteção equivalentes.</li>\n</ul>\n\n<h3>5. Armazenamento e retenção</h3>\n<p>Os dados serão mantidos pelo prazo mínimo de <strong>5 anos</strong> após o término do acompanhamento, conforme exigência do CFP (Resolução nº 001/2009), em ambiente eletrônico criptografado com controle de acesso restrito ao profissional responsável.</p>\n\n<h3>6. Direitos do titular (Art. 18 LGPD)</h3>\n<p>O(A) titular pode, a qualquer momento, solicitar ao controlador:</p>\n<ul>\n <li>Confirmação da existência de tratamento;</li>\n <li>Acesso aos seus dados;</li>\n <li>Correção de dados incompletos, inexatos ou desatualizados;</li>\n <li>Anonimização, bloqueio ou eliminação de dados desnecessários ou tratados em desconformidade;</li>\n <li>Portabilidade dos dados;</li>\n <li>Revogação deste consentimento, nos termos do §5º do Art. 8º (sem prejuízo do tratamento legalmente exigido).</li>\n</ul>\n\n<h3>7. Contato</h3>\n<p>Para exercer seus direitos ou esclarecer dúvidas: <strong>{{terapeuta_email}}</strong> · <strong>{{terapeuta_telefone}}</strong>.</p>\n\n<p style="margin-top:30px;">Declaro que <strong>li, compreendi e consinto</strong> livremente com o tratamento dos meus dados pessoais conforme descrito acima.</p>\n\n<p style="margin-top:20px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div style="display:flex; justify-content:space-between; margin-top:80px;">\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{paciente_nome}}</strong><br/>\n Titular dos dados — CPF: {{paciente_cpf}}\n </div>\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Controlador — CRP {{terapeuta_crp}}\n </div>\n</div>',
|
||||
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}}</span>\n</div>',
|
||||
E'<div style="text-align:center; font-size:9pt; color:#999;">\n Documento regido pela Lei nº 13.709/2018 (LGPD) e pelo Código de Ética Profissional do Psicólogo.\n</div>',
|
||||
ARRAY['paciente_nome','paciente_cpf','terapeuta_nome','terapeuta_crp','terapeuta_email','terapeuta_telefone','cidade_estado','data_atual_extenso','clinica_nome','clinica_endereco'],
|
||||
true, true
|
||||
);
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- 2. Autorização para Gravação de Sessão
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
INSERT INTO public.document_templates (
|
||||
id, tenant_id, owner_id, nome_template, tipo, descricao,
|
||||
corpo_html, cabecalho_html, rodape_html,
|
||||
variaveis, is_global, ativo
|
||||
) VALUES (
|
||||
gen_random_uuid(), NULL, NULL,
|
||||
'Autorização para Gravação de Sessão',
|
||||
'autorizacao_gravacao',
|
||||
'Autorização específica do paciente para gravação de áudio/vídeo das sessões (supervisão, ensino, registro clínico).',
|
||||
E'<h2 style="text-align:center; margin-bottom:30px;">AUTORIZAÇÃO PARA GRAVAÇÃO DE SESSÃO</h2>\n\n<p>Eu, <strong>{{paciente_nome}}</strong>, CPF nº <strong>{{paciente_cpf}}</strong>, declaro ter sido devidamente informado(a) pelo(a) psicólogo(a) <strong>{{terapeuta_nome}}</strong>, CRP <strong>{{terapeuta_crp}}</strong>, e <strong>AUTORIZO</strong> a gravação das sessões de atendimento psicológico nas condições abaixo.</p>\n\n<h3>1. Tipo de gravação</h3>\n<p>Modalidade autorizada: <strong>{{tipo_gravacao}}</strong> (áudio, vídeo ou ambos).</p>\n\n<h3>2. Finalidade</h3>\n<p>As gravações serão utilizadas exclusivamente para:</p>\n<ul>\n <li><strong>{{finalidade_gravacao}}</strong></li>\n</ul>\n<p>Finalidades comuns: registro clínico para análise posterior do profissional; supervisão técnica com supervisor identificado; uso didático em formação (com anonimização); pesquisa científica (mediante consentimento adicional específico).</p>\n\n<h3>3. Compartilhamento</h3>\n<p>As gravações são <strong>confidenciais</strong>. Não serão compartilhadas com terceiros, exceto quando:</p>\n<ul>\n <li>Houver autorização expressa e por escrito do(a) titular;</li>\n <li>Para fins de supervisão técnica, com o(a) supervisor(a) identificado(a) — <strong>{{supervisor_nome}}</strong> (quando aplicável);</li>\n <li>Anonimizadas, para fins didáticos ou de pesquisa (com novo consentimento específico).</li>\n</ul>\n\n<h3>4. Armazenamento e descarte</h3>\n<p>As gravações serão armazenadas em ambiente criptografado, com acesso restrito ao(à) profissional responsável, pelo prazo de <strong>{{prazo_retencao}}</strong>, após o qual serão definitivamente eliminadas, conforme a LGPD (Lei nº 13.709/2018).</p>\n\n<h3>5. Direitos do(a) paciente</h3>\n<ul>\n <li>Revogar esta autorização a qualquer tempo, com efeito sobre gravações futuras;</li>\n <li>Solicitar a eliminação imediata de gravações específicas;</li>\n <li>Solicitar cópia da gravação para fins próprios;</li>\n <li>Ser informado(a) sobre cada utilização (supervisão, pesquisa).</li>\n</ul>\n\n<h3>6. Considerações éticas</h3>\n<p>A presente autorização está em conformidade com o Código de Ética Profissional do Psicólogo, com a Resolução CFP nº 010/2005 (sigilo profissional) e com a Lei nº 13.709/2018 (LGPD). A negativa de gravação <strong>não prejudica</strong> o atendimento psicológico, que prosseguirá normalmente.</p>\n\n<p style="margin-top:30px;">Declaro que <strong>li, compreendi e autorizo</strong> a gravação das sessões nos termos acima.</p>\n\n<p style="margin-top:20px;">{{cidade_estado}}, {{data_atual_extenso}}.</p>\n\n<div style="display:flex; justify-content:space-between; margin-top:80px;">\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{paciente_nome}}</strong><br/>\n CPF: {{paciente_cpf}}\n </div>\n <div style="text-align:center; width:45%;">\n <hr style="border:none; border-top:1px solid #333; margin-bottom:8px;" />\n <strong>{{terapeuta_nome}}</strong><br/>\n Psicólogo(a) — CRP {{terapeuta_crp}}\n </div>\n</div>',
|
||||
E'<div style="text-align:center;">\n <strong>{{clinica_nome}}</strong><br/>\n <span style="font-size:10pt; color:#666;">{{clinica_endereco}}</span>\n</div>',
|
||||
E'<div style="text-align:center; font-size:9pt; color:#999;">\n Autorização regida pelo Código de Ética do Psicólogo (CFP 010/2005) e pela Lei nº 13.709/2018 (LGPD).\n</div>',
|
||||
ARRAY['paciente_nome','paciente_cpf','terapeuta_nome','terapeuta_crp','tipo_gravacao','finalidade_gravacao','supervisor_nome','prazo_retencao','cidade_estado','data_atual_extenso','clinica_nome','clinica_endereco'],
|
||||
true, true
|
||||
);
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
-- 3. Amend tcle_online com cláusula LGPD explícita
|
||||
-- ----------------------------------------------------------------------------
|
||||
-- O template original (seed_015) menciona criptografia mas não cita a LGPD
|
||||
-- explicitamente nem os direitos do titular. Acrescenta uma seção 5.
|
||||
-- ──────────────────────────────────────────────────────────────────────────
|
||||
UPDATE public.document_templates
|
||||
SET corpo_html = REPLACE(
|
||||
corpo_html,
|
||||
'<h3>4. Limitações</h3>',
|
||||
E'<h3>5. Proteção de Dados (LGPD)</h3>\n<p>O atendimento online é regido pela <strong>Lei Geral de Proteção de Dados — Lei nº 13.709/2018 (LGPD)</strong>. Você tem direito a (Art. 18 LGPD): confirmar a existência de tratamento dos seus dados; acessar seus dados; corrigir dados incompletos ou inexatos; solicitar eliminação dos dados após o término do tratamento (resguardados os prazos legais de retenção do CFP); e revogar este consentimento a qualquer momento. Para exercê-los, contate <strong>{{terapeuta_email}}</strong>.</p>\n\n<h3>6. Limitações</h3>'
|
||||
),
|
||||
variaveis = ARRAY['paciente_nome','paciente_cpf','plataforma_online','terapeuta_nome','terapeuta_crp','terapeuta_email','cidade_estado','data_atual_extenso','clinica_nome','clinica_endereco'],
|
||||
descricao = 'Consentimento específico para atendimento psicológico por meios tecnológicos (Resolução CFP nº 11/2018) + cláusula LGPD.',
|
||||
updated_at = now()
|
||||
WHERE tipo = 'tcle_online' AND is_global = true;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Backfill: linkar document_generated.documento_id em registros antigos
|
||||
-- pra suportar re-edicao in-place de documentos gerados.
|
||||
--
|
||||
-- O codigo novo (DocumentGenerate.service.js saveGeneratedDocument) ja
|
||||
-- preenche o documento_id no INSERT pra criacoes novas. Este script eh
|
||||
-- one-off pra docs gerados ANTES desse fix.
|
||||
--
|
||||
-- Match: dg.pdf_path = d.bucket_path + match de tenant/patient pra evitar
|
||||
-- linkar a doc errado em caso colidente. Registros sem match (paths que
|
||||
-- nao existem mais em documents — docs deletados/cleanup) ficam orfaos
|
||||
-- com documento_id=NULL: nao quebra nada, so nao tem caminho de re-edit.
|
||||
|
||||
BEGIN;
|
||||
|
||||
UPDATE public.document_generated dg
|
||||
SET documento_id = d.id
|
||||
FROM public.documents d
|
||||
WHERE dg.documento_id IS NULL
|
||||
AND dg.pdf_path = d.bucket_path
|
||||
AND dg.patient_id = d.patient_id
|
||||
AND dg.tenant_id = d.tenant_id
|
||||
AND d.deleted_at IS NULL;
|
||||
|
||||
-- Relatorio pos-backfill
|
||||
DO $REPORT$
|
||||
DECLARE
|
||||
v_linked int;
|
||||
v_orphans int;
|
||||
BEGIN
|
||||
SELECT count(*) FILTER (WHERE documento_id IS NOT NULL),
|
||||
count(*) FILTER (WHERE documento_id IS NULL)
|
||||
INTO v_linked, v_orphans
|
||||
FROM public.document_generated;
|
||||
RAISE NOTICE 'document_generated: % linked, % orphans (sem documents correspondente)',
|
||||
v_linked, v_orphans;
|
||||
END;
|
||||
$REPORT$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,166 @@
|
||||
-- Importacao da doc Assinatura eletronica de documentos (Fase 3 #7)
|
||||
-- Gerado a partir de development/saas-docs/05-assinatura-eletronica-melissa.json
|
||||
BEGIN;
|
||||
|
||||
DO $IMPORT$
|
||||
DECLARE
|
||||
v_doc_id uuid;
|
||||
BEGIN
|
||||
INSERT INTO public.saas_docs (
|
||||
titulo, conteudo, categoria, exibir_no_faq, tipo_acesso,
|
||||
pagina_path, ordem, ativo, medias
|
||||
) VALUES (
|
||||
'Assinatura eletrônica de documentos',
|
||||
$HTML$<h2>Assinatura eletrônica de documentos</h2>
|
||||
|
||||
<p>O sistema permite enviar documentos clínicos (TCLE, contratos, autorizações, laudos) pro paciente assinar <strong>sem que ele precise ter login</strong>. O fluxo registra a assinatura com hash do conteúdo, IP, user-agent e timestamp — gerando um audit trail compliance LGPD/CFP.</p>
|
||||
|
||||
<h3>1. Visão geral do fluxo</h3>
|
||||
<ol>
|
||||
<li><strong>Terapeuta</strong> abre o documento no prontuário e clica em <em>Assinar</em></li>
|
||||
<li>Adiciona os signatários (nome + email) e ativa <em>"Gerar link público para assinatura"</em></li>
|
||||
<li>Sistema cria signature requests + um <strong>link público temporário</strong> com token</li>
|
||||
<li>Terapeuta copia a URL e envia pro paciente (WhatsApp, email, SMS — manual por enquanto)</li>
|
||||
<li><strong>Paciente</strong> abre o link em qualquer navegador, lê o documento, marca o checkbox de aceite LGPD e clica <em>Assinar</em></li>
|
||||
<li>Sistema computa SHA-256 do PDF baixado, registra assinatura via RPC server-side (IP/UA capturados pelo banco)</li>
|
||||
<li>Terapeuta vê o status atualizado no documento (pendente → assinado)</li>
|
||||
</ol>
|
||||
|
||||
<h3>2. Lado terapeuta — criar solicitação</h3>
|
||||
<p>No preview de um documento (na aba <em>Documentos</em> do prontuário), clique no botão <strong>Assinar</strong> na sidebar de ações. O <em>DocumentSignatureDialog</em> abre com:</p>
|
||||
<ul>
|
||||
<li><strong>Lista de signatários:</strong> adicione um ou mais — pra cada um, nome + email são obrigatórios. O paciente principal vem pré-preenchido se disponível.</li>
|
||||
<li><strong>Toggle "Gerar link público para assinatura"</strong> (default ON): cria um share_link junto com as signature requests. Sem isso, só fica a request registrada — o paciente precisa logar no portal pra assinar.</li>
|
||||
<li><strong>Select de validade do link:</strong> 24h / 3 dias / 7 dias / 30 dias. Default 7 dias (168h).</li>
|
||||
<li><strong>Submit:</strong> cria as requests + link e mostra a URL pronta pra copiar.</li>
|
||||
</ul>
|
||||
|
||||
<h3>3. Lado paciente — link público (sem login)</h3>
|
||||
<p>Ao abrir o link <code>/shared/document/:token</code>, o paciente vê:</p>
|
||||
<ul>
|
||||
<li><strong>Preview do PDF</strong> inline (iframe)</li>
|
||||
<li><strong>Painel azul</strong> embaixo do preview com:
|
||||
<ul>
|
||||
<li>Aviso LGPD/CFP explicando o que vai ser registrado</li>
|
||||
<li>Checkbox <em>"Li o documento e concordo com seu conteúdo"</em> (bloqueia botões até marcado)</li>
|
||||
<li>Select de signatário (se houver mais de um cadastrado)</li>
|
||||
<li>Botões <strong>Assinar</strong> (emerald) e <strong>Recusar</strong> (rose)</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p>Ao clicar Assinar:</p>
|
||||
<ol>
|
||||
<li>Sistema baixa o PDF e computa <strong>SHA-256</strong> client-side (proof of integrity — se o doc foi alterado depois, hash não bate)</li>
|
||||
<li>Chama a RPC <code>sign_document_by_token</code> passando o hash</li>
|
||||
<li>RPC captura <strong>IP via inet_client_addr()</strong> e <strong>user-agent via current_setting('request.headers')</strong> — server-side, à prova de spoof client-side</li>
|
||||
<li>Registra em <code>document_signatures</code>: timestamp, hash, IP, UA, status='assinado'</li>
|
||||
<li>Mostra tela de confirmação "Documento assinado com sucesso"</li>
|
||||
</ol>
|
||||
|
||||
<h3>4. Audit trail registrado</h3>
|
||||
<p>Cada assinatura grava:</p>
|
||||
<ul>
|
||||
<li><strong>signatario_id</strong> (se o signatário tem cadastro como paciente vinculado)</li>
|
||||
<li><strong>signatario_nome</strong> e <strong>signatario_email</strong> (do que foi cadastrado pelo terapeuta)</li>
|
||||
<li><strong>assinado_em</strong> — timestamp da RPC (server time, não client clock)</li>
|
||||
<li><strong>assinatura_hash</strong> — SHA-256 do PDF no momento da assinatura</li>
|
||||
<li><strong>ip_address</strong> — capturado server-side (anti-spoof)</li>
|
||||
<li><strong>user_agent</strong> — header HTTP via current_setting</li>
|
||||
<li><strong>status</strong> — pendente / enviado / assinado / recusado / expirado</li>
|
||||
</ul>
|
||||
|
||||
<div style="background: rgba(16,185,129,0.08); border: 1px solid rgba(16,185,129,0.3); border-radius: 10px; padding: 12px 14px; margin: 14px 0; font-size: 0.85rem;">
|
||||
<strong>🛡️ Por que server-side?</strong> Capturar IP/UA no banco via <code>inet_client_addr()</code> é anti-spoof: o cliente não consegue forjar valores arbitrários. Garante que o audit trail reflete a sessão HTTP real, não um POST manipulado.
|
||||
</div>
|
||||
|
||||
<h3>5. Recusar a assinatura</h3>
|
||||
<p>O paciente pode <strong>recusar</strong> em vez de assinar — útil se ele não concorda com o conteúdo. Click em <strong>Recusar</strong> abre um <em>confirm</em>; ao confirmar, o sistema registra a recusa (com timestamp + IP/UA da mesma forma) e marca a request como <code>status='recusado'</code>. O terapeuta vê isso na lista de signature requests e pode entrar em contato pra ajustar o documento.</p>
|
||||
|
||||
<h3>6. Portal do paciente — lista de pendências</h3>
|
||||
<p>Pacientes logados no portal (<code>/portal/documentos</code>) veem uma lista de TODOS os documentos solicitados pra eles, com KPIs no topo:</p>
|
||||
<ul>
|
||||
<li><strong>Total</strong> · <strong>Pendentes</strong> · <strong>Assinados</strong> · <strong>Recusados</strong></li>
|
||||
</ul>
|
||||
<p>Filtro por status (todos / pendentes / assinados) + lista. Click em <strong>Assinar agora</strong> num item pendente leva pro <code>/shared/document/:token</code> (mesma página pública, mas com auth já garantida via portal).</p>
|
||||
|
||||
<h3>7. Expiração e múltiplos usos</h3>
|
||||
<ul>
|
||||
<li><strong>Validade do link:</strong> configurada na criação (24h/3d/7d/30d). Após expirar, retorna 410 Gone. Terapeuta pode gerar novo link pelo botão <em>Compartilhar</em> no preview do doc.</li>
|
||||
<li><strong>Limite de usos:</strong> calculado como <code>max(signatários × 3, 5)</code> — gerar 1 signatário dá 5 usos disponíveis (margem de erro / reload / multi-device).</li>
|
||||
<li><strong>Cada assinatura é única:</strong> mesmo signatário não consegue assinar 2x — a RPC bloqueia se já houver registro com status=assinado.</li>
|
||||
</ul>
|
||||
|
||||
<h3>8. Múltiplos signatários</h3>
|
||||
<p>Documentos como termo de autorização de menor podem precisar de 2+ assinaturas (responsável legal + paciente menor, ou os dois pais). O dialog aceita N signatários; cada um recebe sua própria entry em <code>document_signatures</code>. O link público é o mesmo — quando o paciente abre, escolhe qual signatário ele é no select e assina apenas a sua entry.</p>
|
||||
|
||||
<h3>9. Validade legal</h3>
|
||||
<p>A assinatura eletrônica registrada pelo sistema atende:</p>
|
||||
<ul>
|
||||
<li><strong>LGPD (Lei 13.709/2018):</strong> consentimento explícito registrado com timestamp, IP e UA — base legal Art. 7º I</li>
|
||||
<li><strong>Código de Ética CFP:</strong> documento clínico com identificação inequívoca do signatário (nome+email+IP+hash)</li>
|
||||
<li><strong>MP 2200-2/2001 (ICP-Brasil):</strong> assinatura <em>simples</em> com integridade via hash — não é certificado A1/A3, mas é válida pra documentos sem exigência ICP</li>
|
||||
</ul>
|
||||
<p>⚠️ Pra documentos que exigem ICP-Brasil (notarial, procuração com poderes especiais), use uma plataforma externa de assinatura qualificada — esse fluxo não substitui.</p>
|
||||
|
||||
<h3>⚠️ Notas pro desenvolvedor</h3>
|
||||
<ul>
|
||||
<li><strong>RPCs:</strong> <code>sign_document_by_signature_id</code> (paciente logado no portal), <code>sign_document_by_token</code> (link público), <code>get_signable_document_by_token</code> (resolve token → doc + signature_request), <code>list_my_signatures</code> (lista do paciente, cruza por signatario_id, signatario_email e patient.user_id)</li>
|
||||
<li><strong>Service:</strong> <code>DocumentSignatures.service.js</code> com wrappers <code>signByPortal</code>, <code>signByToken</code>, <code>getSignableDocumentByToken</code>, <code>listMySignatures</code>, <code>hashDocument</code>, <code>refuseSignature</code>, <code>createSignatureRequests</code>, <code>createShareLink</code>, <code>buildShareUrl</code></li>
|
||||
<li><strong>Composable:</strong> <code>useDocumentSignatures</code> (Tipo A blueprint)</li>
|
||||
<li><strong>UI lado terapeuta:</strong> <code>DocumentSignatureDialog.vue</code> (component)</li>
|
||||
<li><strong>UI lado paciente:</strong> <code>PortalDocumentos.vue</code> (portal logado) + <code>SharedDocumentPage.vue</code> (link público)</li>
|
||||
<li><strong>Notificação automática</strong> (paciente recebe email/WA quando signature criada) — pendente, depende de Módulo 6 (notifications factory channel)</li>
|
||||
</ul>$HTML$,
|
||||
'Documentos',
|
||||
true,
|
||||
'usuario',
|
||||
'/melissa/paciente',
|
||||
5,
|
||||
true,
|
||||
'[{"tipo": "imagem", "url": ""}]'::jsonb
|
||||
)
|
||||
RETURNING id INTO v_doc_id;
|
||||
|
||||
INSERT INTO public.saas_faq_itens (doc_id, pergunta, resposta, ordem, ativo) VALUES
|
||||
(v_doc_id, 'Como peço pra um paciente assinar um documento (TCLE, contrato, autorização)?',
|
||||
$FAQ$Na aba <strong>Documentos</strong> do prontuário, clique no doc → no preview, clique em <strong>Assinar</strong> (sidebar de ações). O dialog abre. Adicione o paciente como signatário (nome + email), mantenha <em>"Gerar link público para assinatura"</em> marcado, escolha validade (7 dias é o default) e clique em <strong>Solicitar</strong>. O sistema cria a request e mostra uma URL pra copiar. Envie pro paciente via WhatsApp, email, SMS — como preferir.$FAQ$, 0, true),
|
||||
|
||||
(v_doc_id, 'Como o paciente assina sem ter login no sistema?',
|
||||
$FAQ$Ele abre o link público (<code>/shared/document/:token</code>) em qualquer navegador. Vê o PDF inline, lê o aviso LGPD/CFP, marca o checkbox <em>"Li o documento e concordo"</em>, e clica <strong>Assinar</strong>. O sistema computa hash SHA-256 do PDF, chama a RPC server-side que captura IP/User-Agent e registra a assinatura. Nada de cadastro, nada de senha.$FAQ$, 1, true),
|
||||
|
||||
(v_doc_id, 'Que informação fica registrada quando ele assina?',
|
||||
$FAQ$Tudo que precisa pra audit compliance: <strong>nome e email</strong> do signatário (do cadastro), <strong>timestamp server-side</strong> (não do relógio do cliente), <strong>hash SHA-256 do PDF</strong> no momento da assinatura (qualquer alteração posterior invalida a integridade), <strong>IP</strong> e <strong>User-Agent</strong> capturados pelo banco via <code>inet_client_addr()</code> e <code>current_setting('request.headers')</code> — anti-spoof. Tudo fica em <code>public.document_signatures</code>.$FAQ$, 2, true),
|
||||
|
||||
(v_doc_id, 'O terapeuta também precisa assinar o documento?',
|
||||
$FAQ$Depende do tipo. Pra atestados, laudos e declarações, geralmente sim — você gera o PDF a partir do template (que já contém seu nome + registro profissional + assinatura digitalizada se você incluiu no rodapé). Pra contratos e termos com paciente como contraparte, você adiciona você mesmo como segundo signatário no dialog antes de enviar. Cada um abre o link e assina sua entry separadamente.$FAQ$, 3, true),
|
||||
|
||||
(v_doc_id, 'O link tem validade? E se expirar?',
|
||||
$FAQ$Tem. Você escolhe 24h, 3 dias, 7 dias ou 30 dias na hora de criar (default 7d). Depois disso o link retorna erro 410 Gone. Se o paciente não assinou a tempo, gere um novo link: no preview do doc, clique em <strong>Compartilhar</strong> ou abra o dialog de assinatura novamente — vai criar outro token. Limite de usos do link: ~5 (margem pra reload/multi-device), depois também expira.$FAQ$, 4, true),
|
||||
|
||||
(v_doc_id, 'E se o paciente recusar a assinatura?',
|
||||
$FAQ$Tem botão <strong>Recusar</strong> ao lado do <em>Assinar</em>. Clique pede confirmação; ao confirmar, a request fica com <code>status='recusado'</code> com timestamp e IP/UA registrados igual à assinatura. Você vê o status na lista de pendências do doc e na aba do prontuário. Geralmente: ajuste o conteúdo do documento e envie nova solicitação.$FAQ$, 5, true),
|
||||
|
||||
(v_doc_id, 'O paciente pode assinar depois pelo Portal sem precisar do link?',
|
||||
$FAQ$Sim, se ele tem conta de portal. Em <strong>/portal/documentos</strong> aparece a lista de tudo que está pendente pra ele assinar, com KPIs (total, pendentes, assinados, recusados) e botão <strong>Assinar agora</strong> que leva pra mesma página de assinatura. Útil pra pacientes que perderam o link no WhatsApp — eles loga e acha tudo num lugar só.$FAQ$, 6, true),
|
||||
|
||||
(v_doc_id, 'Como compartilho o link com o paciente — tem envio automático?',
|
||||
$FAQ$Hoje o envio é <strong>manual</strong>: o dialog gera a URL, você copia e cola onde quiser (WhatsApp, email, SMS, AirDrop, QR code, link em conversa direta). Envio automático (notificação por WA/email quando signature é criada) está no roadmap, depende do Módulo 6 (notifications factory channel) que ainda não foi implementado.$FAQ$, 7, true),
|
||||
|
||||
(v_doc_id, 'A assinatura tem validade legal mesmo sem certificado ICP-Brasil?',
|
||||
$FAQ$Pra documentos clínicos comuns (TCLE, contrato de prestação, autorizações, declarações entre terapeuta-paciente), <strong>sim</strong>. A assinatura simples com timestamp + hash + IP + UA atende LGPD (Art. 7º I — consentimento explícito) e o Código de Ética do CFP. Pra documentos que exigem certificado ICP-Brasil (notarial, procuração com poderes especiais), use uma plataforma externa de assinatura qualificada — esse fluxo não substitui.$FAQ$, 8, true),
|
||||
|
||||
(v_doc_id, 'O paciente consegue editar o documento antes de assinar?',
|
||||
$FAQ$Não. O paciente <strong>só visualiza</strong> — o PDF é renderizado em iframe e a integridade é garantida pelo hash SHA-256 computado no momento da assinatura. Se o conteúdo precisar mudar, é você que ajusta o documento (editar via template ou regenerar) e envia nova solicitação. A assinatura antiga (se houve) fica registrada com o hash do conteúdo antigo — o doc atual tem hash diferente, mostrando que mudou.$FAQ$, 9, true),
|
||||
|
||||
(v_doc_id, 'Como cancelo uma solicitação de assinatura?',
|
||||
$FAQ$Hoje não há um botão "cancelar" direto na UI. O caminho é: ignore (deixa expirar pelo prazo do link) ou peça pro admin marcar como <code>status='expirado'</code> no banco. Em versões futuras teremos botão de cancelar na lista de pendências do doc.$FAQ$, 10, true),
|
||||
|
||||
(v_doc_id, 'Posso pedir mais de uma pessoa pra assinar o mesmo documento?',
|
||||
$FAQ$Sim. Pra termos com múltiplos signatários (autorização de atendimento de menor com 2 pais, contrato com responsável legal + paciente), adicione cada um como signatário separado no dialog. Cada um vira uma entry em <code>document_signatures</code>. O link público é o mesmo — quando o signatário abre, escolhe quem ele é no select acima dos botões e assina apenas a entry dele. Útil também pra você incluir si mesmo (terapeuta) + paciente num contrato bilateral.$FAQ$, 11, true);
|
||||
|
||||
RAISE NOTICE 'Doc criada: id=%, faq_itens=12', v_doc_id;
|
||||
END;
|
||||
$IMPORT$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,168 @@
|
||||
-- Importação da doc Fase 1 (Busca global + Recently viewed)
|
||||
-- Gerado a partir de development/saas-docs/01-busca-global-melissa.json
|
||||
BEGIN;
|
||||
|
||||
DO $IMPORT$
|
||||
DECLARE
|
||||
v_doc_id uuid;
|
||||
BEGIN
|
||||
-- 1) Cria a doc principal
|
||||
INSERT INTO public.saas_docs (
|
||||
titulo, conteudo, categoria, exibir_no_faq, tipo_acesso,
|
||||
pagina_path, ordem, ativo, medias
|
||||
) VALUES (
|
||||
'Busca global e Acessados recentemente',
|
||||
$HTML$<h2>Busca global no Layout Melissa</h2>
|
||||
|
||||
<p>A <strong>busca global</strong> é o atalho mais rápido para encontrar pacientes, sessões, documentos e cadastros recebidos sem precisar navegar pelos menus. Você acessa pelo <em>dock central</em> do Layout Melissa ou usando o atalho de teclado <kbd>Ctrl</kbd> + <kbd>K</kbd> (em qualquer página do Melissa).</p>
|
||||
|
||||
<h3>1. Como abrir</h3>
|
||||
<p>Localize o campo de busca no dock central do Melissa. Ele aparece como um botão com o ícone de lupa e o placeholder <em>"Buscar paciente, agenda, atalho…"</em>, com o atalho <kbd>Ctrl K</kbd> indicado no canto direito.</p>
|
||||
|
||||
<div style="border: 1px solid #cbd5e1; border-radius: 12px; padding: 0 14px; height: 44px; max-width: 480px; display: flex; align-items: center; gap: 10px; background: #1e2333; color: #cbd5e1; font-family: 'Segoe UI', sans-serif; margin: 12px 0;">
|
||||
<i class="pi pi-search" style="font-size: 0.95rem;"></i>
|
||||
<span style="flex: 1; font-size: 0.9rem;">Buscar paciente, agenda, atalho…</span>
|
||||
<span style="font-size: 0.62rem; padding: 2px 7px; border-radius: 4px; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.15); letter-spacing: 0.05em;">Ctrl K</span>
|
||||
</div>
|
||||
|
||||
<p><strong>Três jeitos de abrir:</strong></p>
|
||||
<ul>
|
||||
<li>Clicando no campo no dock central</li>
|
||||
<li>Pressionando <kbd>Ctrl + K</kbd> (Windows/Linux) ou <kbd>⌘ + K</kbd> (Mac) em qualquer página</li>
|
||||
<li>Pelo menu lateral, opção "Buscar" (quando disponível)</li>
|
||||
</ul>
|
||||
|
||||
<h3>2. O Dialog Spotlight</h3>
|
||||
<p>Ao abrir, o sistema mostra um <strong>diálogo centralizado</strong> com o input grande no topo e os resultados em colunas abaixo. Isso é o padrão Spotlight (igual ao usado em macOS, Linear, GitHub, Slack).</p>
|
||||
|
||||
<div style="background: var(--surface-card, #fff); border: 1px solid #e2e8f0; border-radius: 14px; max-width: 520px; box-shadow: 0 12px 32px rgba(0,0,0,0.15); overflow: hidden; margin: 12px 0; font-family: 'Segoe UI', sans-serif;">
|
||||
<div style="padding: 14px 18px; border-bottom: 1px solid #e2e8f0; display: flex; align-items: center; gap: 12px;">
|
||||
<i class="pi pi-search" style="color: #64748b;"></i>
|
||||
<span style="flex: 1; color: #94a3b8; font-size: 1.05rem;">Buscar paciente, agenda, atalho…</span>
|
||||
<span style="font-size: 0.65rem; padding: 2px 8px; border-radius: 4px; background: #f1f5f9; border: 1px solid #e2e8f0; color: #64748b;">Esc</span>
|
||||
</div>
|
||||
<div style="padding: 6px;">
|
||||
<div style="text-transform: uppercase; letter-spacing: 0.18em; color: #64748b; font-size: 0.62rem; font-weight: 700; padding: 8px 10px 4px; opacity: 0.75;">Acessados recentemente</div>
|
||||
<div style="display: flex; align-items: center; gap: 10px; padding: 9px 10px; border-radius: 8px;">
|
||||
<span style="width: 32px; height: 32px; display: grid; place-items: center; border-radius: 7px; background: rgba(244,114,182,0.18); color: #ec4899; font-size: 0.9rem;">
|
||||
<i class="pi pi-user"></i>
|
||||
</span>
|
||||
<div style="flex: 1;">
|
||||
<div style="font-size: 0.88rem; font-weight: 500;">André Green</div>
|
||||
<div style="font-size: 0.74rem; color: #64748b;">andre@email.com</div>
|
||||
</div>
|
||||
<i class="pi pi-arrow-right" style="color: #94a3b8; font-size: 0.75rem;"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>3. Onde a busca procura</h3>
|
||||
<p>Digitando <strong>pelo menos 2 caracteres</strong>, o sistema dispara uma busca completa em 5 categorias:</p>
|
||||
<ul>
|
||||
<li><strong style="color: #ec4899;">Pacientes</strong> — por nome completo, e-mail, telefone ou CPF</li>
|
||||
<li><strong style="color: #6366f1;">Sessões</strong> — por título ou nome do paciente, em qualquer data</li>
|
||||
<li><strong style="color: #0ea5e9;">Documentos</strong> — por nome do arquivo ou descrição</li>
|
||||
<li><strong style="color: #f97316;">Cadastros recebidos</strong> — solicitações de novos pacientes pendentes</li>
|
||||
<li><strong>Atalhos</strong> — ações rápidas como "Agenda", "Financeiro", etc.</li>
|
||||
</ul>
|
||||
|
||||
<p>Cada categoria aparece com um <strong>ícone colorido distinto</strong> para facilitar a leitura visual. Os resultados são limitados aos 6 mais relevantes por categoria.</p>
|
||||
|
||||
<h3>4. Como navegar nos resultados</h3>
|
||||
<p>Você pode usar o mouse ou o teclado:</p>
|
||||
<ul>
|
||||
<li><kbd>↑</kbd> / <kbd>↓</kbd> — navegar entre os resultados</li>
|
||||
<li><kbd>Enter</kbd> — abrir o item selecionado</li>
|
||||
<li><kbd>Esc</kbd> — fechar o diálogo</li>
|
||||
<li><kbd>Clique no backdrop</kbd> — fecha também</li>
|
||||
</ul>
|
||||
|
||||
<h3>5. Acessados recentemente</h3>
|
||||
<p>Quando você abre a busca <strong>sem digitar nada</strong>, a primeira seção mostra <strong>"Acessados recentemente"</strong> — os últimos 5 pacientes que você visitou (em qualquer dispositivo deste navegador).</p>
|
||||
|
||||
<div style="background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 12px 16px; margin: 12px 0; font-size: 0.88rem; color: #475569;">
|
||||
<strong>💡 Dica:</strong> Use Ctrl+K + Enter para reabrir o último paciente acessado em 2 segundos.
|
||||
</div>
|
||||
|
||||
<p>Esses 5 pacientes ficam salvos no seu navegador (não no banco de dados), então:</p>
|
||||
<ul>
|
||||
<li>São <strong>privados</strong> — outros usuários não veem</li>
|
||||
<li>São <strong>por navegador</strong> — se trocar do Chrome pro Firefox, a lista recomeça</li>
|
||||
<li><strong>Persistem</strong> após fechar o navegador (localStorage)</li>
|
||||
<li>Auto-rotacionam: ao acessar o 6º paciente, o mais antigo sai</li>
|
||||
</ul>
|
||||
|
||||
<h3>6. Clique nos resultados</h3>
|
||||
<p>Ao clicar:</p>
|
||||
<ul>
|
||||
<li><strong>Paciente</strong> → abre o prontuário (<code>/melissa/paciente?id=…</code>)</li>
|
||||
<li><strong>Sessão</strong> → abre o evento na agenda</li>
|
||||
<li><strong>Documento</strong> → abre o prontuário do paciente na aba Documentos</li>
|
||||
<li><strong>Cadastro recebido</strong> → vai pra lista de Cadastros recebidos</li>
|
||||
<li><strong>Atalho</strong> → navega pra seção (Agenda, Financeiro, etc.)</li>
|
||||
</ul>
|
||||
|
||||
<h3>7. Tema claro × escuro</h3>
|
||||
<p>O Dialog adapta automaticamente as cores conforme o tema escolhido em <strong>Meu Perfil → Preferências</strong>. Texto, fundos e bordas seguem as configurações do sistema. Apenas os ícones por categoria (paciente rosa, sessão índigo, documento azul, cadastro laranja) mantêm a mesma cor para preservar a identificação visual rápida.</p>
|
||||
|
||||
<h3>⚠️ Notas pro desenvolvedor</h3>
|
||||
<p>Atualmente o componente <code>MelissaBusca.vue</code> <strong>não tem atributos <code>id</code></strong> em seus elementos. Para o sistema de highlight da ajuda funcionar (links <code>data-highlight</code>), sugere-se adicionar:</p>
|
||||
<ul>
|
||||
<li><code>id="melissa-busca-trigger"</code> no botão de trigger no dock</li>
|
||||
<li><code>id="melissa-busca-dialog"</code> no Dialog</li>
|
||||
<li><code>id="melissa-busca-input"</code> no input dentro do Dialog</li>
|
||||
<li><code>id="melissa-busca-recent"</code> no grupo de Acessados recentemente</li>
|
||||
</ul>$HTML$,
|
||||
'Navegação',
|
||||
true,
|
||||
'usuario',
|
||||
'/melissa',
|
||||
1,
|
||||
true,
|
||||
'[{"tipo": "imagem", "url": ""}]'::jsonb
|
||||
)
|
||||
RETURNING id INTO v_doc_id;
|
||||
|
||||
-- 2) Insere os 12 FAQ items vinculados
|
||||
INSERT INTO public.saas_faq_itens (doc_id, pergunta, resposta, ordem, ativo) VALUES
|
||||
(v_doc_id, 'Como abrir a busca rapidamente?',
|
||||
$FAQ$Use o atalho <kbd>Ctrl + K</kbd> (Windows/Linux) ou <kbd>⌘ + K</kbd> (Mac) em qualquer página do Melissa. Você também pode clicar diretamente no campo de busca no dock central.$FAQ$, 0, true),
|
||||
|
||||
(v_doc_id, 'Posso buscar paciente por telefone ou CPF?',
|
||||
$FAQ$Sim. A busca de pacientes encontra pelo <strong>nome completo, e-mail, telefone ou CPF</strong>. Digite pelo menos 2 caracteres e aguarde os resultados.$FAQ$, 1, true),
|
||||
|
||||
(v_doc_id, 'O que aparece em "Acessados recentemente"?',
|
||||
$FAQ$Os últimos 5 pacientes que você abriu pelo prontuário, em ordem do mais recente pro mais antigo. A lista aparece quando você abre a busca <strong>sem digitar nada</strong>.$FAQ$, 2, true),
|
||||
|
||||
(v_doc_id, 'Outros usuários veem meus "Acessados recentemente"?',
|
||||
$FAQ$Não. A lista é <strong>privada e local</strong> — fica salva apenas no seu navegador atual (localStorage). Se você logar em outro navegador ou computador, a lista começa vazia naquele dispositivo.$FAQ$, 3, true),
|
||||
|
||||
(v_doc_id, 'Quantos caracteres preciso digitar pra começar a buscar?',
|
||||
$FAQ$<strong>Pelo menos 2</strong>. Buscas de 1 caractere são muito amplas e não disparam pesquisa. A partir de 2 caracteres, o sistema aguarda 200ms (tempo de digitação) antes de consultar o banco — assim você não dispara dezenas de buscas digitando rápido.$FAQ$, 4, true),
|
||||
|
||||
(v_doc_id, 'Por que minha busca não retorna nada?',
|
||||
$FAQ$Verifique: (1) digitou pelo menos 2 caracteres; (2) o termo está sem erros graves de digitação (a busca tolera pequenas variações via similarity); (3) o paciente/sessão realmente existe no seu cadastro. Se persistir, faça uma busca mais ampla — ex: apenas o primeiro nome.$FAQ$, 5, true),
|
||||
|
||||
(v_doc_id, 'O que cada cor de ícone significa?',
|
||||
$FAQ$Cada categoria tem uma cor própria: <strong style="color: #ec4899;">Rosa</strong> = Paciente, <strong style="color: #6366f1;">Índigo</strong> = Sessão da agenda, <strong style="color: #0ea5e9;">Azul</strong> = Documento, <strong style="color: #f97316;">Laranja</strong> = Cadastro recebido pendente. Atalhos vêm em cinza neutro.$FAQ$, 6, true),
|
||||
|
||||
(v_doc_id, 'Como navegar pelos resultados sem usar o mouse?',
|
||||
$FAQ$Use as setas do teclado <kbd>↑</kbd> e <kbd>↓</kbd> para navegar entre os itens e <kbd>Enter</kbd> para abrir o selecionado. Pra fechar sem selecionar, use <kbd>Esc</kbd>.$FAQ$, 7, true),
|
||||
|
||||
(v_doc_id, 'Posso buscar documentos pelo nome do paciente?',
|
||||
$FAQ$Sim. A busca de documentos cruza pelo nome do arquivo, descrição e <strong>nome do paciente vinculado</strong>. Ao clicar num resultado de documento, você é levado direto pra aba Documentos do prontuário daquele paciente.$FAQ$, 8, true),
|
||||
|
||||
(v_doc_id, 'Como limpar a lista de "Acessados recentemente"?',
|
||||
$FAQ$Hoje não há um botão na interface — a lista é gerenciada automaticamente (limite de 5, mais antigo cai quando você acessa um novo). Pra limpar manualmente, você pode apagar os dados do site no seu navegador (Configurações → Privacidade → Limpar dados de navegação → escopo "localStorage").$FAQ$, 9, true),
|
||||
|
||||
(v_doc_id, 'A busca encontra sessões antigas ou só as de hoje?',
|
||||
$FAQ$Encontra sessões de <strong>qualquer data</strong> — passadas e futuras. O grupo "Agenda de hoje" mostra apenas as do dia atual (preview rápido); o grupo "Sessões" inclui todas as outras encontradas no banco. Cada item mostra a data e horário da sessão.$FAQ$, 10, true),
|
||||
|
||||
(v_doc_id, 'Os atalhos (Agenda, Financeiro, etc.) sempre aparecem?',
|
||||
$FAQ$Sim. Quando o campo está vazio, mostramos 4 atalhos padrão. Conforme você digita, os atalhos que combinam com sua busca permanecem visíveis (junto com os resultados do banco).$FAQ$, 11, true);
|
||||
|
||||
RAISE NOTICE 'Doc criada: id=%, faq_itens=12', v_doc_id;
|
||||
END;
|
||||
$IMPORT$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,168 @@
|
||||
-- Importacao da doc do Cronometro de sessao (Melissa)
|
||||
-- Gerado a partir de development/saas-docs/02-cronometro-melissa.json
|
||||
BEGIN;
|
||||
|
||||
DO $IMPORT$
|
||||
DECLARE
|
||||
v_doc_id uuid;
|
||||
BEGIN
|
||||
-- 1) Cria a doc principal
|
||||
INSERT INTO public.saas_docs (
|
||||
titulo, conteudo, categoria, exibir_no_faq, tipo_acesso,
|
||||
pagina_path, ordem, ativo, medias
|
||||
) VALUES (
|
||||
'Cronômetro de sessão',
|
||||
$HTML$<h2>Cronômetro de sessão</h2>
|
||||
|
||||
<p>O <strong>cronômetro de sessão</strong> acompanha o tempo decorrido durante o atendimento e é integrado com a agenda. Quando aberto a partir de uma sessão em andamento, ele já vem com o paciente pré-selecionado e dispara automaticamente.</p>
|
||||
|
||||
<h3>1. Três jeitos de abrir</h3>
|
||||
<ul>
|
||||
<li>Pelo botão <strong>⏱</strong> ao lado do relógio gigante do dashboard (abre vazio, escolha o paciente — ou deixe como atividade livre)</li>
|
||||
<li>Pelo botão <strong>⏱</strong> que aparece sobre os cards de sessão em curso na timeline horizontal/vertical do dashboard</li>
|
||||
<li>Pelo CTA <strong>"Iniciar cronômetro"</strong> no card <em>"Próximo paciente"</em> quando a sessão está em andamento</li>
|
||||
</ul>
|
||||
<p>Os dois últimos pré-selecionam o paciente da sessão e disparam o timer automaticamente.</p>
|
||||
|
||||
<h3>2. Sessão em curso na timeline</h3>
|
||||
<p>Quando uma sessão entra em andamento (horário atual entre início e fim do evento), aparece um ícone <strong>⏱</strong> pulsando no canto superior direito do card do evento. O pulso é sutil, em verde — só pra sinalizar que dá pra cronometrar dali.</p>
|
||||
|
||||
<div style="display: flex; align-items: center; gap: 8px; background: #6366f1; color: white; padding: 4px 10px; border-radius: 4px; max-width: 320px; position: relative; margin: 12px 0; font-family: 'Segoe UI', sans-serif;">
|
||||
<span style="font-size: 0.85rem; font-weight: 600;">11:00 – Larissa Almeida</span>
|
||||
<span style="position: absolute; top: 3px; right: 3px; width: 22px; height: 22px; display: grid; place-items: center; background: rgba(0,0,0,0.45); border: 1px solid rgba(255,255,255,0.4); border-radius: 999px; color: white; box-shadow: 0 0 0 4px rgba(16,185,129,0.25);">
|
||||
<i class="pi pi-stopwatch" style="font-size: 0.7rem;"></i>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p>Clicar no <strong>⏱</strong> <strong>não abre o evento</strong> — abre o cronômetro pré-configurado pra essa sessão.</p>
|
||||
|
||||
<h3>3. Programado vs tempo real</h3>
|
||||
<p>Quando aberto via timeline ou card "Próximo paciente", o cronômetro mostra o <strong>horário programado original da sessão</strong> sob o select de paciente. Se você abriu depois do horário previsto, aparece um badge laranja <strong>"atrasada X min"</strong>.</p>
|
||||
|
||||
<div style="background: rgba(15,23,42,0.85); color: #cbd5e1; padding: 14px; border-radius: 10px; max-width: 360px; font-family: 'Segoe UI', sans-serif; margin: 12px 0;">
|
||||
<label style="font-size: 0.62rem; text-transform: uppercase; letter-spacing: 0.15em; color: rgba(255,255,255,0.5); display: block; margin-bottom: 8px;">Paciente / atividade</label>
|
||||
<div style="background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.15); padding: 9px 14px; border-radius: 10px; font-size: 0.9rem;">Larissa Almeida</div>
|
||||
<div style="display: flex; align-items: center; gap: 6px; margin-top: 8px; padding: 4px 0;">
|
||||
<i class="pi pi-calendar" style="font-size: 0.7rem; color: rgba(255,255,255,0.55);"></i>
|
||||
<span style="font-size: 0.78rem; color: rgba(255,255,255,0.7);">Programado: 11:00 – 11:50</span>
|
||||
<span style="margin-left: 4px; padding: 1px 8px; border-radius: 999px; background: rgba(251,146,60,0.18); color: rgb(253,186,116); font-size: 0.7rem; font-weight: 500; border: 1px solid rgba(251,146,60,0.35);">atrasada 8 min</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>⚠️ <strong>O cronômetro NÃO desconta o tempo de atraso automaticamente.</strong> Ele conta a duração configurada cheia (50min padrão) a partir do clique. A info "atrasada" é só pra você decidir se quer encerrar antes ou estender.</p>
|
||||
|
||||
<h3>4. Anatomia do dialog</h3>
|
||||
<ul>
|
||||
<li><strong>Header:</strong> rótulo "Cronômetro" + status (<em>Pronto</em> / <em>Em andamento</em> / <em>Pausado</em>)</li>
|
||||
<li><strong>Botão Minimizar:</strong> recolhe pro chip no dock</li>
|
||||
<li><strong>Botão X (Encerrar sem salvar):</strong> descarta a sessão (com confirmação se houver atividade)</li>
|
||||
<li><strong>Select de paciente:</strong> pode trocar manualmente; opção <em>"— Atividade livre"</em> pra usos sem paciente</li>
|
||||
<li><strong>Programado + badge de atraso:</strong> só aparece quando aberto via evento da agenda</li>
|
||||
<li><strong>Display gigante mm:ss:</strong> vira vermelho quando passa do tempo planejado (mostra <code>-mm:ss</code>)</li>
|
||||
<li><strong>±5 min:</strong> estende ou encurta o tempo configurado a qualquer momento</li>
|
||||
<li><strong>Botão grande inferior:</strong> ▶ Começar / ⏹ Parar</li>
|
||||
</ul>
|
||||
|
||||
<h3>5. Minimizar e restaurar</h3>
|
||||
<p>Click no <strong>botão minimizar</strong> (ou click fora do dialog) <strong>recolhe</strong> o cronômetro pra um <strong>chip flutuante no dock</strong> (canto inferior esquerdo, ao lado do ψ). O timer continua rodando em background. Click no chip restaura o dialog em tela cheia.</p>
|
||||
|
||||
<div style="display: inline-flex; align-items: center; gap: 10px; padding: 8px 14px 8px 12px; background: rgba(15,23,42,0.85); border: 1px solid rgba(255,255,255,0.18); border-radius: 999px; color: #cbd5e1; font-family: 'Segoe UI', sans-serif; box-shadow: 0 8px 24px rgba(0,0,0,0.25); margin: 12px 0;">
|
||||
<i class="pi pi-stopwatch" style="font-size: 0.85rem; color: #6ee7b7;"></i>
|
||||
<span style="font-variant-numeric: tabular-nums; font-weight: 500; font-size: 0.85rem;">48:13</span>
|
||||
<span style="font-size: 0.72rem; color: rgba(255,255,255,0.6); padding-left: 6px; border-left: 1px solid rgba(255,255,255,0.18);">Larissa Almeida</span>
|
||||
</div>
|
||||
|
||||
<p>Em mobile (telas <768px), o chip mostra só o ícone + tempo — sem o nome do paciente — pra caber no dock estreito. O nome continua acessível ao restaurar.</p>
|
||||
|
||||
<h3>6. Parar (salva) vs Fechar (descarta)</h3>
|
||||
<p>Duas ações diferentes pra terminar — a escolha importa:</p>
|
||||
<ul>
|
||||
<li><strong>⏹ Parar</strong> (botão grande inferior): encerra a contagem e <strong>SALVA o tempo decorrido no banco</strong> (evento <code>session-end</code> com elapsed em segundos). Caminho normal de fim de sessão.</li>
|
||||
<li><strong>X Encerrar sem salvar</strong> (header): descarta. Pede <strong>confirmação</strong> se há sessão em andamento ou tempo decorrido — não fecha por acidente. Se o cronômetro está limpo (não iniciado, sem tempo), fecha direto.</li>
|
||||
<li><strong>Click fora / Minimizar</strong>: NÃO encerra. Esconde o dialog e mantém o timer rodando como chip no dock.</li>
|
||||
</ul>
|
||||
|
||||
<h3>7. Quando o tempo acaba</h3>
|
||||
<p>Aos <strong>50 minutos cronometrados</strong> (ou conforme configurado), o sistema toca um <strong>som curto</strong> uma única vez. O display vira <strong>vermelho</strong> e continua contando em negativo (mostra <code>-mm:ss</code>). <strong>Não há corte automático</strong> — você decide quando parar.</p>
|
||||
|
||||
<p>O toque pode ser trocado em <strong>Configurações → Cronômetro → Som de término</strong>. Opções: sino, gong, soft, silêncio.</p>
|
||||
|
||||
<h3>8. Persistência (reload-safe)</h3>
|
||||
<p>Se você fechar a aba ou recarregar o navegador com cronômetro ativo, ao voltar o sistema <strong>retoma exatamente de onde parou</strong> — descontando automaticamente o tempo passado entre fechar e abrir. O snapshot fica no <code>localStorage</code> do navegador, atualizado a cada mudança de estado.</p>
|
||||
|
||||
<p><strong>Limite de segurança:</strong> se passar de 24h sem voltar à aba, o restore não acumula o tempo perdido (proteção contra mudanças do relógio do sistema).</p>
|
||||
|
||||
<h3>9. Cronômetro já ativo</h3>
|
||||
<p>Existe <strong>um cronômetro por vez</strong>. Se você clicar no <strong>⏱</strong> de outra sessão enquanto há cronômetro rodando, o sistema mostra um toast <strong>"Cronômetro já ativo"</strong> com o nome do paciente atual — <strong>e não troca</strong>. Pare o cronômetro atual antes de iniciar outro.</p>
|
||||
|
||||
<h3>10. Atividade livre (sem paciente)</h3>
|
||||
<p>Você pode abrir o cronômetro sem paciente (botão ⏱ do dashboard, sem clicar em evento específico) e selecionar <em>"— Atividade livre (sem paciente)"</em> no dropdown. Útil pra:</p>
|
||||
<ul>
|
||||
<li>Pausa cronometrada</li>
|
||||
<li>Pomodoro pessoal</li>
|
||||
<li>Atendimento informal não cadastrado</li>
|
||||
</ul>
|
||||
<p>Atividade livre <strong>não emite session-end</strong> ao parar (não há paciente pra vincular o tempo).</p>
|
||||
|
||||
<h3>⚠️ Notas pro desenvolvedor</h3>
|
||||
<p>Atualmente o componente <code>MelissaCronometro.vue</code> <strong>não tem atributos <code>id</code></strong> em seus elementos. Para o sistema de highlight da ajuda funcionar (links <code>data-highlight</code>), sugere-se adicionar:</p>
|
||||
<ul>
|
||||
<li><code>id="crono-trigger-hero"</code> no botão ⏱ ao lado do relógio (<code>MelissaHeroClock.vue</code>)</li>
|
||||
<li><code>id="crono-trigger-timeline"</code> nos botões ⏱ overlay (<code>MelissaTimelineHoje.vue</code>)</li>
|
||||
<li><code>id="crono-dialog"</code> no panel principal (<code>.mc-panel</code>)</li>
|
||||
<li><code>id="crono-stop-btn"</code> no botão Parar (caminho do salvamento)</li>
|
||||
<li><code>id="crono-close-btn"</code> no X (caminho do descarte)</li>
|
||||
</ul>$HTML$,
|
||||
'Sessão',
|
||||
true,
|
||||
'usuario',
|
||||
'/melissa',
|
||||
2,
|
||||
true,
|
||||
'[{"tipo": "imagem", "url": ""}]'::jsonb
|
||||
)
|
||||
RETURNING id INTO v_doc_id;
|
||||
|
||||
-- 2) Insere os 12 FAQ items vinculados
|
||||
INSERT INTO public.saas_faq_itens (doc_id, pergunta, resposta, ordem, ativo) VALUES
|
||||
(v_doc_id, 'Como começo o cronômetro da Larissa que chegou agora pra sessão?',
|
||||
$FAQ$Quando o horário programado da Larissa estiver dentro da janela do evento (já começou na agenda), aparece um botão <strong>⏱</strong> pulsando em verde no canto superior direito do card da sessão na timeline. Clique nele — o cronômetro abre com a Larissa pré-selecionada e <strong>já começa a contar automaticamente</strong>. Alternativa: clique no botão <em>"Iniciar cronômetro"</em> no card "Próximo paciente" do dashboard (mesmo efeito).$FAQ$, 0, true),
|
||||
|
||||
(v_doc_id, 'O cronômetro continua se eu fechar a aba do navegador?',
|
||||
$FAQ$<strong>Sim.</strong> O estado é salvo no <code>localStorage</code> a cada mudança (paciente, play, pause, ajustes). Ao reabrir a aba (ou recarregar), o cronômetro retoma do ponto correto — o tempo passado entre fechar e abrir é descontado automaticamente. Limite: se passar de 24h, o sistema não acumula esse tempo (proteção contra mudanças do relógio do sistema).$FAQ$, 1, true),
|
||||
|
||||
(v_doc_id, 'Cliquei no X com sessão rodando, perdi o tempo?',
|
||||
$FAQ$Não, o sistema <strong>pede confirmação antes</strong>. Quando há sessão em andamento ou tempo decorrido sem salvar, aparece um diálogo <em>"Encerrar sessão sem salvar?"</em>. Você precisa clicar em <strong>"Encerrar sem salvar"</strong> (botão vermelho) pra confirmar o descarte. Se o cronômetro estiver limpo (não iniciado, sem tempo), o X fecha direto — não há nada pra preservar.$FAQ$, 2, true),
|
||||
|
||||
(v_doc_id, 'Posso ter dois cronômetros rodando ao mesmo tempo?',
|
||||
$FAQ$Não. Existe <strong>um cronômetro por vez</strong>. Se você clicar no <strong>⏱</strong> de outra sessão enquanto já há um cronômetro ativo, o sistema mostra um toast <em>"Cronômetro já ativo — sessão de X em andamento"</em> e <strong>não troca</strong>. Pare ou descarte o atual antes de iniciar outro.$FAQ$, 3, true),
|
||||
|
||||
(v_doc_id, 'O que significa o badge laranja "atrasada 8 min"?',
|
||||
$FAQ$Significa que <strong>o cronômetro foi aberto 8 minutos depois do horário programado</strong> da sessão na agenda. Por exemplo: sessão programada pra 11:00, você inicia o cronômetro às 11:08. O badge é apenas informativo — o cronômetro continua contando a duração configurada cheia (50min padrão) a partir do clique. Você decide se vai encerrar antes pra terminar no horário previsto ou deixar rodar pra dar a sessão completa.$FAQ$, 4, true),
|
||||
|
||||
(v_doc_id, 'O cronômetro desconta o tempo de atraso automaticamente?',
|
||||
$FAQ$<strong>Não.</strong> A decisão fica com você. Cada clínica e cada terapeuta tem uma política diferente pra atraso (alguns dão sessão cheia, outros encerram no horário programado, outros estendem). O cronômetro mostra a info do atraso pra você decidir, mas conta sempre a duração configurada cheia a partir do clique.$FAQ$, 5, true),
|
||||
|
||||
(v_doc_id, 'Que som toca quando o tempo acaba?',
|
||||
$FAQ$Por padrão, um <strong>som de sino curto</strong>, uma única vez. Você pode trocar em <strong>Configurações → Cronômetro → Som de término</strong>. Opções: sino, gong, soft, silêncio. O som toca <strong>exatamente na transição</strong> de tempo positivo pra zero/negativo — não repete. Depois disso o display continua contando em negativo (vermelho) até você parar.$FAQ$, 6, true),
|
||||
|
||||
(v_doc_id, 'Como adiciono mais tempo na sessão sem reiniciar?',
|
||||
$FAQ$Use os botões <strong>+5 min</strong> e <strong>-5 min</strong> ao redor do display gigante. Funcionam a qualquer momento — antes, durante ou depois do tempo acabar. Cada clique soma ou desconta 5 minutos. Se o tempo já está negativo (passou do limite), +5min volta a contagem pra positivo.$FAQ$, 7, true),
|
||||
|
||||
(v_doc_id, 'Onde fica salvo o tempo final da sessão?',
|
||||
$FAQ$Quando você clica em <strong>⏹ Parar</strong>, o tempo cronometrado é gravado no banco vinculado à sessão da agenda (na tabela <code>agenda_eventos</code>, campo de duração real). Esse caminho é o oficial — <strong>fechar pelo X descarta sem salvar</strong>. Sessões com menos de 5 segundos cronometrados são ignoradas (proteção contra start/stop acidentais).$FAQ$, 8, true),
|
||||
|
||||
(v_doc_id, 'Posso usar o cronômetro pra coisas que não são sessão de paciente?',
|
||||
$FAQ$Sim. Selecione <em>"— Atividade livre (sem paciente)"</em> no dropdown de paciente. Útil pra pausa cronometrada, pomodoro pessoal, atendimento informal não cadastrado. Atividade livre <strong>não dispara session-end</strong> ao parar — não há paciente pra vincular o tempo no DB.$FAQ$, 9, true),
|
||||
|
||||
(v_doc_id, 'Como minimizo o cronômetro pra continuar trabalhando?',
|
||||
$FAQ$Clique no botão <strong>minimizar</strong> no header (ícone <code>—</code>) ou simplesmente <strong>clique fora do dialog</strong>. O cronômetro vira um chip flutuante no dock (canto inferior esquerdo, ao lado do ψ) e continua contando em background. Pra restaurar: clique no chip. Em mobile, o chip mostra só ícone + tempo (sem nome) pra caber no dock estreito.$FAQ$, 10, true),
|
||||
|
||||
(v_doc_id, 'Como mudo o paciente no cronômetro já aberto?',
|
||||
$FAQ$Basta clicar no <strong>select de paciente</strong> e escolher outro. A troca é imediata — não reinicia o tempo decorrido (a contagem continua igual). Útil quando você abriu o cronômetro no paciente errado e quer corrigir sem perder o tempo já contado. Mas atenção: o <em>session-end</em> ao parar vai vincular o tempo ao paciente que estiver selecionado <em>no momento da parada</em>.$FAQ$, 11, true);
|
||||
|
||||
RAISE NOTICE 'Doc criada: id=%, faq_itens=12', v_doc_id;
|
||||
END;
|
||||
$IMPORT$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,173 @@
|
||||
-- Importacao da doc da aba Documentos do paciente (Fase 2)
|
||||
-- Gerado a partir de development/saas-docs/03-documentos-paciente-melissa.json
|
||||
BEGIN;
|
||||
|
||||
DO $IMPORT$
|
||||
DECLARE
|
||||
v_doc_id uuid;
|
||||
BEGIN
|
||||
INSERT INTO public.saas_docs (
|
||||
titulo, conteudo, categoria, exibir_no_faq, tipo_acesso,
|
||||
pagina_path, ordem, ativo, medias
|
||||
) VALUES (
|
||||
'Documentos do paciente',
|
||||
$HTML$<h2>Documentos do paciente</h2>
|
||||
|
||||
<p>A aba <strong>Documentos</strong> do prontuário (em <code>/melissa/paciente?id=...&tab=documentos</code>) centraliza tudo que está vinculado àquele paciente: arquivos enviados por upload, documentos gerados a partir de templates (atestados, declarações, recibos, laudos…), e tudo que precisa ser compartilhado ou assinado.</p>
|
||||
|
||||
<h3>1. Layout 2-col</h3>
|
||||
<p>A página tem 2 colunas:</p>
|
||||
<ul>
|
||||
<li><strong>Sidebar esquerda (~240px):</strong> lista de tipos de documento com contadores. Click num tipo filtra a lista. "Todos" mostra tudo.</li>
|
||||
<li><strong>Main direita:</strong> grid de cards dos documentos do tipo selecionado, com paginação a partir de 12 itens.</li>
|
||||
</ul>
|
||||
<p>No <strong>mobile</strong> (<1024px), a sidebar vira um drawer acessado pelo botão "Tipos" no header.</p>
|
||||
|
||||
<h3>2. Toolbar (header)</h3>
|
||||
<p>3 botões no topo:</p>
|
||||
<ul>
|
||||
<li><strong>↻ Atualizar:</strong> refetch da lista (ícone spinner quando carregando)</li>
|
||||
<li><strong>📄 Gerar:</strong> abre o dialog de geração a partir de template (vide seção 5)</li>
|
||||
<li><strong>⬆ Upload</strong> (botão primário): abre o dialog de envio de arquivo (vide seção 3)</li>
|
||||
</ul>
|
||||
|
||||
<h3>3. Upload de arquivo</h3>
|
||||
<p>Click no botão <strong>Upload</strong> abre um dialog que aceita:</p>
|
||||
<ul>
|
||||
<li><strong>Drag-and-drop</strong> ou seleção manual</li>
|
||||
<li>Formatos: PDF, imagens (JPG, PNG, WebP), Word, Excel, texto</li>
|
||||
<li>Metadados opcionais: <strong>tipo</strong>, <strong>categoria</strong>, <strong>descrição</strong>, <strong>tags</strong>, <strong>visibilidade</strong> (privado / compartilhado supervisor / compartilhado portal paciente)</li>
|
||||
</ul>
|
||||
<p>Após o upload, o arquivo aparece na lista do tipo escolhido (ou "Outro" se você não selecionou).</p>
|
||||
|
||||
<h3>4. Tipos de documento (sidebar)</h3>
|
||||
<p>Cada documento é classificado em um tipo. Tipos disponíveis:</p>
|
||||
<ul>
|
||||
<li><strong>Laudo</strong> — laudo psicológico, parecer</li>
|
||||
<li><strong>Atestado</strong> — atestado psicológico</li>
|
||||
<li><strong>Declaração</strong> — comparecimento, início de tratamento, encaminhamento</li>
|
||||
<li><strong>Recibo</strong> — recibos de pagamento gerados</li>
|
||||
<li><strong>Receita</strong> — receituários (uso raro em psicologia)</li>
|
||||
<li><strong>Exame</strong> — laudos/resultados de exames trazidos pelo paciente</li>
|
||||
<li><strong>Termo assinado</strong> — TCLE, autorizações</li>
|
||||
<li><strong>Relatório externo</strong> — relatórios de acompanhamento gerados</li>
|
||||
<li><strong>Identidade</strong> — RG, CPF, CNH (cópias)</li>
|
||||
<li><strong>Convênio</strong> — carteirinhas, autorizações de convênio</li>
|
||||
<li><strong>Outro</strong> — fallback pra tudo que não se encaixa nos tipos acima</li>
|
||||
</ul>
|
||||
<p>O contador ao lado de cada tipo mostra quantos docs daquele tipo o paciente tem. Tipos vazios ficam com opacidade reduzida.</p>
|
||||
|
||||
<h3>5. Gerar a partir de template</h3>
|
||||
<p>Click no botão <strong>Gerar</strong> abre o <em>DocumentGenerateDialog</em> em 3 passos:</p>
|
||||
<ol>
|
||||
<li><strong>Selecionar template:</strong> grid com todos os templates ativos (globais + do tenant). Click num card seleciona.</li>
|
||||
<li><strong>Editar variáveis:</strong> os campos do template aparecem com FloatLabel. Variáveis que vêm do sistema (nome do paciente, CRP do terapeuta, CNPJ da clínica etc) já vêm preenchidas automaticamente. Banner no topo conta "X de Y preenchidos". Campos vazios mostram um hint embaixo explicando onde cadastrar o dado (ex: <em>"Perfil → Registro Profissional"</em>).</li>
|
||||
<li><strong>Preview:</strong> iframe sandboxed renderizando o HTML do template com as vars substituídas. Daqui você pode voltar pra editar, só baixar o PDF (sem salvar no sistema), ou salvar como documento do paciente.</li>
|
||||
</ol>
|
||||
|
||||
<div style="background: rgba(34,197,94,0.08); border: 1px solid rgba(34,197,94,0.25); border-radius: 10px; padding: 12px 14px; margin: 14px 0; font-size: 0.85rem; color: var(--text-color);">
|
||||
<strong>💡 Auto-fill cobre:</strong> dados do paciente, terapeuta (incluindo registro profissional formatado tipo "CRP 12345/SP"), clínica/tenant (incluindo CNPJ formatado), data atual em formato curto e por extenso, e — se a sessão for vinculada — valor da sessão em número e por extenso.
|
||||
</div>
|
||||
|
||||
<h3>6. Editar um documento gerado (re-edição in-place)</h3>
|
||||
<p>Documentos gerados a partir de template podem ser <strong>re-editados</strong> mantendo o mesmo registro (ID, audit trail e link com o paciente preservados). Click em <strong>Editar</strong> no card do doc ou na sidebar do preview:</p>
|
||||
|
||||
<ol>
|
||||
<li>O sistema busca o template original + os valores que você usou na primeira geração</li>
|
||||
<li>Abre o dialog em modo edição (header amber "Editar documento") pulando direto pro passo 2 (variáveis pré-preenchidas)</li>
|
||||
<li>Você ajusta o que precisar → Preview → <strong>Substituir documento</strong></li>
|
||||
<li>O PDF é regenerado e substitui o anterior no Storage; o doc fica com o mesmo ID, audit trail intacto</li>
|
||||
</ol>
|
||||
|
||||
<p><strong>Documento legado</strong> (sem registro de geração ou que era um upload): o dialog mostra um toast e cai no fluxo normal de "selecione um template". Ao salvar, ele linka o doc existente ao novo template/valores.</p>
|
||||
|
||||
<h3>7. Preview do documento</h3>
|
||||
<p>Click num card abre o <em>DocumentPreviewDialog</em>:</p>
|
||||
<ul>
|
||||
<li><strong>Preview inline:</strong> iframe pra PDF, imagem renderizada direto, fallback "Preview não disponível" pra outros formatos</li>
|
||||
<li><strong>Sidebar de detalhes</strong> (direita): tipo, categoria, visibilidade, descrição, tags</li>
|
||||
<li><strong>5 botões de ação</strong> no rodapé da sidebar:
|
||||
<ul>
|
||||
<li><strong>Baixar</strong> — download direto do arquivo</li>
|
||||
<li><strong>Editar</strong> — abre o generate dialog em modo edição (seção 6)</li>
|
||||
<li><strong>Compartilhar</strong> — gera link compartilhável (seção 8)</li>
|
||||
<li><strong>Assinar</strong> — fluxo de assinatura eletrônica (seção 9)</li>
|
||||
<li><strong>Excluir</strong> (vermelho) — soft-delete com confirmação</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3>8. Compartilhar</h3>
|
||||
<p>Gera um link público temporário pro paciente acessar o documento sem precisar de login. Configurável:</p>
|
||||
<ul>
|
||||
<li>Tempo de expiração (1h, 24h, 7 dias, custom)</li>
|
||||
<li>Senha opcional</li>
|
||||
<li>Permitir download ou só visualização</li>
|
||||
</ul>
|
||||
<p>O status compartilhado fica visível na sidebar de detalhes do preview.</p>
|
||||
|
||||
<h3>9. Assinar</h3>
|
||||
<p>Fluxo de assinatura eletrônica (modal). O documento original recebe uma <strong>página adicional de assinatura</strong> com timestamp e identificação do signatário. A assinatura é registrada em <code>document_signatures</code> com hash do conteúdo original (proof of integrity).</p>
|
||||
|
||||
<h3>10. Excluir e recuperar</h3>
|
||||
<p>Excluir é <strong>soft-delete</strong>: o documento ganha <code>deleted_at</code> mas o arquivo permanece no Storage e o registro fica preservado por <strong>5 anos</strong> (compliance LGPD/CFP). Pra recuperar, vá em <strong>Configurações → Lixo de documentos</strong>.</p>
|
||||
|
||||
<h3>⚠️ Notas pro desenvolvedor</h3>
|
||||
<p>O componente <code>MelissaPatientDocuments.vue</code> reusa do <code>features/documents</code>:</p>
|
||||
<ul>
|
||||
<li><code>useDocuments</code> — composable de fetch/CRUD/URLs assinadas</li>
|
||||
<li><code>DocumentCard</code>, <code>DocumentUploadDialog</code>, <code>DocumentPreviewDialog</code>, <code>DocumentGenerateDialog</code>, <code>DocumentSignatureDialog</code>, <code>DocumentShareDialog</code></li>
|
||||
</ul>
|
||||
<p>O linkage <code>document_generated.documento_id</code> (FK pra <code>documents</code>) é o que viabiliza a re-edição in-place. Docs gerados antes da migration de linkage precisam do backfill SQL em <code>database-novo/tmp/backfill-document-generated-link.sql</code>.</p>$HTML$,
|
||||
'Documentos',
|
||||
true,
|
||||
'usuario',
|
||||
'/melissa/paciente',
|
||||
3,
|
||||
true,
|
||||
'[{"tipo": "imagem", "url": ""}]'::jsonb
|
||||
)
|
||||
RETURNING id INTO v_doc_id;
|
||||
|
||||
INSERT INTO public.saas_faq_itens (doc_id, pergunta, resposta, ordem, ativo) VALUES
|
||||
(v_doc_id, 'Como envio um documento que já existe (PDF/imagem do paciente)?',
|
||||
$FAQ$Na aba <strong>Documentos</strong> do prontuário, click no botão <strong>Upload</strong> (azul, no canto superior direito). Você pode arrastar o arquivo pra área do dialog ou clicar pra selecionar. Antes de enviar, preencha o tipo, descrição e tags se quiser — assim o doc já vai pra categoria certa na sidebar.$FAQ$, 0, true),
|
||||
|
||||
(v_doc_id, 'Como gero um documento (atestado, declaração, recibo) a partir de template?',
|
||||
$FAQ$Click no botão <strong>Gerar</strong> no header da aba Documentos do paciente. O dialog abre em 3 passos: (1) escolha o template, (2) confira as variáveis pré-preenchidas (e ajuste se necessário), (3) preview e <em>Salvar documento</em>. O PDF é gerado e salvo automaticamente no prontuário.$FAQ$, 1, true),
|
||||
|
||||
(v_doc_id, 'As variáveis (CRP, nome, CNPJ etc) preenchem sozinhas mesmo?',
|
||||
$FAQ$Sim, sempre que possível. O sistema busca: dados do paciente (nome, CPF, RG, endereço, telefone, email…), do terapeuta (nome, email, telefone, e o registro profissional formatado tipo <em>CRP 12345/SP</em>), da clínica (nome, endereço, telefone, CNPJ formatado), data atual em formato curto e por extenso. Se você abriu o gerador a partir de uma sessão, os dados da sessão (valor, data) também entram. Campos vazios mostram embaixo um hint dizendo onde cadastrar o dado faltante.$FAQ$, 2, true),
|
||||
|
||||
(v_doc_id, 'Posso editar um documento gerado sem refazer tudo do zero?',
|
||||
$FAQ$Sim. Click em <strong>Editar</strong> no card do documento (ou na sidebar do preview). O dialog abre em <em>modo edição</em> com o template original já selecionado e <strong>todos os valores que você usou anteriormente preenchidos</strong>. Você ajusta o que precisa, confere o preview e click em <em>Substituir documento</em>. O PDF é regenerado e substitui o anterior, mas o ID e o audit trail do doc continuam os mesmos.$FAQ$, 3, true),
|
||||
|
||||
(v_doc_id, 'Posso editar um documento que foi feito por upload (não por template)?',
|
||||
$FAQ$Sim, mas o fluxo é diferente: como não há template original, o sistema mostra um aviso e abre o dialog em modo "selecione um template". Ao salvar, ele <strong>substitui o arquivo enviado por um PDF gerado</strong> e linka ao novo template. Útil pra "converter" um upload manual em algo padronizado. Se você só quer trocar o arquivo, exclua o doc e faça upload do novo.$FAQ$, 4, true),
|
||||
|
||||
(v_doc_id, 'Como compartilho um documento com o paciente sem ele precisar logar?',
|
||||
$FAQ$No preview, click em <strong>Compartilhar</strong>. Um dialog gera um link público temporário com opção de tempo de expiração (1h, 24h, 7 dias, custom) e senha opcional. O paciente acessa pelo link, sem login. O status fica visível na sidebar de detalhes do doc.$FAQ$, 5, true),
|
||||
|
||||
(v_doc_id, 'Como assino eletronicamente um documento?',
|
||||
$FAQ$No preview, click em <strong>Assinar</strong>. O fluxo adiciona uma página de assinatura ao PDF com timestamp e identificação. A assinatura é registrada com hash do conteúdo original — qualquer alteração posterior invalida a integridade. Ideal pra laudos, declarações e atestados que precisam de validade legal.$FAQ$, 6, true),
|
||||
|
||||
(v_doc_id, 'Excluí um documento por engano, dá pra recuperar?',
|
||||
$FAQ$Sim. Exclusão é <strong>soft-delete</strong> — o documento ganha um marcador <code>deleted_at</code> mas continua no banco e o arquivo permanece no Storage. Pra recuperar, vá em <strong>Configurações → Lixo de documentos</strong>. O período de retenção é de <strong>5 anos</strong> (compliance LGPD e regulamentação CFP), depois o arquivo é purgado permanentemente.$FAQ$, 7, true),
|
||||
|
||||
(v_doc_id, 'Por que alguns documentos aparecem na categoria "Outro"?',
|
||||
$FAQ$Documentos enviados por upload sem tipo definido caem em "Outro" automaticamente. Documentos gerados a partir de templates cujo tipo não está mapeado pras categorias padrão (declarações, atestados, laudos, etc) também — exemplos: contrato de prestação de serviços, autorização para gravação, termo de consentimento. Você pode mover o doc pra outra categoria editando o tipo na hora do upload ou via menu de ações no card.$FAQ$, 8, true),
|
||||
|
||||
(v_doc_id, 'Quais formatos de arquivo posso fazer upload?',
|
||||
$FAQ$PDF, imagens (JPG, PNG, WebP, GIF), documentos Office (DOCX, XLSX, PPTX), texto simples (TXT, CSV) e formatos compactados (ZIP). Pra qualquer formato fora dessa lista, salve como PDF antes. O preview inline só funciona pra PDF e imagens — outros formatos mostram a opção "Baixar arquivo" no lugar.$FAQ$, 9, true),
|
||||
|
||||
(v_doc_id, 'Como o sistema garante que o documento não vaza pra outros profissionais?',
|
||||
$FAQ$Cada documento tem um campo de <strong>visibilidade</strong>: <em>Privado</em> (só você vê), <em>Compartilhado com supervisor</em> (você + seu supervisor) ou <em>Compartilhado com portal do paciente</em> (o paciente também vê pelo portal). O default é Privado. RLS (Row Level Security) no banco bloqueia leitura por terceiros, independente da visibilidade. URLs do Storage são assinadas e expiram em 1h.$FAQ$, 10, true),
|
||||
|
||||
(v_doc_id, 'Os botões da sidebar do preview (Baixar/Editar/Compartilhar/Assinar/Excluir) não funcionavam, foi corrigido?',
|
||||
$FAQ$Sim. Bug conhecido até 2026-05-22: o <code>DocumentPreviewDialog</code> emitia os 5 eventos mas o componente pai não os escutava, então nada acontecia ao clicar. Agora todos os 5 botões funcionam normalmente e o de Editar abre o dialog de geração em modo edição.$FAQ$, 11, true);
|
||||
|
||||
RAISE NOTICE 'Doc criada: id=%, faq_itens=12', v_doc_id;
|
||||
END;
|
||||
$IMPORT$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,122 @@
|
||||
-- Importacao da doc da pagina de Templates de documentos (Fase 2)
|
||||
-- Gerado a partir de development/saas-docs/04-documentos-templates-melissa.json
|
||||
BEGIN;
|
||||
|
||||
DO $IMPORT$
|
||||
DECLARE
|
||||
v_doc_id uuid;
|
||||
BEGIN
|
||||
INSERT INTO public.saas_docs (
|
||||
titulo, conteudo, categoria, exibir_no_faq, tipo_acesso,
|
||||
pagina_path, ordem, ativo, medias
|
||||
) VALUES (
|
||||
'Templates de documentos',
|
||||
$HTML$<h2>Templates de documentos</h2>
|
||||
|
||||
<p>A página <strong>Templates de documentos</strong> (acessível pelo menu Prontuários → Templates de documentos, ou diretamente em <code>/melissa/documentos-templates</code>) é onde você gerencia os modelos usados pra gerar atestados, declarações, recibos, laudos e outros documentos clínicos.</p>
|
||||
|
||||
<h3>1. Globais vs Tenant (Seus templates)</h3>
|
||||
<p>A lista é dividida em 2 grupos:</p>
|
||||
<ul>
|
||||
<li><strong>Templates padrão (globais)</strong> — vêm pré-instalados com o sistema (Declaração de Comparecimento, Atestado Psicológico, Recibo de Pagamento, Laudo Psicológico, Parecer, Encaminhamento, etc). São <strong>read-only</strong> — você não pode editar nem desativar, mas pode duplicar pra personalizar.</li>
|
||||
<li><strong>Seus templates (tenant)</strong> — os que você criou ou duplicou. Editáveis, removíveis (desativação soft-delete).</li>
|
||||
</ul>
|
||||
<p>Todos os templates ativos do tenant (globais + seus) ficam disponíveis na hora de gerar um documento pro paciente.</p>
|
||||
|
||||
<h3>2. Lista de templates</h3>
|
||||
<p>Cards em grid mostrando: nome, tipo, descrição, badge "padrão" pros globais. No card de cada template do tenant há um menu de 3 pontos com: <strong>Duplicar</strong>, <strong>Editar</strong>, <strong>Desativar</strong>. Pros globais, só <strong>Duplicar</strong> (e click no card abre a Preview).</p>
|
||||
|
||||
<h3>3. Preview de template global (read-only)</h3>
|
||||
<p>Click num template padrão abre a Preview — iframe sandbox renderizando o HTML completo (cabeçalho + corpo + rodapé) com estilos de A4 simulando o PDF final. Header tem botão <strong>Duplicar</strong> pra você levar pros seus templates.</p>
|
||||
|
||||
<h3>4. Criar novo template</h3>
|
||||
<p>Botão <strong>+ Novo template</strong> abre o editor em modo "create". Campos:</p>
|
||||
<ul>
|
||||
<li><strong>Nome</strong> e <strong>tipo</strong> (declaração, atestado, recibo, laudo, etc) — define a categoria do documento gerado</li>
|
||||
<li><strong>Descrição</strong> opcional — aparece na lista</li>
|
||||
<li><strong>Cabeçalho</strong> (top fixo) — geralmente nome da clínica, endereço, CNPJ</li>
|
||||
<li><strong>Corpo</strong> (conteúdo principal) — o texto do documento com variáveis interpoladas</li>
|
||||
<li><strong>Rodapé</strong> (bottom fixo) — assinatura, contato, observações</li>
|
||||
</ul>
|
||||
|
||||
<h3>5. Editor rich-text + variáveis</h3>
|
||||
<p>Cada bloco (cabeçalho/corpo/rodapé) tem editor WYSIWYG com formatação, listas, tabelas e inserção de imagens. Ao clicar no botão de <strong>variáveis</strong>, abre um menu com todas as variáveis disponíveis. Click numa insere <code>{{nome_da_variavel}}</code> no cursor.</p>
|
||||
|
||||
<div style="background: rgba(99,102,241,0.08); border: 1px solid rgba(99,102,241,0.25); border-radius: 10px; padding: 12px 14px; margin: 14px 0; font-size: 0.85rem; color: var(--text-color);">
|
||||
<strong>💡 Variáveis disponíveis:</strong> <code>{{paciente_nome}}</code>, <code>{{paciente_cpf}}</code>, <code>{{paciente_rg}}</code>, <code>{{paciente_email}}</code>, <code>{{terapeuta_nome}}</code>, <code>{{terapeuta_registro}}</code> (CRP 12345/SP formatado), <code>{{terapeuta_telefone}}</code>, <code>{{clinica_nome}}</code>, <code>{{clinica_cnpj}}</code>, <code>{{data_atual}}</code>, <code>{{data_atual_extenso}}</code>, e — se gerado a partir de sessão — <code>{{valor}}</code>, <code>{{valor_extenso}}</code>, <code>{{data_sessao}}</code>. Lista completa no dropdown do editor.
|
||||
</div>
|
||||
|
||||
<h3>6. Mobile (drawer pros templates)</h3>
|
||||
<p>Em telas <1024px a lista vira um drawer com botão "Templates" no header. Click num item fecha o drawer e mostra o preview/editor ocupando a tela toda.</p>
|
||||
|
||||
<h3>7. Duplicar</h3>
|
||||
<p>Duplicar copia o template (incluindo cabeçalho, corpo, rodapé e variáveis) pra <em>Seus templates</em> com sufixo <em>"(cópia)"</em> no nome. Você edita à vontade depois.</p>
|
||||
|
||||
<h3>8. Desativar (soft-delete)</h3>
|
||||
<p>Templates do tenant podem ser <strong>desativados</strong> (não excluídos). Ficam marcados com <code>ativo = false</code> e somem da lista padrão e do dropdown de geração — mas o registro permanece no banco, e documentos antigos gerados a partir desse template continuam acessíveis. Pra reativar, marque "incluir desativados" no filtro (futuro — atualmente só via DB).</p>
|
||||
|
||||
<h3>9. Tipos de template</h3>
|
||||
<p>Cada template tem um <strong>tipo</strong>. O tipo determina automaticamente qual categoria o documento gerado terá no prontuário do paciente:</p>
|
||||
<ul>
|
||||
<li><code>declaracao_comparecimento</code>, <code>declaracao_inicio_tratamento</code>, <code>encaminhamento</code> → categoria <strong>Declaração</strong></li>
|
||||
<li><code>atestado_psicologico</code> → categoria <strong>Atestado</strong></li>
|
||||
<li><code>laudo_psicologico</code>, <code>parecer_psicologico</code> → categoria <strong>Laudo</strong></li>
|
||||
<li><code>recibo_pagamento</code> → categoria <strong>Recibo</strong></li>
|
||||
<li><code>relatorio_acompanhamento</code> → categoria <strong>Relatório externo</strong></li>
|
||||
<li>Outros tipos (<code>termo_consentimento</code>, <code>contrato_servicos</code>, <code>autorizacao_*</code>, <code>outro</code>) → categoria <strong>Outro</strong></li>
|
||||
</ul>
|
||||
|
||||
<h3>⚠️ Notas pro desenvolvedor</h3>
|
||||
<p>O componente <code>MelissaDocumentosTemplates.vue</code> reusa <code>useDocumentTemplates</code> + <code>DocumentTemplateEditor</code>. A lista de tipos vem do composable (<code>TIPOS_TEMPLATE</code>). O mapeamento tipo de template → tipo do documento gerado vive em <code>DocumentGenerate.service.js</code> (<code>TEMPLATE_TYPE_TO_DOC_TYPE</code>). RLS no banco: templates globais (<code>is_global = true</code>) tem leitura aberta; templates do tenant respeitam <code>tenant_id</code>.</p>$HTML$,
|
||||
'Documentos',
|
||||
true,
|
||||
'usuario',
|
||||
'/melissa/documentos-templates',
|
||||
4,
|
||||
true,
|
||||
'[{"tipo": "imagem", "url": ""}]'::jsonb
|
||||
)
|
||||
RETURNING id INTO v_doc_id;
|
||||
|
||||
INSERT INTO public.saas_faq_itens (doc_id, pergunta, resposta, ordem, ativo) VALUES
|
||||
(v_doc_id, 'Pra que serve a página de Templates?',
|
||||
$FAQ$Pra você gerenciar os <strong>modelos</strong> que serão usados na hora de gerar atestados, declarações, recibos, laudos e outros documentos clínicos. Cada template tem cabeçalho, corpo e rodapé com variáveis interpoladas (nome do paciente, CRP, data, etc) — quando você usa o botão <em>Gerar</em> num prontuário, é um desses templates que está sendo aplicado.$FAQ$, 0, true),
|
||||
|
||||
(v_doc_id, 'Por que não consigo editar os templates padrão (com badge "padrão")?',
|
||||
$FAQ$Templates marcados como <strong>globais</strong> (badge azul "padrão") vêm pré-instalados com o sistema e são compartilhados entre todos os tenants. Não dá pra editar pra preservar a versão de referência. Pra personalizar um, click em <strong>Duplicar</strong> — uma cópia vai pra <em>Seus templates</em> e ali você edita à vontade.$FAQ$, 1, true),
|
||||
|
||||
(v_doc_id, 'Como uso uma variável no template?',
|
||||
$FAQ$No editor (cabeçalho, corpo ou rodapé), posicione o cursor onde quer a variável e clique no botão de <strong>variáveis</strong> na barra de ferramentas. Um menu lista todas as variáveis disponíveis agrupadas por categoria. Click numa variável insere <code>{{nome_da_variavel}}</code> no cursor. Na hora de gerar o documento, esse placeholder é substituído pelo valor real.$FAQ$, 2, true),
|
||||
|
||||
(v_doc_id, 'Quais variáveis estão disponíveis?',
|
||||
$FAQ$Agrupadas por categoria — <strong>Paciente:</strong> nome, CPF, RG, data nascimento, email, telefone, endereço. <strong>Terapeuta:</strong> nome, email, telefone, registro profissional (formatado tipo "CRP 12345/SP"), tipo/número/UF do registro separados. <strong>Clínica:</strong> nome, endereço, telefone, CNPJ. <strong>Sessão:</strong> data, hora, valor, valor por extenso, forma de pagamento, modalidade. <strong>Geral:</strong> data atual, data atual por extenso. Lista completa visível no menu de variáveis do editor.$FAQ$, 3, true),
|
||||
|
||||
(v_doc_id, 'Posso recuperar um template que eu desativei?',
|
||||
$FAQ$Sim, mas hoje só via banco de dados (administrador). Desativar é <strong>soft-delete</strong>: o template ganha <code>ativo = false</code> e some da lista. Documentos antigos gerados com ele continuam acessíveis. Em versões futuras teremos um filtro "mostrar desativados" pra reativar via UI.$FAQ$, 4, true),
|
||||
|
||||
(v_doc_id, 'Como duplico um template padrão pra personalizar?',
|
||||
$FAQ$Click no card do template padrão pra abrir a <strong>Preview</strong>. No header da preview tem um botão <strong>Duplicar</strong>. Confirme — a cópia aparece em <em>Seus templates</em> com sufixo "(cópia)" no nome. Em seguida click em <strong>Editar</strong> nessa cópia pra ajustar texto, variáveis, cabeçalho, rodapé.$FAQ$, 5, true),
|
||||
|
||||
(v_doc_id, 'Qual a diferença prática entre template Global e do Tenant?',
|
||||
$FAQ$Globais são compartilhados entre todos os tenants (vêm com o sistema) e são <strong>read-only</strong>. Templates do tenant pertencem só à sua clínica/conta e são editáveis. Ambos aparecem juntos na hora de gerar um documento — você não precisa duplicar pra usar um global, só pra personalizar. Se um global atende, use direto.$FAQ$, 6, true),
|
||||
|
||||
(v_doc_id, 'Posso usar imagens no template (logo da clínica, assinatura digitalizada)?',
|
||||
$FAQ$Sim. O editor aceita inserção de imagens via toolbar. Recomendado: PNG ou JPG com tamanho moderado (logo até 200x80px, assinatura até 300x120px). Imagens muito grandes inflam o PDF gerado. Pra incluir o logo da clínica, prefira colocar no <strong>cabeçalho</strong> — assim aparece no topo de toda página do PDF.$FAQ$, 7, true),
|
||||
|
||||
(v_doc_id, 'O cabeçalho e rodapé aparecem em todas as páginas do PDF?',
|
||||
$FAQ$Sim. O renderizador usa CSS <code>@page</code> com cabeçalho fixo no topo e rodapé fixo no rodapé de cada página gerada. Documentos curtos (1 página) você não percebe; documentos longos (laudos extensos) repetem cabeçalho/rodapé automaticamente. Útil pra manter identificação da clínica em todas as folhas.$FAQ$, 8, true),
|
||||
|
||||
(v_doc_id, 'Como sei se um template tem variável obrigatória?',
|
||||
$FAQ$Hoje não há marcação "obrigatória" — todas as variáveis declaradas no template aparecem como editáveis na hora de gerar. Se uma vier vazia (porque não cadastrou no perfil/paciente/etc), o sistema mostra um hint embaixo do campo dizendo onde cadastrar (ex: <em>"Perfil → Registro Profissional"</em>). Você pode gerar mesmo com vazias — o placeholder fica como <code>{{variavel}}</code> no PDF, mas isso quase nunca é desejado.$FAQ$, 9, true),
|
||||
|
||||
(v_doc_id, 'Tem limite de templates por tenant?',
|
||||
$FAQ$Não há limite hard no banco. Em planos free pode haver limite por contrato (verifique seu plano em Configurações → Plano). Recomendado manter o conjunto enxuto (10-20 templates) pra não poluir o dropdown na hora de gerar — se você não usa, desative.$FAQ$, 10, true),
|
||||
|
||||
(v_doc_id, 'Os templates são compartilhados entre os terapeutas do mesmo tenant?',
|
||||
$FAQ$Sim. Todos os templates do tenant ficam disponíveis pra todos os usuários ativos do mesmo tenant (clínica). Quem cria/edita pode ser qualquer um com permissão de edição — não há "templates privados por usuário" no momento. Se precisar isolar templates por terapeuta, organize por nome (ex: "Atestado · Dra. Ana").$FAQ$, 11, true);
|
||||
|
||||
RAISE NOTICE 'Doc criada: id=%, faq_itens=12', v_doc_id;
|
||||
END;
|
||||
$IMPORT$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,143 @@
|
||||
-- Importacao da doc Emissao de recibo profissional (Fase 4 #14)
|
||||
-- Gerado a partir de development/saas-docs/06-recibo-profissional-melissa.json
|
||||
BEGIN;
|
||||
|
||||
DO $IMPORT$
|
||||
DECLARE
|
||||
v_doc_id uuid;
|
||||
BEGIN
|
||||
INSERT INTO public.saas_docs (
|
||||
titulo, conteudo, categoria, exibir_no_faq, tipo_acesso,
|
||||
pagina_path, ordem, ativo, medias
|
||||
) VALUES (
|
||||
'Emissão de recibo profissional',
|
||||
$HTML$<h2>Emissão de recibo profissional</h2>
|
||||
|
||||
<p>Quando uma sessão é registrada como <strong>paga</strong>, o sistema oferece um botão <em>Emitir recibo</em> que gera um PDF profissional pré-preenchido com todos os dados do paciente, terapeuta, clínica e da sessão — sem precisar passar pelo fluxo "Gerar a partir de template" manual.</p>
|
||||
|
||||
<h3>1. Quando o botão aparece</h3>
|
||||
<p>O botão <strong>Emitir recibo</strong> (outlined, ícone PDF) aparece no <em>painel financeiro do evento</em> (<code>AgendaEventoFinanceiroPanel</code>) — dentro do modal de uma sessão — somente quando:</p>
|
||||
<ul>
|
||||
<li>A sessão tem um <strong>financial_record vinculado</strong> (foi gerada cobrança via "Receber")</li>
|
||||
<li>O status do record é <strong><code>paid</code></strong> (pagamento já registrado)</li>
|
||||
</ul>
|
||||
<p>Em sessões de pacote (status='contrato'), sem cobrança gerada, pendente, ou cancelada — o botão não aparece. Use o fluxo manual de <em>Gerar</em> na aba Documentos pra emitir recibos de casos especiais.</p>
|
||||
|
||||
<h3>2. O que o recibo traz preenchido automaticamente</h3>
|
||||
<ul>
|
||||
<li><strong>Paciente:</strong> nome, CPF, RG (do cadastro do paciente)</li>
|
||||
<li><strong>Sessão:</strong> data e hora, modalidade</li>
|
||||
<li><strong>Valor:</strong> número (R$ 150,00) <strong>e por extenso</strong> ("cento e cinquenta reais")</li>
|
||||
<li><strong>Forma de pagamento:</strong> PIX, dinheiro, cartão, maquininha, etc — vindo do financial_record</li>
|
||||
<li><strong>Terapeuta:</strong> nome completo + registro profissional formatado ("CRP 12345/SP")</li>
|
||||
<li><strong>Clínica:</strong> nome, endereço, telefone, CNPJ formatado</li>
|
||||
<li><strong>Data atual:</strong> em formato curto (22/05/2026) e por extenso ("22 de maio de 2026")</li>
|
||||
</ul>
|
||||
|
||||
<h3>3. Registro profissional genérico</h3>
|
||||
<p>O sistema suporta <strong>qualquer conselho profissional</strong>, não só CRP. A formatação é automática a partir do que está cadastrado no <em>Perfil → Registro Profissional</em>:</p>
|
||||
<ul>
|
||||
<li><strong>CRP</strong> 12345/SP (psicologia)</li>
|
||||
<li><strong>CRM</strong> 67890/RJ (medicina)</li>
|
||||
<li><strong>CRFa</strong> 11111/MG (fonoaudiologia)</li>
|
||||
<li><strong>CREFITO</strong> 22222/SP (fisioterapia)</li>
|
||||
<li><strong>CRESS</strong> 33333/RS (serviço social)</li>
|
||||
<li><strong>CRN</strong> 44444/SP (nutrição)</li>
|
||||
<li>Ou personalizado via tipo "Outro" + nome livre</li>
|
||||
</ul>
|
||||
<p>No template, a variável <code>{{terapeuta_registro}}</code> sempre traz o registro formatado, independente do conselho. Tem também variáveis individuais: <code>{{terapeuta_registro_tipo}}</code>, <code>{{terapeuta_registro_numero}}</code>, <code>{{terapeuta_registro_uf}}</code> pra uso fino.</p>
|
||||
|
||||
<h3>4. Valor por extenso</h3>
|
||||
<p>Helper interno (<code>src/utils/valorExtenso.js</code>) converte número pra extenso em pt-BR completo até 999 milhões:</p>
|
||||
|
||||
<div style="background: rgba(99,102,241,0.06); border: 1px solid rgba(99,102,241,0.2); border-radius: 10px; padding: 12px 14px; margin: 12px 0; font-family: 'IBM Plex Mono', monospace; font-size: 0.82rem;">
|
||||
<strong>R$ 1,00</strong> → "um real"<br>
|
||||
<strong>R$ 150,00</strong> → "cento e cinquenta reais"<br>
|
||||
<strong>R$ 1.234,56</strong> → "mil duzentos e trinta e quatro reais e cinquenta e seis centavos"<br>
|
||||
<strong>R$ 0,50</strong> → "cinquenta centavos"<br>
|
||||
<strong>R$ 1.000.000,00</strong> → "um milhão de reais"
|
||||
</div>
|
||||
|
||||
<p>Pluralização correta (real/reais, centavo/centavos), tratamento de centavos isolados ("R$ 0,X"), milhar com "mil" sem "um", milhão/milhões.</p>
|
||||
|
||||
<h3>5. Onde o recibo é salvo</h3>
|
||||
<p>Ao clicar <strong>Emitir recibo</strong>:</p>
|
||||
<ol>
|
||||
<li>Sistema busca o template global <code>recibo_pagamento</code></li>
|
||||
<li>Carrega todas as variáveis (auto-fill descrito acima)</li>
|
||||
<li>Gera o PDF</li>
|
||||
<li>Faz upload pro bucket <code>generated-docs</code></li>
|
||||
<li>Insere registros em <code>documents</code> e <code>document_generated</code> (com linkage)</li>
|
||||
<li>Dispara <strong>download</strong> automático no browser</li>
|
||||
<li>Toast "Recibo emitido — PDF baixado e salvo nos documentos do paciente"</li>
|
||||
</ol>
|
||||
<p>O recibo aparece na aba <em>Documentos</em> do prontuário do paciente sob a categoria <strong>Recibo</strong>. Pode ser editado in-place, compartilhado ou assinado eletronicamente normalmente.</p>
|
||||
|
||||
<h3>6. Quick path vs flow manual</h3>
|
||||
<p>São <strong>2 caminhos</strong> pra gerar o mesmo PDF:</p>
|
||||
<ul>
|
||||
<li><strong>Quick path</strong> (este): clica num botão e pronto. Recibo da sessão paga, valor exato do record, forma de pagamento idem.</li>
|
||||
<li><strong>Flow manual</strong>: aba Documentos → Gerar → escolhe template "Recibo de Pagamento" → edita valores manualmente → preview → salva.</li>
|
||||
</ul>
|
||||
<p>Use o quick path no fluxo normal. Use o manual quando precisar emitir recibo de algo que não está vinculado a sessão (consulta avulsa) ou quando precisar ajustar valores.</p>
|
||||
|
||||
<h3>⚠️ Notas pro desenvolvedor</h3>
|
||||
<ul>
|
||||
<li><strong>Service:</strong> <code>emitirReciboParaSessao(eventoId, { patientId?, valor?, formaPagamento? })</code> em <code>DocumentGenerate.service.js</code>. Quick path one-call: busca template, carrega vars, gera, salva, download.</li>
|
||||
<li><strong>Helper extenso:</strong> <code>src/utils/valorExtenso.js</code> — pt-BR até 999 milhões. Atenção: zero retorna "zero reais", inputs inválidos retornam string vazia.</li>
|
||||
<li><strong>Mapeamento:</strong> <code>TEMPLATE_TYPE_TO_DOC_TYPE['recibo_pagamento'] = 'recibo'</code> garante que o doc gerado vai pra categoria certa na sidebar.</li>
|
||||
<li><strong>Template:</strong> migration <code>20260521000008_recibo_uses_terapeuta_registro.sql</code> trocou <code>"Psicólogo(a) - CRP {{terapeuta_crp}}"</code> por <code>{{terapeuta_registro}}</code> no template global. Universal pra qualquer conselho.</li>
|
||||
<li><strong>Botão UI:</strong> <code>AgendaEventoFinanceiroPanel.vue</code> linha ~320, branch <code>v-else-if="record.status === 'paid'"</code>.</li>
|
||||
</ul>$HTML$,
|
||||
'Financeiro',
|
||||
true,
|
||||
'usuario',
|
||||
'/melissa/agenda',
|
||||
6,
|
||||
true,
|
||||
'[{"tipo": "imagem", "url": ""}]'::jsonb
|
||||
)
|
||||
RETURNING id INTO v_doc_id;
|
||||
|
||||
INSERT INTO public.saas_faq_itens (doc_id, pergunta, resposta, ordem, ativo) VALUES
|
||||
(v_doc_id, 'Como emito um recibo pra uma sessão que recebi o pagamento?',
|
||||
$FAQ$Abra a sessão no calendário da agenda → no painel <em>Cobrança</em> dentro do modal, com o pagamento já registrado (status <strong>Pago</strong>), aparece o botão <strong>Emitir recibo</strong>. Clique uma vez. O sistema gera o PDF, salva nos documentos do paciente e dispara o download automaticamente. Toast confirma a operação.$FAQ$, 0, true),
|
||||
|
||||
(v_doc_id, 'Por que o botão "Emitir recibo" não aparece na minha sessão?',
|
||||
$FAQ$O botão só aparece quando o financial_record da sessão tem <strong>status = pago</strong>. Possíveis motivos: (1) você não gerou cobrança ainda — clique em <em>Receber</em> pra registrar o pagamento primeiro; (2) cobrança está pendente — registre o recebimento; (3) sessão é de pacote (status='contrato') — pacotes não emitem recibo por sessão, use o fluxo manual em <em>Documentos → Gerar</em>; (4) cobrança foi cancelada — gere uma nova.$FAQ$, 1, true),
|
||||
|
||||
(v_doc_id, 'O valor por extenso vem certo ("cento e cinquenta reais")?',
|
||||
$FAQ$Sim, com gramática pt-BR correta até 999 milhões. Exemplos: R$ 1,00 → "um real", R$ 150,00 → "cento e cinquenta reais", R$ 1.234,56 → "mil duzentos e trinta e quatro reais e cinquenta e seis centavos", R$ 0,50 → "cinquenta centavos". Pluralização real/reais e centavo/centavos automática.$FAQ$, 2, true),
|
||||
|
||||
(v_doc_id, 'O recibo funciona pra qualquer conselho profissional (CRM, CRFa…)?',
|
||||
$FAQ$<strong>Sim.</strong> O template usa a variável <code>{{terapeuta_registro}}</code> que se adapta ao tipo de registro cadastrado no seu Perfil. Funciona pra CRP (psicologia), CRM (medicina), CRFa (fonoaudiologia), CREFITO (fisioterapia), CRESS (serviço social), CRN (nutrição), e qualquer outro conselho — incluindo "Outro" com nome livre. A formatação genérica fica tipo "CRP 12345/SP", "CRM 67890/RJ", etc.$FAQ$, 3, true),
|
||||
|
||||
(v_doc_id, 'Onde o recibo fica salvo depois de emitido?',
|
||||
$FAQ$Em <strong>2 lugares</strong>: (1) baixado automaticamente no seu computador via download do navegador; (2) salvo na aba <em>Documentos</em> do prontuário do paciente, na categoria <strong>Recibo</strong> da sidebar. Daí você pode reabrir, compartilhar com o paciente, enviar pra assinar, ou editar in-place se precisar ajustar.$FAQ$, 4, true),
|
||||
|
||||
(v_doc_id, 'Posso emitir recibo de algo que não é sessão (consulta avulsa, pacote)?',
|
||||
$FAQ$Sim, mas pelo <strong>fluxo manual</strong>: vá na aba <em>Documentos</em> do paciente → botão <strong>Gerar</strong> → escolha o template <em>"Recibo de Pagamento"</em>. Você preenche os valores na mão (valor, forma de pagamento, descrição) já que não vem de uma sessão específica. O resto (CRP, paciente, clínica) auto-completa igual.$FAQ$, 5, true),
|
||||
|
||||
(v_doc_id, 'Meu CRP/CRM aparece vazio no recibo, o que fazer?',
|
||||
$FAQ$Cadastre seu registro profissional em <strong>Perfil → Registro Profissional</strong>. Selecione o tipo (CRP/CRM/CRFa/…/Outro), número e UF. Salve. Próximos recibos gerados já trazem formatado. Pra atualizar recibos antigos, abra o doc na aba Documentos do paciente e use <em>Editar</em> — o sistema vai puxar o registro atualizado.$FAQ$, 6, true),
|
||||
|
||||
(v_doc_id, 'O CNPJ da clínica aparece formatado no recibo?',
|
||||
$FAQ$Sim, automaticamente. Em <strong>Configurações → Negócio (Tenant)</strong>, cadastre o CPF ou CNPJ no campo unificado. O sistema detecta pela quantidade de dígitos: 11 dígitos formata como CPF (XXX.XXX.XXX-XX), 14 como CNPJ (XX.XXX.XXX/XXXX-XX). O recibo usa a variável <code>{{clinica_cnpj}}</code> que sai formatada.$FAQ$, 7, true),
|
||||
|
||||
(v_doc_id, 'Errei o valor do recibo, posso corrigir sem gerar outro?',
|
||||
$FAQ$Sim. Vá na aba <em>Documentos</em> do paciente → abra o recibo no preview → clique em <strong>Editar</strong>. O dialog abre em modo edição com o template do recibo já carregado e os valores anteriores preenchidos. Ajuste o que precisa → <em>Substituir documento</em>. O PDF é regenerado e substitui o anterior, mantendo o mesmo ID e audit trail.$FAQ$, 8, true),
|
||||
|
||||
(v_doc_id, 'Posso enviar o recibo pro paciente assinar?',
|
||||
$FAQ$Sim. Recibos são documentos como qualquer outro — abra na aba Documentos → preview → botão <strong>Assinar</strong> na sidebar. Gera link público temporário, paciente abre sem login, marca aceite LGPD, assina. Útil pra recibos de valores altos ou contratos de pacote onde você quer registro formal da concordância.$FAQ$, 9, true),
|
||||
|
||||
(v_doc_id, 'Recibo de uma sessão antiga vai com a data de hoje ou a data da sessão?',
|
||||
$FAQ$<strong>As duas</strong>. O recibo traz a <em>data da sessão</em> ("Referente ao atendimento de 15/03/2026") e a <em>data atual de emissão</em> ("São Carlos, 22 de maio de 2026") no rodapé. Importante pra fiscal — a data de emissão indica quando o documento foi formalmente criado, mesmo que a sessão tenha sido meses atrás.$FAQ$, 10, true),
|
||||
|
||||
(v_doc_id, 'Posso reemitir um recibo que já foi emitido pra mesma sessão?',
|
||||
$FAQ$Sim, mas com cuidado. Clicar em <strong>Emitir recibo</strong> de novo gera um <strong>novo PDF</strong> e salva como novo documento na aba — você fica com 2 recibos da mesma sessão. Pra apenas atualizar (sem duplicar), edite o existente em <em>Documentos → preview → Editar</em>. Se duplicar por engano, exclua o antigo (soft-delete preserva por 5 anos no Lixo).$FAQ$, 11, true);
|
||||
|
||||
RAISE NOTICE 'Doc criada: id=%, faq_itens=12', v_doc_id;
|
||||
END;
|
||||
$IMPORT$;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,165 @@
|
||||
-- Importacao da doc Relatorios e exportacao (Fase 5 #13)
|
||||
-- Gerado a partir de development/saas-docs/07-relatorios-export-melissa.json
|
||||
BEGIN;
|
||||
|
||||
DO $IMPORT$
|
||||
DECLARE
|
||||
v_doc_id uuid;
|
||||
BEGIN
|
||||
INSERT INTO public.saas_docs (
|
||||
titulo, conteudo, categoria, exibir_no_faq, tipo_acesso,
|
||||
pagina_path, ordem, ativo, medias
|
||||
) VALUES (
|
||||
'Relatórios de sessões e exportação',
|
||||
$HTML$<h2>Relatórios de sessões e exportação</h2>
|
||||
|
||||
<p>A página <strong>Relatórios</strong> (acessível em <code>/melissa/relatorios</code> ou Prontuários → Relatórios) consolida as sessões num período escolhido com KPIs, gráfico de evolução e tabela detalhada. Você pode <strong>exportar tudo pra PDF, Excel ou CSV</strong> respeitando os filtros aplicados.</p>
|
||||
|
||||
<h3>1. Layout 2-col</h3>
|
||||
<ul>
|
||||
<li><strong>Sidebar esquerda</strong> (~280px): cards de estatísticas clicáveis (atuam como filtros) + seletor de período + filtro por status</li>
|
||||
<li><strong>Main direita</strong>: gráfico de evolução (Chart.js) + DataTable de sessões filtradas com paginação</li>
|
||||
</ul>
|
||||
<p><strong>Mobile</strong> (<1024px): sidebar vira drawer acessado por botão "Filtros" no header.</p>
|
||||
|
||||
<h3>2. Filtros de período</h3>
|
||||
<p>4 opções no seletor:</p>
|
||||
<ul>
|
||||
<li><strong>Esta semana</strong> — domingo a sábado da semana atual</li>
|
||||
<li><strong>Este mês</strong> (default) — primeiro dia ao último dia do mês corrente</li>
|
||||
<li><strong>Últimos 3 meses</strong> — janela rolante de 3 meses até o fim do mês atual</li>
|
||||
<li><strong>Últimos 6 meses</strong> — idem, janela de 6 meses</li>
|
||||
</ul>
|
||||
<p>Ao trocar o período, dispara uma nova query no banco. Os KPIs, gráfico e tabela se atualizam.</p>
|
||||
|
||||
<h3>3. Estatísticas (KPIs)</h3>
|
||||
<p>Sidebar mostra cards com contadores do período:</p>
|
||||
<ul>
|
||||
<li><strong>Total de sessões</strong> — todas independente de status</li>
|
||||
<li><strong>Realizadas</strong> — concluídas com sucesso</li>
|
||||
<li><strong>Faltas</strong> — paciente faltou</li>
|
||||
<li><strong>Cancelamentos</strong> — sessão cancelada</li>
|
||||
<li><strong>Remarcadas</strong> — paciente remarcou</li>
|
||||
</ul>
|
||||
<p>Cada card é <strong>clicável</strong>: filtra a tabela mostrando apenas as sessões daquele status. Clique no mesmo card pra desfazer o filtro.</p>
|
||||
|
||||
<h3>4. Gráfico de evolução</h3>
|
||||
<p>Gráfico de barras/linhas (Chart.js) mostrando a evolução de sessões no período. O agrupamento adapta automaticamente:</p>
|
||||
<ul>
|
||||
<li><strong>Semana / Mês</strong> → agrupa por <strong>dia</strong></li>
|
||||
<li><strong>3 meses / 6 meses</strong> → agrupa por <strong>semana ISO</strong> ou <strong>mês ISO</strong></li>
|
||||
</ul>
|
||||
<p>Cores por status (verde = realizadas, vermelho = faltas, amarelo = canceladas, azul = remarcadas).</p>
|
||||
|
||||
<h3>5. Tabela detalhada</h3>
|
||||
<p>DataTable com colunas: data/hora, paciente, modalidade, status, valor (se aplicável), forma de pagamento. Paginada (15 por página default), ordenável por qualquer coluna. Status com tag colorida.</p>
|
||||
|
||||
<h3>6. Exportação — 3 formatos</h3>
|
||||
<p>3 botões no topo da tabela (ou header da página dependendo do layout):</p>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Botão</th><th>Formato</th><th>Quando usar</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><strong>📄 PDF</strong></td><td>PDF A4</td><td>Apresentar pra contador, anexar a processo, arquivo formal com identidade visual da clínica</td></tr>
|
||||
<tr><td><strong>📊 Excel</strong></td><td>XLSX</td><td>Análise no Excel/Google Sheets, fórmulas, gráficos próprios, manipulação fina</td></tr>
|
||||
<tr><td><strong>📋 CSV</strong></td><td>CSV UTF-8</td><td>Importar em outro sistema, processamento via script, BI externo</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div style="background: rgba(34,197,94,0.06); border: 1px solid rgba(34,197,94,0.25); border-radius: 10px; padding: 12px 14px; margin: 14px 0; font-size: 0.85rem;">
|
||||
<strong>🎯 Os filtros aplicados na tela são respeitados</strong> — se você filtrou por "Realizadas" e exportou pra Excel, só as realizadas vão pro arquivo. Período idem. Quer todos os status? clique no card de filtro pra desfazer antes de exportar.
|
||||
</div>
|
||||
|
||||
<h3>7. Detalhes técnicos por formato</h3>
|
||||
|
||||
<h4>PDF</h4>
|
||||
<ul>
|
||||
<li>Renderizado client-side via HTML → PDF (mesmo pipeline do gerador de documentos)</li>
|
||||
<li>Cabeçalho com KPIs em destaque + tabela A4 abaixo</li>
|
||||
<li>Identidade visual: nome da clínica, logo (se cadastrado), data de geração</li>
|
||||
<li>Tamanho: 1 página por ~30 sessões; relatórios longos paginam automaticamente com cabeçalho/rodapé fixos</li>
|
||||
</ul>
|
||||
|
||||
<h4>Excel (XLSX)</h4>
|
||||
<ul>
|
||||
<li>Gerado com <code>exceljs</code> (import dinâmico — não infla o bundle inicial)</li>
|
||||
<li><strong>Frozen header</strong> — primeira linha fica fixa ao rolar</li>
|
||||
<li><strong>Alternating rows</strong> — zebrado pra leitura</li>
|
||||
<li>Colunas formatadas: data como data, valor como currency BRL</li>
|
||||
<li>Branded — cabeçalho com cor da clínica</li>
|
||||
</ul>
|
||||
|
||||
<h4>CSV</h4>
|
||||
<ul>
|
||||
<li>Vanilla JS — sem dependência externa, gerado instantaneamente</li>
|
||||
<li><strong>BOM UTF-8</strong> no início — força Excel a abrir com acentos corretos</li>
|
||||
<li><strong>Separador <code>;</code></strong> (padrão pt-BR — Excel BR espera ; em vez de ,)</li>
|
||||
<li>Aspas em campos com vírgula ou quebra de linha</li>
|
||||
</ul>
|
||||
|
||||
<h3>8. Nome do arquivo gerado</h3>
|
||||
<p>Padrão <code>relatorio_sessoes_AAAAMMDD_HHmm.{pdf|xlsx|csv}</code> com timestamp da hora de geração. Garante que múltiplas exportações no mesmo dia não sobrescrevem.</p>
|
||||
|
||||
<h3>⚠️ Notas pro desenvolvedor</h3>
|
||||
<ul>
|
||||
<li><strong>Service:</strong> <code>src/services/reportExport.service.js</code> com 3 funções: <code>exportSessionsToPDF</code>, <code>exportSessionsToXLSX</code>, <code>exportSessionsToCSV</code>. Todas aceitam <code>{ sessions, period, statusFilter, tenant }</code>.</li>
|
||||
<li><strong>PDF:</strong> usa <code>pdf.service.htmlToPdfBlob</code> (mesmo do gerador de documentos)</li>
|
||||
<li><strong>XLSX:</strong> <code>const { default: ExcelJS } = await import('exceljs')</code> — code splitting</li>
|
||||
<li><strong>CSV:</strong> vanilla JS com BOM + escape de campos</li>
|
||||
<li><strong>Pages:</strong> <code>RelatoriosPage.vue</code> (rota classic/Rail) e <code>MelissaRelatorios.vue</code> (rota Melissa) compartilham o mesmo service</li>
|
||||
<li><strong>Pendência:</strong> exportação agendada (envio automático por email no dia 1 de cada mês) — depende do Módulo 6 notifications. Hoje só on-demand.</li>
|
||||
</ul>$HTML$,
|
||||
'Relatórios',
|
||||
true,
|
||||
'usuario',
|
||||
'/melissa/relatorios',
|
||||
7,
|
||||
true,
|
||||
'[{"tipo": "imagem", "url": ""}]'::jsonb
|
||||
)
|
||||
RETURNING id INTO v_doc_id;
|
||||
|
||||
INSERT INTO public.saas_faq_itens (doc_id, pergunta, resposta, ordem, ativo) VALUES
|
||||
(v_doc_id, 'Como vejo um resumo das minhas sessões num período?',
|
||||
$FAQ$Abra a página <strong>Relatórios</strong> (menu Prontuários → Relatórios, ou diretamente em <code>/melissa/relatorios</code>). Você escolhe o período na sidebar esquerda (esta semana, este mês, últimos 3 ou 6 meses) e o sistema mostra KPIs em cards, gráfico de evolução e tabela detalhada de cada sessão.$FAQ$, 0, true),
|
||||
|
||||
(v_doc_id, 'Quais períodos posso filtrar?',
|
||||
$FAQ$4 opções fixas no seletor: <strong>Esta semana</strong> (domingo a sábado da semana corrente), <strong>Este mês</strong> (default — dia 1 ao último dia do mês atual), <strong>Últimos 3 meses</strong> e <strong>Últimos 6 meses</strong> (janelas rolantes terminando no fim do mês atual). Não há custom date range na UI ainda — pra filtrar uma data específica, exporte pra Excel ou CSV e filtre lá.$FAQ$, 1, true),
|
||||
|
||||
(v_doc_id, 'Como exporto o relatório pra PDF?',
|
||||
$FAQ$No topo da página de Relatórios, clique no botão <strong>PDF</strong> (ícone vermelho de arquivo). O sistema renderiza o relatório com KPIs em destaque + tabela A4 e dispara o download. Útil pra apresentar pra contador, anexar a processos ou arquivar formalmente. O PDF traz a identidade visual da clínica (nome, logo se cadastrado, data de geração).$FAQ$, 2, true),
|
||||
|
||||
(v_doc_id, 'Como exporto pra Excel?',
|
||||
$FAQ$Botão <strong>Excel</strong> (ícone verde) no topo da página. Gera um arquivo <code>.xlsx</code> com cabeçalho fixo (frozen header), linhas zebradas pra leitura, colunas formatadas (datas como data, valores como moeda BRL) e cabeçalho com cor da clínica. Pronto pra análise no Excel, Google Sheets ou LibreOffice.$FAQ$, 3, true),
|
||||
|
||||
(v_doc_id, 'Quando devo usar CSV em vez de Excel?',
|
||||
$FAQ$Use <strong>CSV</strong> quando precisar importar os dados em outro sistema (ERP, BI, banco de dados), fazer processamento via script, ou compartilhar com alguém que não tenha Excel. O arquivo é mais leve e universal. Use <strong>Excel</strong> quando o destino final for análise humana — formatação de moeda, gráficos próprios, fórmulas. Os 2 trazem os mesmos dados.$FAQ$, 4, true),
|
||||
|
||||
(v_doc_id, 'Os filtros aplicados na tela também valem pra exportação?',
|
||||
$FAQ$<strong>Sim, sempre.</strong> Se você filtrou por "Realizadas" clicando no card de KPI, só as sessões realizadas vão pro arquivo exportado. Período idem. Quer exportar todos os status? clique no card de filtro pra desfazer (ou clique em outro KPI e depois nele de novo) antes de exportar. Na dúvida, o título do PDF/Excel sempre traz os filtros aplicados na primeira linha.$FAQ$, 5, true),
|
||||
|
||||
(v_doc_id, 'O Excel exportado tem fórmulas ou só dados?',
|
||||
$FAQ$Só dados. As colunas vêm formatadas (data como data, valor como moeda BRL) mas sem fórmulas pré-instaladas — você adiciona o que precisar depois (somas, médias, gráficos). Decisão de design: pra evitar conflito com diferentes locales/versões do Excel, exportamos puro e você customiza.$FAQ$, 6, true),
|
||||
|
||||
(v_doc_id, 'Por que o gráfico às vezes mostra dias e às vezes semanas/meses?',
|
||||
$FAQ$Agrupamento automático conforme o período pra evitar gráfico ilegível: <strong>Semana / Mês</strong> → 7-31 colunas por dia (legível). <strong>3 meses</strong> → ~13 colunas por semana ISO. <strong>6 meses</strong> → ~26 colunas ou ~6 colunas por mês ISO. Se forçássemos 180 colunas em "6 meses", ficaria ilegível.$FAQ$, 7, true),
|
||||
|
||||
(v_doc_id, 'Posso filtrar o relatório por um paciente específico?',
|
||||
$FAQ$Hoje não diretamente na página de Relatórios. Pra ver sessões de um paciente específico, vá no <strong>prontuário do paciente</strong> (aba Sessões) — lá tem timeline completa com filtros próprios. Ou exporte o relatório geral pra Excel/CSV e filtre por nome do paciente no Excel.$FAQ$, 8, true),
|
||||
|
||||
(v_doc_id, 'Consigo ver o relatório de outro terapeuta da clínica?',
|
||||
$FAQ$Depende da sua permissão no tenant. Por default, cada terapeuta vê só as próprias sessões. Owners/admins do tenant podem ter acesso aos relatórios consolidados de todos os profissionais — verifique em <strong>Configurações → Equipe</strong> qual é seu papel. Pra solicitar acesso ampliado, fale com o owner do tenant.$FAQ$, 9, true),
|
||||
|
||||
(v_doc_id, 'Como ficam os nomes dos arquivos exportados?',
|
||||
$FAQ$Padrão <code>relatorio_sessoes_AAAAMMDD_HHmm.{pdf|xlsx|csv}</code>. Exemplo: <code>relatorio_sessoes_20260522_1430.xlsx</code>. Timestamp garante que múltiplas exportações no mesmo dia não sobrescrevem o anterior — fica fácil organizar versões.$FAQ$, 10, true),
|
||||
|
||||
(v_doc_id, 'Posso agendar exportações automáticas (envio por email mensal)?',
|
||||
$FAQ$Ainda não. Hoje a exportação é <strong>on-demand</strong> — você precisa abrir a página e clicar no botão. Exportação agendada (ex: PDF mensal enviado por email no dia 1) está no roadmap pós-MVP, depende do Módulo 6 (notifications factory channel) que ainda não foi implementado. Por enquanto, agende um lembrete pra você abrir a página todo dia 1.$FAQ$, 11, true);
|
||||
|
||||
RAISE NOTICE 'Doc criada: id=%, faq_itens=12', v_doc_id;
|
||||
END;
|
||||
$IMPORT$;
|
||||
|
||||
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,177 @@
|
||||
# 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`.
|
||||
- [x] **Compliance CFP (#5/#6/#7/#8/#9 todos done · 2026-05-21)** —
|
||||
- #5 (registro profissional): migration `20260521000003_profiles_professional_registration.sql` — adiciona `professional_registration_type` (CHECK 8 conselhos) + `_number` + `_uf`.
|
||||
- #6 (consent forms editáveis): migration `20260521000005_document_templates_consent_types.sql` estende CHECK com `termo_lgpd` + `autorizacao_gravacao`. `seed_060_consent_forms_extra.sql` insere 2 templates novos (LGPD + Gravação) + UPDATE no `tcle_online` adicionando cláusula LGPD. Biblioteca completa: TCLE base, tcle_online (telehealth), autorizacao_menor, termo_sigilo, termo_lgpd, autorizacao_gravacao + UI já existente (`DocumentTemplatesPage` + `DocumentTemplateEditor`).
|
||||
- #7 (assinatura eletrônica no portal): 2 migrations RPC — `20260521000006` cria `sign_document_by_signature_id` + `sign_document_by_token` + `get_signable_document_by_token` (IP/UA capturados server-side via `inet_client_addr()` + `current_setting('request.headers')`); `20260521000007` cria `list_my_signatures` (cruzamento auth.uid() por 3 caminhos). `DocumentSignatures.service` estendido. `useDocumentSignatures` composable novo. `PortalDocumentos.vue` lista pendências do paciente logado. `SharedDocumentPage.vue` estendida com painel azul de assinatura (aviso LGPD + checkbox aceite + Assinar/Recusar). `DocumentSignatureDialog` (terapeuta-side, já existia) ganha checkbox "Gerar link público" + select de validade + bloco com URL gerado/copy.
|
||||
- #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`.
|
||||
- [x] **Recibo profissional PDF (#14 · 2026-05-21)** — `valorExtenso.js` helper pt-BR. `DocumentGenerate.service` puxa registro profissional do profile (auto-formato `CRP 12345/SP`), formata `cpf_cnpj` do tenant, computa `valor`+`valor_extenso`, mapeia `templateTipo` → `tipo_documento` (recibo_pagamento → 'recibo'). Migration `20260521000008` substitui `{{terapeuta_crp}}` por `{{terapeuta_registro}}` no template — universal pra qualquer conselho. `emitirReciboParaSessao(eventoId, opts)` é quick path one-call. Botão "Emitir recibo" no `AgendaEventoFinanceiroPanel` quando `record.status === 'paid'`. #15 NFS-e ainda em aberto.
|
||||
- [x] **§1.3 UX block 3/4 (#10 + #11 + #13 · 2026-05-21)** —
|
||||
- #10 Busca global: `GlobalSearch.vue` (RPC `search_global`) já estava completo no AppTopbar/Rail. **MelissaBusca promovida** de client-side preview pra RPC com debounce 200ms + searchSeq. 3 grupos novos (rpc-appointments, rpc-documents, rpc-intakes). `@paciente` no MelissaLayout corrigido pra navegar pro paciente clicado (era bug — ignorava payload).
|
||||
- #11 Recently viewed: `composables/useRecentPatients.js` (localStorage por user_id, max 5, dedup, eventos sync entre instâncias). `registerPatientVisit` chamado em `MelissaPaciente.loadAll` + `PatientProntuario.loadDetail`. Grupo "Acessados recentemente" no GlobalSearch + MelissaBusca quando query vazia.
|
||||
- #13 Relatórios export: `services/reportExport.service.js` com 3 funções (PDF via pdf.service, Excel via exceljs com import dinâmico, CSV vanilla). 3 botões no header de `RelatoriosPage.vue` e `MelissaRelatorios.vue`.
|
||||
- [ ] **#12 Papel timbrado (BLOQUEADO)** — código no UniaoApp (projeto externo). Quando user importar o código, plugar como variável `cabecalho_html`/`rodape_html` global em `document_templates` ou criar setting `tenants.letterhead_html`.
|
||||
- [ ] NFS-e emissão (#15) — Esforço L, decisão de provider pendente (Focus NF-e vs prefeitura direta).
|
||||
- [ ] E2E Playwright crítico (#16)
|
||||
- [ ] Sentry (#18)
|
||||
|
||||
### Fase 4 — Agenda residual
|
||||
|
||||
- [x] **Popover snapshot stale** (commit `f83315b` durante C11) — watch em `MelissaLayout` cobre virtual→materializada.
|
||||
- [x] **Reverse transition confirm dialogs** (commit `5684297` durante C11) — `ctx.reverseArtifacts` + dialog.
|
||||
- [x] **Decomposição agenda (Fases A+B1+B2 · 2026-05-21)** — `useMelissaAgenda.js` saiu de 3033L → 2042L (-991L, ~33%). 3 utils + 1 service novo (`agendaBilling.service`).
|
||||
- Fase A: `features/agenda/utils/{eventoTipo,dbFields,timeHelpers,colors}.js`
|
||||
- Fase B1 (commit `e7e3d1b`): service ganha `computeSeriePrice`, `generateOccurrenceDates`, `loadStatusChangeContext`, `needsStatusConfirmDialog`.
|
||||
- Fase B2 (commit `049dd91`): service ganha `applyStatusDecisions`, `createPackageContract`, `materializeAndChargePerSession`.
|
||||
- [x] **Replicação Rail + Clínica (Fases C+D · 2026-05-21)** —
|
||||
- Composable novo `useAgendaStatusChange` (Tipo A wrapper) reusável em qualquer page.
|
||||
- Fase C (commit `034c2c0`): `AgendaTerapeutaPage.onUpdateSeriesEvent` refatorado + `AgendaStatusChangeConfirmDialog` plugado. Antes era `update(id, {status})` cru; agora cobre multa + pacote saldo + reverse.
|
||||
- Fase D (commit `6807b44`): `AgendaClinicaPage` espelha Fase C com adaptações (`updateClinic`+`createClinic` recebem `tenantId` arg).
|
||||
- [x] **C12 antecipar UX iter** (commits `9c518a2` + `b7f3c23`) — "Trocar método" pattern (UPDATE em vez de cancel cycle) + filtro cancelled no dialog Lançamentos.
|
||||
- [ ] **Indicadores visuais 3 canais** (barra esquerda verde / badge $ amber / neutro) — replicar no Rail/Clínica. Custom event classNames do FullCalendar, requer `_paymentStateMap` bulk-load igual ao Melissa.
|
||||
- [ ] **Popover Rail antecipar/revogar/trocar método** — Rail não tem popover separado (usa AgendaEventDialog direto), precisa refactor maior pra acomodar.
|
||||
- [ ] **Doc de ajuda completa** — user enviará prompt específico.
|
||||
|
||||
---
|
||||
|
||||
## 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 one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { ref, computed, watch, onBeforeUnmount } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useAjuda } from '@/composables/useAjuda';
|
||||
|
||||
@@ -73,6 +73,24 @@ function fechar() {
|
||||
faqAbertos.value = {};
|
||||
closeDrawer();
|
||||
}
|
||||
|
||||
// ── Fechar ao clicar fora ─────────────────────────────────────
|
||||
// Listener so existe enquanto o drawer esta aberto. Clique nos botoes
|
||||
// que abrem/fecham o drawer (marcados com data-ajuda-toggle) sao
|
||||
// ignorados — senao fecha aqui e o @click reabre.
|
||||
function onDocMouseDown(e) {
|
||||
if (!drawerOpen.value) return;
|
||||
const t = e.target;
|
||||
if (!(t instanceof Element)) return;
|
||||
if (t.closest('.ajuda-panel')) return; // dentro do drawer
|
||||
if (t.closest('[data-ajuda-toggle]')) return; // botao trigger
|
||||
closeDrawer();
|
||||
}
|
||||
watch(drawerOpen, (open) => {
|
||||
if (open) document.addEventListener('mousedown', onDocMouseDown, true);
|
||||
else document.removeEventListener('mousedown', onDocMouseDown, true);
|
||||
});
|
||||
onBeforeUnmount(() => document.removeEventListener('mousedown', onDocMouseDown, true));
|
||||
// ── Highlight de elemento na página ──────────────────────────
|
||||
async function handleDocClick(e) {
|
||||
const anchor = e.target.closest('a[data-highlight]');
|
||||
|
||||
@@ -17,31 +17,61 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue';
|
||||
|
||||
const isOnline = ref(true); // começa como true; detecta em onMounted
|
||||
// ── Estado ────────────────────────────────────────────────────
|
||||
// Começa otimista (true) — só marca offline com confirmação dupla.
|
||||
const isOnline = ref(true);
|
||||
const wasOffline = ref(false);
|
||||
const showReconnected = ref(false);
|
||||
|
||||
let pollTimer = null;
|
||||
let reconnectedTimer = null;
|
||||
let consecutiveFailures = 0;
|
||||
|
||||
// ── Detecção real: tenta buscar um recurso minúsculo ──────────
|
||||
// Em DEV, ignora completamente o polling: Vite HMR + dev server podem
|
||||
// disparar falhas pontuais que geram falso positivo constante. Em DEV,
|
||||
// só confia em navigator.onLine + eventos nativos (mais conservador).
|
||||
const IS_DEV = import.meta.env?.DEV === true;
|
||||
|
||||
// Tolerância: precisa N falhas seguidas pra considerar offline. Evita
|
||||
// falso positivo de slow request / HMR rebuild / network blip.
|
||||
const FAILURE_THRESHOLD = 2;
|
||||
const POLL_INTERVAL = IS_DEV ? 60_000 : 30_000;
|
||||
const FETCH_TIMEOUT = 8_000;
|
||||
|
||||
// ── Detecção: navigator.onLine primeiro, fetch como confirmação ──
|
||||
//
|
||||
// navigator.onLine é a fonte autoritativa do browser. Se for true,
|
||||
// quase certo que tem rede física. Se for false, com certeza offline.
|
||||
// O fetch só serve pra detectar "rede funciona mas servidor parado".
|
||||
async function checkConnectivity() {
|
||||
// 1) Browser offline = confia direto, sem fetch
|
||||
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
|
||||
consecutiveFailures = FAILURE_THRESHOLD;
|
||||
setOffline();
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) Browser online — confirma com HEAD no favicon (rápido, cacheável)
|
||||
try {
|
||||
// favicon do próprio app (cache busted) — não depende de rede externa
|
||||
await fetch('/favicon.ico?_t=' + Date.now(), {
|
||||
method: 'HEAD',
|
||||
cache: 'no-store',
|
||||
signal: AbortSignal.timeout(4000)
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT)
|
||||
});
|
||||
consecutiveFailures = 0;
|
||||
setOnline();
|
||||
} catch {
|
||||
setOffline();
|
||||
consecutiveFailures++;
|
||||
// Só marca offline após N falhas consecutivas — evita falso positivo
|
||||
// de slow request, HMR rebuild, transient blip.
|
||||
if (consecutiveFailures >= FAILURE_THRESHOLD) {
|
||||
setOffline();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setOnline() {
|
||||
if (!isOnline.value && wasOffline.value) {
|
||||
// acabou de reconectar
|
||||
showReconnected.value = true;
|
||||
if (reconnectedTimer) clearTimeout(reconnectedTimer);
|
||||
reconnectedTimer = setTimeout(() => {
|
||||
@@ -59,19 +89,25 @@ function setOffline() {
|
||||
}
|
||||
|
||||
// ── Eventos nativos do browser ────────────────────────────────
|
||||
// navigator.onLine + offline/online events são SUPER confiáveis pra
|
||||
// estado real (sem rede física, wifi caiu, etc). Outros falsos
|
||||
// positivos vinham só do fetch agressivo.
|
||||
function onBrowserOffline() {
|
||||
consecutiveFailures = FAILURE_THRESHOLD;
|
||||
setOffline();
|
||||
}
|
||||
function onBrowserOnline() {
|
||||
consecutiveFailures = 0;
|
||||
checkConnectivity();
|
||||
} // confirma antes de marcar online
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('offline', onBrowserOffline);
|
||||
window.addEventListener('online', onBrowserOnline);
|
||||
|
||||
// Polling a cada 10 s — captura quedas que não disparam evento
|
||||
pollTimer = setInterval(checkConnectivity, 10_000);
|
||||
// Polling defensivo — captura quedas que não disparam evento
|
||||
// (raras, ex: DNS travado em wifi público).
|
||||
pollTimer = setInterval(checkConnectivity, POLL_INTERVAL);
|
||||
|
||||
// Verifica estado atual ao montar (útil se já começou offline)
|
||||
checkConnectivity();
|
||||
|
||||
@@ -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)
|
||||
* ---------------------------- */
|
||||
@@ -219,8 +197,17 @@ function generateUser() {
|
||||
});
|
||||
}
|
||||
|
||||
function patientsListRoute() {
|
||||
// Rota de destino do "Salvar e ver paciente". Em melissa, prefere a
|
||||
// view individual do paciente recém-criado (id vem de data.id no
|
||||
// emit('created')); fallback pra lista.
|
||||
function patientViewRoute(patientId) {
|
||||
const p = String(route.path || '');
|
||||
if (p.startsWith('/melissa') && patientId) {
|
||||
return { path: '/melissa/paciente', query: { id: String(patientId) } };
|
||||
}
|
||||
if (p.startsWith('/melissa')) {
|
||||
return '/melissa/pacientes';
|
||||
}
|
||||
return p.startsWith('/therapist') ? '/therapist/patients' : '/admin/pacientes';
|
||||
}
|
||||
|
||||
@@ -240,29 +227,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',
|
||||
@@ -273,7 +261,10 @@ async function submit(mode = 'only') {
|
||||
|
||||
emit('created', data);
|
||||
if (props.closeOnCreated) close();
|
||||
if (mode === 'view') await router.push(patientsListRoute());
|
||||
if (mode === 'view') {
|
||||
const pid = data?.id || null;
|
||||
await router.push(patientViewRoute(pid));
|
||||
}
|
||||
} catch (err) {
|
||||
const msg = err?.message || err?.details || 'Não foi possível criar o paciente.';
|
||||
errorMsg.value = msg;
|
||||
@@ -355,10 +346,10 @@ async function submit(mode = 'only') {
|
||||
<!-- Na rota de pacientes OU em fluxo (hideViewListButton): só "Salvar" / "Salvar e fechar" -->
|
||||
<Button v-if="isOnPatientsPage" label="Salvar" :loading="saving" :disabled="saving" @click="submit('only')" />
|
||||
<Button v-else-if="hideViewListButton" label="Salvar e fechar" :loading="saving" :disabled="saving" @click="submit('only')" />
|
||||
<!-- Standalone fora da lista: "Salvar e fechar" + "Salvar e ver pacientes" -->
|
||||
<!-- Standalone fora da lista: "Salvar e fechar" + "Salvar e ver paciente" -->
|
||||
<template v-else>
|
||||
<Button label="Salvar e fechar" severity="secondary" outlined :loading="saving" :disabled="saving" @click="submit('only')" />
|
||||
<Button label="Salvar e ver pacientes" :loading="saving" :disabled="saving" @click="submit('view')" />
|
||||
<Button label="Salvar e ver paciente" :loading="saving" :disabled="saving" @click="submit('view')" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -36,6 +36,7 @@ import { useConfirm } from 'primevue/useconfirm';
|
||||
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useAgendaFinanceiro } from '@/composables/useAgendaFinanceiro';
|
||||
import { emitirReciboParaSessao } from '@/services/DocumentGenerate.service';
|
||||
|
||||
// ── props / emits ─────────────────────────────────────────────────────────────
|
||||
const props = defineProps({
|
||||
@@ -56,6 +57,7 @@ const { gerarCobrancaManual, loading: finLoading, error: finError } = useAgendaF
|
||||
const record = ref(null); // financial_record vinculado
|
||||
const fetching = ref(false);
|
||||
const generating = ref(false);
|
||||
const emittingRecibo = ref(false);
|
||||
|
||||
// ── opções de método de pagamento ─────────────────────────────────────────────
|
||||
const PAYMENT_METHODS = [
|
||||
@@ -100,6 +102,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;
|
||||
@@ -210,6 +226,27 @@ function requestCancel() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Emitir recibo PDF da sessão ─────────────────────────────────────────────
|
||||
// Gera, salva (Storage + documents/document_generated) e baixa um recibo
|
||||
// pré-preenchido com paciente/sessão/valor/forma de pagamento + registro
|
||||
// profissional do terapeuta (CRP/CRM/CRFa etc — auto-formatado).
|
||||
async function onEmitirRecibo() {
|
||||
if (emittingRecibo.value) return;
|
||||
emittingRecibo.value = true;
|
||||
try {
|
||||
await emitirReciboParaSessao(props.evento.id, {
|
||||
patientId: props.evento.patient_id || props.evento.paciente_id,
|
||||
valor: record.value?.final_amount ?? record.value?.amount ?? props.evento.price,
|
||||
formaPagamento: paymentLabel(record.value?.payment_method)
|
||||
});
|
||||
toast.add({ severity: 'success', summary: 'Recibo emitido', detail: 'PDF baixado e salvo nos documentos do paciente.', life: 3000 });
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao emitir recibo', detail: e?.message || 'Tente novamente.', life: 4500 });
|
||||
} finally {
|
||||
emittingRecibo.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -235,10 +272,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 ────────────────────────────────── -->
|
||||
@@ -275,6 +315,20 @@ function requestCancel() {
|
||||
<Button label="Receber" icon="pi pi-check" size="small" class="rounded-full flex-1" @click="openPayDialog" />
|
||||
<Button icon="pi pi-times" size="small" severity="danger" outlined class="rounded-full h-7 w-7" v-tooltip.top="'Cancelar cobrança'" @click="requestCancel" />
|
||||
</div>
|
||||
|
||||
<!-- Ação: pago — emitir recibo PDF -->
|
||||
<div v-else-if="record.status === 'paid'" class="flex gap-1.5 mt-3">
|
||||
<Button
|
||||
label="Emitir recibo"
|
||||
icon="pi pi-file-pdf"
|
||||
size="small"
|
||||
outlined
|
||||
class="rounded-full flex-1"
|
||||
:loading="emittingRecibo"
|
||||
v-tooltip.top="'Gera PDF e salva nos documentos do paciente'"
|
||||
@click="onEmitirRecibo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -15,9 +15,11 @@ import InputText from 'primevue/inputtext';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { searchPages } from './pagesIndex';
|
||||
import { useRecentPatients } from '@/composables/useRecentPatients';
|
||||
|
||||
const router = useRouter();
|
||||
const tenantStore = useTenantStore();
|
||||
const { items: recentPatients, hasItems: hasRecentPatients } = useRecentPatients();
|
||||
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// State
|
||||
@@ -67,9 +69,14 @@ const filteredPages = computed(() => {
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Flat list pra navegação por teclado
|
||||
// ────────────────────────────────────────────────────────────
|
||||
// Recently-viewed só aparece quando a query está vazia — não polui resultados de busca.
|
||||
const showRecent = computed(() => !query.value.trim() && hasRecentPatients.value);
|
||||
const recentItems = computed(() => showRecent.value ? (recentPatients.value || []).slice(0, 5) : []);
|
||||
|
||||
const flatList = computed(() => {
|
||||
const out = [];
|
||||
filteredActions.value.forEach((a, i) => out.push({ group: 'actions', item: a, idx: i }));
|
||||
recentItems.value.forEach((p, i) => out.push({ group: 'recent', item: p, idx: i }));
|
||||
results.value.patients.forEach((p, i) => out.push({ group: 'patients', item: p, idx: i }));
|
||||
results.value.intakes.forEach((r, i) => out.push({ group: 'intakes', item: r, idx: i }));
|
||||
results.value.appointments.forEach((a, i) => out.push({ group: 'appointments', item: a, idx: i }));
|
||||
@@ -195,6 +202,16 @@ function onInputKeydown(e) {
|
||||
}
|
||||
|
||||
async function goTo(entry) {
|
||||
// Recent patients: usa id pra navegar pro prontuário do paciente
|
||||
if (entry?.group === 'recent' && entry?.item?.id) {
|
||||
showPanel.value = false;
|
||||
query.value = '';
|
||||
resetResults();
|
||||
activeIndex.value = -1;
|
||||
await router.push({ path: '/therapist/patients/' + entry.item.id });
|
||||
return;
|
||||
}
|
||||
|
||||
const target = entry?.item?.to || entry?.item?.deeplink || entry?.item?.path;
|
||||
if (!target) return;
|
||||
showPanel.value = false;
|
||||
@@ -266,6 +283,27 @@ const kbdModifier = kbdIsMac ? '⌘' : 'Ctrl';
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Acessados recentemente (só quando query vazia) -->
|
||||
<div v-if="showRecent" class="gs-group">
|
||||
<div class="gs-group__title">Acessados recentemente</div>
|
||||
<button
|
||||
v-for="(p, i) in recentItems"
|
||||
:key="'rp-' + p.id"
|
||||
type="button"
|
||||
class="gs-item"
|
||||
:class="{ 'is-active': findFlatIndex('recent', i) === activeIndex }"
|
||||
@mouseenter="activeIndex = findFlatIndex('recent', i)"
|
||||
@click="goTo({ group: 'recent', item: p, idx: i })"
|
||||
>
|
||||
<span class="gs-item__icon"><i class="pi pi-history" /></span>
|
||||
<span class="gs-item__main">
|
||||
<span class="gs-item__label">{{ p.nome }}</span>
|
||||
<span class="gs-item__sub">{{ p.extras?.telefone || p.extras?.email || 'Abrir prontuário' }}</span>
|
||||
</span>
|
||||
<i class="gs-item__go pi pi-arrow-right" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Ações -->
|
||||
<div v-if="filteredActions.length" class="gs-group">
|
||||
<div class="gs-group__title">{{ query.trim() ? 'Ações' : 'Atalhos' }}</div>
|
||||
|
||||
@@ -34,7 +34,7 @@ export const PAGES = [
|
||||
{ id: 'p_t_medicos', label: 'Médicos referenciadores', icon: 'pi pi-user-edit', sublabel: 'Médicos que encaminham pacientes', path: '/therapist/patients/medicos', roles: ['therapist'], keywords: kw('medicos','encaminhadores','referenciadores','indicacao') },
|
||||
{ id: 'p_t_link_externo', label: 'Link de cadastro externo', icon: 'pi pi-link', sublabel: 'Link público pra pacientes', path: '/therapist/patients/link-externo', roles: ['therapist'], keywords: kw('link','externo','publico','cadastro paciente','convite') },
|
||||
{ id: 'p_t_cad_recebidos', label: 'Cadastros recebidos', icon: 'pi pi-inbox', sublabel: 'Pacientes aguardando aceite', path: '/therapist/patients/cadastro/recebidos', roles: ['therapist'], keywords: kw('recebidos','pendentes','aceitar','intake','novos') },
|
||||
{ id: 'p_t_doc_templates', label: 'Templates de documentos', icon: 'pi pi-file-edit', sublabel: 'Modelos reutilizáveis', path: '/therapist/documents/templates', roles: ['therapist'], keywords: kw('templates','modelos','contratos','documentos') },
|
||||
{ id: 'p_cfg_doc_templates', label: 'Modelos de documentos', icon: 'pi pi-file-edit', sublabel: 'Configurações → Documentos', path: '/configuracoes/documentos/templates', roles: ['therapist','admin'], keywords: kw('templates','modelos','contratos','documentos','recibo','atestado','laudo','tcle','lgpd','consent') },
|
||||
{ id: 'p_t_online_sched', label: 'Agendamento online', icon: 'pi pi-globe', sublabel: 'Página pública de agendamento', path: '/therapist/online-scheduling', roles: ['therapist'], keywords: kw('online','publico','agendar','landing','pagina','site') },
|
||||
{ id: 'p_t_ag_recebidos', label: 'Agendamentos recebidos', icon: 'pi pi-calendar-plus',sublabel: 'Solicitações da agenda pública', path: '/therapist/agendamentos-recebidos', roles: ['therapist'], keywords: kw('solicitacoes','recebidos','publico','pedidos') },
|
||||
{ id: 'p_t_fin_lanc', label: 'Lançamentos financeiros', icon: 'pi pi-list', sublabel: 'Entradas e saídas', path: '/therapist/financeiro/lancamentos', roles: ['therapist'], keywords: kw('lancamentos','entradas','saidas','fluxo de caixa','receitas','despesas') },
|
||||
|
||||
@@ -117,14 +117,14 @@ function buildConfig() {
|
||||
];
|
||||
|
||||
// Toolbar completa para o corpo do e-mail
|
||||
// Botões hr (linha horizontal), eraser (apagar formatação) e source (HTML)
|
||||
// foram removidos — não funcionavam de forma esperada.
|
||||
const bodyButtons = [
|
||||
'bold', 'italic', 'underline', 'strikethrough', '|',
|
||||
'ul', 'ol', '|',
|
||||
'font', 'fontsize', 'brush', 'paragraph', '|',
|
||||
'align', '|',
|
||||
'link', 'table', '|',
|
||||
'hr', 'eraser', '|',
|
||||
'source'
|
||||
'link', 'table'
|
||||
];
|
||||
|
||||
return {
|
||||
@@ -194,7 +194,24 @@ watch(
|
||||
|
||||
// ── API exposta ───────────────────────────────────────────────
|
||||
defineExpose({
|
||||
insertHTML: (html) => jodit?.selection.insertHTML(html)
|
||||
insertHTML: (html) => jodit?.selection.insertHTML(html),
|
||||
// Salva markers da seleção atual antes do foco sair do editor
|
||||
// (ex: usuário abre drawer e perde o cursor). Retorna o array de
|
||||
// markers que pode ser passado pra restoreSelection depois.
|
||||
saveSelection: () => {
|
||||
if (!jodit) return null;
|
||||
try { return jodit.selection.save(); }
|
||||
catch { return null; }
|
||||
},
|
||||
// Restaura selection a partir dos markers salvos. Re-foca o editor.
|
||||
restoreSelection: (markers) => {
|
||||
if (!jodit) return;
|
||||
try {
|
||||
jodit.focus();
|
||||
if (markers) jodit.selection.restore(markers);
|
||||
} catch { /* silencioso */ }
|
||||
},
|
||||
focus: () => jodit?.focus()
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -65,11 +65,22 @@ const router = useRouter();
|
||||
|
||||
const isOnPatientsPage = computed(() => {
|
||||
const p = String(route.path || '');
|
||||
return p.includes('/patients') || p.includes('/pacientes');
|
||||
// /melissa/paciente (singular — prontuário) é página de paciente.
|
||||
// /melissa/pacientes (plural — lista) também.
|
||||
return p.includes('/patients') || p.includes('/pacientes') || p.startsWith('/melissa/paciente');
|
||||
});
|
||||
|
||||
function patientsListRoute() {
|
||||
// Rota de destino quando o usuário pede "Salvar e ver paciente":
|
||||
// — no Melissa, abre o prontuário do paciente (singular, via query id)
|
||||
// — no Therapist/Admin, volta pra lista (não há rota dedicada de view).
|
||||
function patientViewRoute(patientId) {
|
||||
const p = String(route.path || '');
|
||||
if (p.startsWith('/melissa') && patientId) {
|
||||
return { path: '/melissa/paciente', query: { id: String(patientId) } };
|
||||
}
|
||||
if (p.startsWith('/melissa')) {
|
||||
return '/melissa/pacientes';
|
||||
}
|
||||
return p.startsWith('/therapist') ? '/therapist/patients' : '/admin/pacientes';
|
||||
}
|
||||
|
||||
@@ -82,7 +93,9 @@ async function onCreated(data) {
|
||||
isOpen.value = false;
|
||||
emit('created', data);
|
||||
if (pendingMode.value === 'view') {
|
||||
await router.push(patientsListRoute());
|
||||
// data.id vem do PatientsCadastroPage (criação ou edição)
|
||||
const pid = data?.id || props.patientId || null;
|
||||
await router.push(patientViewRoute(pid));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -197,10 +210,10 @@ async function onCreated(data) {
|
||||
<!-- Na rota de pacientes OU em fluxo (hideViewListButton): só um botao -->
|
||||
<Button v-if="isOnPatientsPage" label="Salvar" :loading="!!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('only')" />
|
||||
<Button v-else-if="hideViewListButton" label="Salvar e fechar" :loading="!!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('only')" />
|
||||
<!-- Standalone fora da lista: "Salvar e fechar" + "Salvar e ver pacientes" -->
|
||||
<!-- Standalone fora da lista: "Salvar e fechar" + "Salvar e ver paciente" -->
|
||||
<template v-else>
|
||||
<Button label="Salvar e fechar" severity="secondary" outlined :loading="pendingMode === 'only' && !!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('only')" />
|
||||
<Button label="Salvar e ver pacientes" :loading="pendingMode === 'view' && !!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('view')" />
|
||||
<Button label="Salvar e ver paciente" :loading="pendingMode === 'view' && !!pageRef?.saving?.value" :disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value" @click="submitWith('view')" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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,171 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/composables/useRecentPatients.js
|
||||
|
|
||||
| Tracking dos últimos pacientes acessados pelo usuário logado.
|
||||
| Armazenado em localStorage por user_id pra isolar sessões diferentes
|
||||
| no mesmo browser (multi-conta).
|
||||
|
|
||||
| Usado pelo GlobalSearch.vue / MelissaBusca.vue como "recently viewed"
|
||||
| quando o input está vazio, e pode ser embedido em dashboards.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
const MAX_ITEMS = 5; // top N exibido
|
||||
const STORAGE_PREFIX = 'agpsi:recent-patients:';
|
||||
const STORAGE_EVENT = 'agpsi:recent-patients:changed';
|
||||
|
||||
function storageKey(userId) {
|
||||
return `${STORAGE_PREFIX}${userId || 'anon'}`;
|
||||
}
|
||||
|
||||
function loadFromStorage(userId) {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey(userId));
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveToStorage(userId, items) {
|
||||
try {
|
||||
localStorage.setItem(storageKey(userId), JSON.stringify(items));
|
||||
// Notifica outras instâncias do composable nesta mesma aba
|
||||
window.dispatchEvent(new CustomEvent(STORAGE_EVENT, { detail: { userId } }));
|
||||
} catch {
|
||||
// Quota cheia / modo privado — silenciar
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns composable reativo com `items` (array de pacientes recentes),
|
||||
* `addVisit(patient)` e `clear()`.
|
||||
*
|
||||
* Forma do patient esperado em addVisit:
|
||||
* { id: string, nome: string, ... } — extras (avatar, telefone, etc) são opt-in
|
||||
*
|
||||
* Forma do item armazenado:
|
||||
* { id, nome, visited_at: ISO, extras: {} }
|
||||
*/
|
||||
export function useRecentPatients() {
|
||||
const userId = ref(null);
|
||||
const items = ref([]);
|
||||
|
||||
async function resolveUserId() {
|
||||
if (userId.value) return userId.value;
|
||||
const { data } = await supabase.auth.getUser();
|
||||
userId.value = data?.user?.id || 'anon';
|
||||
return userId.value;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const uid = await resolveUserId();
|
||||
items.value = loadFromStorage(uid);
|
||||
}
|
||||
|
||||
async function addVisit(patient) {
|
||||
if (!patient?.id) return;
|
||||
const uid = await resolveUserId();
|
||||
const current = loadFromStorage(uid);
|
||||
|
||||
const entry = {
|
||||
id: String(patient.id),
|
||||
nome: patient.nome_completo || patient.nome_social || patient.nome || '(sem nome)',
|
||||
visited_at: new Date().toISOString(),
|
||||
extras: {
|
||||
nome_social: patient.nome_social || null,
|
||||
avatar_url: patient.avatar_url || null,
|
||||
telefone: patient.telefone || null,
|
||||
email: patient.email_principal || patient.email || null
|
||||
}
|
||||
};
|
||||
|
||||
// Remove duplicata + insere no topo + limita a MAX_ITEMS
|
||||
const dedup = current.filter(x => x.id !== entry.id);
|
||||
dedup.unshift(entry);
|
||||
const trimmed = dedup.slice(0, MAX_ITEMS);
|
||||
|
||||
saveToStorage(uid, trimmed);
|
||||
items.value = trimmed;
|
||||
}
|
||||
|
||||
async function remove(patientId) {
|
||||
const uid = await resolveUserId();
|
||||
const current = loadFromStorage(uid);
|
||||
const filtered = current.filter(x => String(x.id) !== String(patientId));
|
||||
saveToStorage(uid, filtered);
|
||||
items.value = filtered;
|
||||
}
|
||||
|
||||
async function clear() {
|
||||
const uid = await resolveUserId();
|
||||
saveToStorage(uid, []);
|
||||
items.value = [];
|
||||
}
|
||||
|
||||
// Sincroniza entre instâncias do composable na mesma aba
|
||||
function onChange() {
|
||||
refresh();
|
||||
}
|
||||
function onStorage(ev) {
|
||||
if (typeof ev?.key === 'string' && ev.key.startsWith(STORAGE_PREFIX)) {
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
refresh();
|
||||
window.addEventListener(STORAGE_EVENT, onChange);
|
||||
window.addEventListener('storage', onStorage);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener(STORAGE_EVENT, onChange);
|
||||
window.removeEventListener('storage', onStorage);
|
||||
});
|
||||
|
||||
const hasItems = computed(() => items.value.length > 0);
|
||||
|
||||
return {
|
||||
items,
|
||||
hasItems,
|
||||
addVisit,
|
||||
remove,
|
||||
clear,
|
||||
refresh
|
||||
};
|
||||
}
|
||||
|
||||
// ── Stateless helpers — usáveis fora de componentes (ex: action handlers) ──
|
||||
|
||||
/**
|
||||
* Registra uma visita SEM usar Vue reactivity. Útil pra hooks que não
|
||||
* estão dentro de setup() (ex: router.beforeEach, navigation guards).
|
||||
*/
|
||||
export async function registerPatientVisit(patient) {
|
||||
if (!patient?.id) return;
|
||||
const { data } = await supabase.auth.getUser();
|
||||
const uid = data?.user?.id || 'anon';
|
||||
const current = loadFromStorage(uid);
|
||||
const entry = {
|
||||
id: String(patient.id),
|
||||
nome: patient.nome_completo || patient.nome_social || patient.nome || '(sem nome)',
|
||||
visited_at: new Date().toISOString(),
|
||||
extras: {
|
||||
nome_social: patient.nome_social || null,
|
||||
avatar_url: patient.avatar_url || null,
|
||||
telefone: patient.telefone || null,
|
||||
email: patient.email_principal || patient.email || null
|
||||
}
|
||||
};
|
||||
const dedup = current.filter(x => x.id !== entry.id);
|
||||
dedup.unshift(entry);
|
||||
saveToStorage(uid, dedup.slice(0, MAX_ITEMS));
|
||||
}
|
||||
@@ -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,147 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/features/agenda/composables/useAgendaStatusChange.js
|
||||
|
|
||||
| Composable Tipo A que orquestra o fluxo de status change da agenda
|
||||
| usando agendaBilling.service. Reusável em Melissa / Rail / Clínica.
|
||||
|
|
||||
| Uso:
|
||||
| const { applyStatusChange, dialogOpen, dialogProps, onDialogConfirm,
|
||||
| onDialogCancel } = useAgendaStatusChange({ toast });
|
||||
|
|
||||
| // No handler:
|
||||
| await applyStatusChange({ eventoId, row, novoStatus });
|
||||
|
|
||||
| // No template:
|
||||
| <AgendaStatusChangeConfirmDialog
|
||||
| v-model="dialogOpen"
|
||||
| :evento="dialogProps.evento"
|
||||
| :novoStatus="dialogProps.novoStatus"
|
||||
| :regraExcecao="dialogProps.regraExcecao"
|
||||
| :billingContract="dialogProps.billingContract"
|
||||
| :billingContractStyle="dialogProps.billingContractStyle"
|
||||
| :pendingRecord="dialogProps.pendingRecord"
|
||||
| :sessionPrice="dialogProps.sessionPrice"
|
||||
| @confirm="onDialogConfirm"
|
||||
| @update:modelValue="(v) => !v && onDialogCancel()"
|
||||
| />
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import {
|
||||
loadStatusChangeContext,
|
||||
needsStatusConfirmDialog,
|
||||
applyStatusDecisions
|
||||
} from '@/features/agenda/services/agendaBilling.service';
|
||||
|
||||
/**
|
||||
* @param {object} [opts]
|
||||
* @param {object} [opts.toast] instância de useToast (PrimeVue). Opcional.
|
||||
* @returns composable com state reativo + applyStatusChange
|
||||
*/
|
||||
export function useAgendaStatusChange({ toast = null } = {}) {
|
||||
const tenantStore = useTenantStore();
|
||||
|
||||
// Dialog state — bindar no template
|
||||
const dialogOpen = ref(false);
|
||||
const dialogProps = ref({});
|
||||
let _resolveDialog = null;
|
||||
|
||||
function _openDialog(propsObj) {
|
||||
return new Promise((resolve) => {
|
||||
dialogProps.value = propsObj;
|
||||
dialogOpen.value = true;
|
||||
_resolveDialog = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
function onDialogConfirm(decision) {
|
||||
if (_resolveDialog) _resolveDialog(decision);
|
||||
_resolveDialog = null;
|
||||
dialogOpen.value = false;
|
||||
}
|
||||
|
||||
function onDialogCancel() {
|
||||
if (_resolveDialog) _resolveDialog(null);
|
||||
_resolveDialog = null;
|
||||
dialogOpen.value = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordena: load context → mostra dialog se preciso → aplica decisões.
|
||||
*
|
||||
* @param {object} args
|
||||
* @param {string} args.eventoId uuid (null pra ocorrências virtuais ainda)
|
||||
* @param {object} args.row row do agenda_eventos (pode ser parcial)
|
||||
* @param {string} args.novoStatus 'realizado' | 'faltou' | 'cancelado' | 'agendado'
|
||||
*
|
||||
* @returns {Promise<{ applied: boolean, decision: object|null, ctx: object }>}
|
||||
* applied=true se passou pelo applyStatusDecisions.
|
||||
* decision=null se user cancelou o dialog.
|
||||
*/
|
||||
async function applyStatusChange({ eventoId, row, novoStatus }) {
|
||||
const ownerId = (await supabase.auth.getUser()).data?.user?.id || null;
|
||||
const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
|
||||
|
||||
// 1) Carrega contexto
|
||||
const ctx = await loadStatusChangeContext({
|
||||
supabase,
|
||||
row,
|
||||
eventoId,
|
||||
status: novoStatus,
|
||||
ownerId,
|
||||
tenantId
|
||||
});
|
||||
|
||||
// 2) Dialog se preciso
|
||||
let decision = null;
|
||||
if (needsStatusConfirmDialog(novoStatus, ctx)) {
|
||||
decision = await _openDialog({
|
||||
evento: row,
|
||||
novoStatus,
|
||||
regraExcecao: ctx.regraExcecao,
|
||||
billingContract: ctx.billingContract,
|
||||
billingContractStyle: ctx.billingContract?.charging_style || null,
|
||||
pendingRecord: ctx.pendingRecord,
|
||||
sessionPrice: row?.price ?? null
|
||||
});
|
||||
if (!decision) {
|
||||
// user cancelou
|
||||
return { applied: false, decision: null, ctx };
|
||||
}
|
||||
} else {
|
||||
// Sem dialog — default decision vazia (só aplicar status change básico)
|
||||
decision = {};
|
||||
}
|
||||
|
||||
// 3) Aplica decisões
|
||||
await applyStatusDecisions({
|
||||
supabase,
|
||||
toast,
|
||||
eventoId,
|
||||
row,
|
||||
novoStatus,
|
||||
ctx,
|
||||
decision,
|
||||
ownerId,
|
||||
tenantId
|
||||
});
|
||||
|
||||
return { applied: true, decision, ctx };
|
||||
}
|
||||
|
||||
return {
|
||||
// dialog state — pra template
|
||||
dialogOpen,
|
||||
dialogProps,
|
||||
onDialogConfirm,
|
||||
onDialogCancel,
|
||||
// main action
|
||||
applyStatusChange
|
||||
};
|
||||
}
|
||||
@@ -36,6 +36,12 @@ import { useDeterminedCommitments } from '@/features/agenda/composables/useDeter
|
||||
import { useFeriados } from '@/composables/useFeriados';
|
||||
import { useAgendaBloqueios } from '@/features/agenda/composables/useAgendaBloqueios';
|
||||
|
||||
// Fase D (replicação Clínica): adopta agendaBilling.service via composable
|
||||
// reutilizável. Cobre status change com confirm dialog + multa + reverse +
|
||||
// pacote saldo/upfront (C7-C13 de Melissa, espelho da Fase C do Rail).
|
||||
import { useAgendaStatusChange } from '@/features/agenda/composables/useAgendaStatusChange';
|
||||
import AgendaStatusChangeConfirmDialog from '@/features/agenda/components/AgendaStatusChangeConfirmDialog.vue';
|
||||
|
||||
import { mapAgendaEventosToCalendarEvents, minutesToDuration } from '@/features/agenda/services/agendaMappers';
|
||||
import { useAgendaSettings } from '@/features/agenda/composables/useAgendaSettings';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
@@ -502,6 +508,15 @@ const ownerOptions = computed(() => staffCols.value.map((p) => ({ label: p.title
|
||||
// -------------------- events --------------------
|
||||
const { loading: loadingEvents, error: eventsError, rows, loadClinicRange, createClinic, updateClinic, removeClinic } = useAgendaClinicEvents();
|
||||
|
||||
// Fase D: status change com confirm dialog + billing (Melissa pattern).
|
||||
const {
|
||||
dialogOpen: statusDialogOpen,
|
||||
dialogProps: statusDialogProps,
|
||||
onDialogConfirm: onStatusDialogConfirm,
|
||||
onDialogCancel: onStatusDialogCancel,
|
||||
applyStatusChange
|
||||
} = useAgendaStatusChange({ toast });
|
||||
|
||||
const { loadAndExpand, createRule, updateRule, cancelRule, splitRuleAt, cancelRuleFrom, upsertException } = useRecurrence();
|
||||
|
||||
const { saveRuleItems, propagateToSerie } = useCommitmentServices();
|
||||
@@ -1189,38 +1204,58 @@ function onEditSeriesOccurrence({ id, recurrence_date, inicio_em, fim_em, is_vir
|
||||
async function onUpdateSeriesEvent({ id, status, recurrence_date, inicio_em, fim_em, is_virtual }) {
|
||||
const tid = tenantId.value;
|
||||
try {
|
||||
if (id) {
|
||||
await updateClinic(id, { status }, { tenantId: tid });
|
||||
return;
|
||||
const row = dialogEventRow.value || {};
|
||||
|
||||
// 1) Materializar virtual se preciso (resolve eventoId real)
|
||||
let eventoId = id;
|
||||
if (!id) {
|
||||
if (!is_virtual || !inicio_em) return;
|
||||
const rid = row.recurrence_id ?? row.serie_id ?? null;
|
||||
const rDate = recurrence_date || inicio_em?.slice(0, 10);
|
||||
const { data: existing } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('id')
|
||||
.eq('recurrence_id', rid)
|
||||
.eq('recurrence_date', rDate)
|
||||
.maybeSingle();
|
||||
if (existing?.id) {
|
||||
eventoId = existing.id;
|
||||
} else {
|
||||
// Materializa com status='agendado'; o status final aplica
|
||||
// após applyStatusChange ramificar pelo dialog se preciso.
|
||||
const created = await createClinic(
|
||||
{
|
||||
owner_id: dialogOwnerId.value || clinicOwnerId.value,
|
||||
tenant_id: tid,
|
||||
recurrence_id: rid,
|
||||
recurrence_date: rDate,
|
||||
tipo: 'sessao',
|
||||
status: 'agendado',
|
||||
inicio_em,
|
||||
fim_em,
|
||||
visibility_scope: 'public',
|
||||
titulo: row.titulo || 'Sessão',
|
||||
patient_id: row.patient_id || row.paciente_id || null,
|
||||
determined_commitment_id: row.determined_commitment_id || null
|
||||
},
|
||||
{ tenantId: tid }
|
||||
);
|
||||
eventoId = created?.id || null;
|
||||
}
|
||||
}
|
||||
if (!is_virtual || !inicio_em) return;
|
||||
|
||||
const rid = dialogEventRow.value?.recurrence_id ?? dialogEventRow.value?.serie_id ?? null;
|
||||
const rDate = recurrence_date || inicio_em?.slice(0, 10);
|
||||
// 2) Atualiza status no DB
|
||||
if (eventoId) {
|
||||
await updateClinic(eventoId, { status }, { tenantId: tid });
|
||||
}
|
||||
|
||||
const { data: existing } = await supabase.from('agenda_eventos').select('id').eq('recurrence_id', rid).eq('recurrence_date', rDate).maybeSingle();
|
||||
// 3) Fluxo de billing (load context + dialog + apply)
|
||||
const rowWithStatus = { ...row, id: eventoId, status, inicio_em, fim_em };
|
||||
const { applied } = await applyStatusChange({ eventoId, row: rowWithStatus, novoStatus: status });
|
||||
|
||||
if (existing?.id) {
|
||||
await updateClinic(existing.id, { status }, { tenantId: tid });
|
||||
} else {
|
||||
const row = dialogEventRow.value || {};
|
||||
await createClinic(
|
||||
{
|
||||
owner_id: dialogOwnerId.value || clinicOwnerId.value,
|
||||
tenant_id: tid,
|
||||
recurrence_id: rid,
|
||||
recurrence_date: rDate,
|
||||
tipo: 'sessao',
|
||||
status,
|
||||
inicio_em,
|
||||
fim_em,
|
||||
visibility_scope: 'public',
|
||||
titulo: row.titulo || 'Sessão',
|
||||
patient_id: row.patient_id || row.paciente_id || null,
|
||||
determined_commitment_id: row.determined_commitment_id || null
|
||||
},
|
||||
{ tenantId: tid }
|
||||
);
|
||||
// 4) Refetch se aplicou (UI reflete novo estado)
|
||||
if (applied && typeof loadClinicRange === 'function') {
|
||||
await loadClinicRange();
|
||||
}
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'warn', summary: 'Erro', detail: e?.message || 'Falha ao atualizar status.', life: 3000 });
|
||||
@@ -2463,6 +2498,21 @@ function goRecorrencias() {
|
||||
<!-- Dialog de Bloqueio -->
|
||||
<BloqueioDialog v-model="bloqueioDialogOpen" :mode="bloqueioMode" :workRules="workRules" :settings="settings" :ownerId="clinicOwnerId" :tenantId="tenantId || ''" @saved="refetch" />
|
||||
|
||||
<!-- Fase D: confirma status change com decisões de billing
|
||||
(multa, consumir saldo, gerar cobrança, reverse transition). -->
|
||||
<AgendaStatusChangeConfirmDialog
|
||||
v-model="statusDialogOpen"
|
||||
:evento="statusDialogProps.evento"
|
||||
:novoStatus="statusDialogProps.novoStatus"
|
||||
:regraExcecao="statusDialogProps.regraExcecao"
|
||||
:billingContract="statusDialogProps.billingContract"
|
||||
:billingContractStyle="statusDialogProps.billingContractStyle"
|
||||
:pendingRecord="statusDialogProps.pendingRecord"
|
||||
:sessionPrice="statusDialogProps.sessionPrice"
|
||||
@confirm="onStatusDialogConfirm"
|
||||
@update:modelValue="(v) => !v && onStatusDialogCancel()"
|
||||
/>
|
||||
|
||||
<!-- Dialog: feriados próximos (todos os dias úteis — bloqueados e pendentes) -->
|
||||
<Dialog v-model:visible="feriadosAlertaOpen" modal :draggable="false" header="Feriados nos próximos 30 dias" :style="{ width: '520px', maxWidth: '96vw' }">
|
||||
<div class="flex flex-col gap-3">
|
||||
|
||||
@@ -49,6 +49,13 @@ import { useDeterminedCommitments } from '@/features/agenda/composables/useDeter
|
||||
import { useFeriados } from '@/composables/useFeriados';
|
||||
import { useAgendaBloqueios } from '@/features/agenda/composables/useAgendaBloqueios';
|
||||
|
||||
// Fase C (replicação Rail): adopta agendaBilling.service via composable
|
||||
// reutilizável. Cobre status change com confirm dialog + multa + reverse +
|
||||
// pacote saldo/upfront (C7-C13 de Melissa).
|
||||
import { useAgendaStatusChange } from '@/features/agenda/composables/useAgendaStatusChange';
|
||||
import { createPackageContract, materializeAndChargePerSession } from '@/features/agenda/services/agendaBilling.service';
|
||||
import AgendaStatusChangeConfirmDialog from '@/features/agenda/components/AgendaStatusChangeConfirmDialog.vue';
|
||||
|
||||
import { mapAgendaEventosToCalendarEvents, buildWeeklyBreakBackgroundEvents, minutesToDuration } from '@/features/agenda/services/agendaMappers';
|
||||
|
||||
const router = useRouter();
|
||||
@@ -119,6 +126,16 @@ watch(eventsLoading, (val) => {
|
||||
if (!val) eventsHasLoaded.value = true;
|
||||
});
|
||||
|
||||
// Fase C: orquestrador de status change (Melissa pattern). Cobre confirm
|
||||
// dialog + multa + reverse + pacote saldo/upfront via agendaBilling.service.
|
||||
const {
|
||||
dialogOpen: statusDialogOpen,
|
||||
dialogProps: statusDialogProps,
|
||||
onDialogConfirm: onStatusDialogConfirm,
|
||||
onDialogCancel: onStatusDialogCancel,
|
||||
applyStatusChange
|
||||
} = useAgendaStatusChange({ toast });
|
||||
|
||||
const { loadAndExpand, createRule, updateRule, cancelRule, splitRuleAt, cancelRuleFrom, upsertException } = useRecurrence();
|
||||
|
||||
const { saveRuleItems, propagateToSerie } = useCommitmentServices();
|
||||
@@ -1711,37 +1728,60 @@ function onEditSeriesOccurrence({ id, recurrence_date, inicio_em, fim_em, is_vir
|
||||
|
||||
async function onUpdateSeriesEvent({ id, status, recurrence_date, inicio_em, fim_em, is_virtual }) {
|
||||
try {
|
||||
if (id) {
|
||||
await update(id, { status });
|
||||
return;
|
||||
const row = dialogEventRow.value || {};
|
||||
|
||||
// 1) Materializar virtual se preciso (resolve `eventoId` real)
|
||||
let eventoId = id;
|
||||
if (!id) {
|
||||
if (!is_virtual || !inicio_em) return;
|
||||
const rid = row.recurrence_id ?? row.serie_id ?? null;
|
||||
const rDate = recurrence_date || inicio_em?.slice(0, 10);
|
||||
const { data: existing } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('id')
|
||||
.eq('recurrence_id', rid)
|
||||
.eq('recurrence_date', rDate)
|
||||
.maybeSingle();
|
||||
if (existing?.id) {
|
||||
eventoId = existing.id;
|
||||
// Status atualiza só depois do dialog/applyStatusChange decidir
|
||||
} else {
|
||||
// Materializa criando com status='agendado' — o status final
|
||||
// é aplicado por applyStatusChange (que pode ramificar pelo
|
||||
// dialog se houver decisão a tomar)
|
||||
const created = await create({
|
||||
owner_id: ownerId.value,
|
||||
tenant_id: clinicTenantId.value,
|
||||
recurrence_id: rid,
|
||||
recurrence_date: rDate,
|
||||
tipo: 'sessao',
|
||||
status: 'agendado',
|
||||
inicio_em,
|
||||
fim_em,
|
||||
visibility_scope: 'public',
|
||||
titulo: row.titulo || 'Sessão',
|
||||
patient_id: row.patient_id || row.paciente_id || null,
|
||||
determined_commitment_id: row.determined_commitment_id || null,
|
||||
price: row.price ?? null
|
||||
});
|
||||
eventoId = created?.id || null;
|
||||
}
|
||||
}
|
||||
if (!is_virtual || !inicio_em) return;
|
||||
|
||||
const rid = dialogEventRow.value?.recurrence_id ?? dialogEventRow.value?.serie_id ?? null;
|
||||
const rDate = recurrence_date || inicio_em?.slice(0, 10);
|
||||
// 2) Atualiza status NO DB (applyStatusChange só cuida de billing — não
|
||||
// do status do agenda_evento em si). Antes do dialog pra ctx.row
|
||||
// refletir o novo status do evento.
|
||||
if (eventoId) {
|
||||
await update(eventoId, { status });
|
||||
}
|
||||
|
||||
// Verifica se já foi materializado antes (evita violação de constraint)
|
||||
const { data: existing } = await supabase.from('agenda_eventos').select('id').eq('recurrence_id', rid).eq('recurrence_date', rDate).maybeSingle();
|
||||
// 3) Aplica fluxo de billing (load context + dialog se preciso + apply)
|
||||
const rowWithStatus = { ...row, id: eventoId, status, inicio_em, fim_em };
|
||||
const { applied } = await applyStatusChange({ eventoId, row: rowWithStatus, novoStatus: status });
|
||||
|
||||
if (existing?.id) {
|
||||
await update(existing.id, { status });
|
||||
} else {
|
||||
const row = dialogEventRow.value || {};
|
||||
await create({
|
||||
owner_id: ownerId.value,
|
||||
tenant_id: clinicTenantId.value,
|
||||
recurrence_id: rid,
|
||||
recurrence_date: rDate,
|
||||
tipo: 'sessao',
|
||||
status,
|
||||
inicio_em,
|
||||
fim_em,
|
||||
visibility_scope: 'public',
|
||||
titulo: row.titulo || 'Sessão',
|
||||
patient_id: row.patient_id || row.paciente_id || null,
|
||||
determined_commitment_id: row.determined_commitment_id || null,
|
||||
price: row.price ?? null
|
||||
});
|
||||
// 4) Se aplicou (e não cancelou via dialog), refetch pra UI refletir
|
||||
if (applied) {
|
||||
await loadMyRange?.();
|
||||
}
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'warn', summary: 'Erro', detail: e?.message || 'Falha ao atualizar status.', life: 3000 });
|
||||
@@ -3303,6 +3343,21 @@ onBeforeUnmount(() => {
|
||||
|
||||
<!-- Dialog: Prontuário -->
|
||||
<PatientProntuario :key="selectedPatient?.id || 'none'" v-model="prontuarioOpen" :patient="selectedPatient" @close="closeProntuario" />
|
||||
|
||||
<!-- Fase C: confirma status change com decisões de billing
|
||||
(multa, consumir saldo, gerar cobrança, reverse transition). -->
|
||||
<AgendaStatusChangeConfirmDialog
|
||||
v-model="statusDialogOpen"
|
||||
:evento="statusDialogProps.evento"
|
||||
:novoStatus="statusDialogProps.novoStatus"
|
||||
:regraExcecao="statusDialogProps.regraExcecao"
|
||||
:billingContract="statusDialogProps.billingContract"
|
||||
:billingContractStyle="statusDialogProps.billingContractStyle"
|
||||
:pendingRecord="statusDialogProps.pendingRecord"
|
||||
:sessionPrice="statusDialogProps.sessionPrice"
|
||||
@confirm="onStatusDialogConfirm"
|
||||
@update:modelValue="(v) => !v && onStatusDialogCancel()"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -0,0 +1,816 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI — agendaBilling service (Fase B1)
|
||||
|--------------------------------------------------------------------------
|
||||
| Helpers e loaders relacionados a billing da agenda, extraídos de
|
||||
| useMelissaAgenda.js pra serem reusados em Rail/Clínica.
|
||||
|
|
||||
| Esta sessão (Fase B1) cobre só read-only + helpers puros:
|
||||
| - computeSeriePrice (puro)
|
||||
| - generateOccurrenceDates (puro)
|
||||
| - loadStatusChangeContext (read-only DB)
|
||||
| - needsStatusConfirmDialog (puro)
|
||||
|
|
||||
| Fase B2 (mutations) extrairá: applyStatusDecisions, createPackageContract,
|
||||
| materializeAndChargePerSession.
|
||||
|
|
||||
| Convenção: funções recebem `supabase` explícito (não usa import direto)
|
||||
| pra facilitar teste + reuso fora do contexto Vue. Nenhuma função aqui
|
||||
| dispara toast — caller decide.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { dateToISO } from '@/features/agenda/utils/timeHelpers';
|
||||
|
||||
// ── Helpers puros ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Calcula o valor total da série a partir dos commitmentItems.
|
||||
*
|
||||
* @param {object} recorrencia { qtdSessoes, commitmentItems, serieValorMode }
|
||||
* @returns {{ n, perSessao, packagePrice }}
|
||||
*/
|
||||
export function computeSeriePrice(recorrencia) {
|
||||
const items = recorrencia?.commitmentItems || [];
|
||||
const n = recorrencia?.qtdSessoes || 1;
|
||||
const totalPorSessao = items.reduce((s, i) => s + (i.final_price ?? 0), 0);
|
||||
const pacoteFechado = recorrencia?.serieValorMode === 'dividir';
|
||||
return {
|
||||
n,
|
||||
perSessao: pacoteFechado ? totalPorSessao / n : totalPorSessao,
|
||||
packagePrice: pacoteFechado ? totalPorSessao : totalPorSessao * n
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gera lista de datas ISO ('YYYY-MM-DD') a partir de uma rule de recorrência.
|
||||
* Pula datas em exceptionDates (Set). Para até `max` datas. Suporta weekly
|
||||
* (interval=1 ou 2 pra quinzenal) e custom_weekdays.
|
||||
*
|
||||
* @param {object} rule { start_date, interval, weekdays, type }
|
||||
* @param {number} max
|
||||
* @param {Set<string>} exceptionDates
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function generateOccurrenceDates(rule, max, exceptionDates = new Set()) {
|
||||
const dates = [];
|
||||
const start = new Date(`${rule.start_date}T00:00:00`);
|
||||
const interval = Math.max(1, rule.interval || 1);
|
||||
const weekdays = Array.isArray(rule.weekdays) && rule.weekdays.length
|
||||
? rule.weekdays.map(Number)
|
||||
: [start.getDay()];
|
||||
const isCustom = rule.type === 'custom_weekdays';
|
||||
|
||||
const cursor = new Date(start);
|
||||
let safety = 0;
|
||||
while (dates.length < max && safety < 365 * 3) {
|
||||
const iso = dateToISO(cursor);
|
||||
const dow = cursor.getDay();
|
||||
const inWeekdays = weekdays.includes(dow);
|
||||
if (inWeekdays && !exceptionDates.has(iso)) {
|
||||
dates.push(iso);
|
||||
}
|
||||
if (isCustom) {
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
} else if (inWeekdays) {
|
||||
cursor.setDate(cursor.getDate() + 7 * interval);
|
||||
} else {
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
safety++;
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide se o dialog de confirmação de status change deve ser exibido.
|
||||
*
|
||||
* Pure: depende só do ctx montado por loadStatusChangeContext.
|
||||
*
|
||||
* Regras:
|
||||
* - faltou/cancelado: mostra se há regra de exceção com charge_mode != 'none'
|
||||
* OU pacote saldo/upfront
|
||||
* - realizado: mostra se há pending record OU pacote saldo
|
||||
* - agendado: (reverse) mostra se há artefatos a desfazer
|
||||
*/
|
||||
export function needsStatusConfirmDialog(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';
|
||||
const hasPending = !!ctx?.pendingRecord;
|
||||
|
||||
if (isFaltouOrCancel) {
|
||||
return hasRegraComCobranca || isPacoteSaldo || isPacoteUpfront;
|
||||
}
|
||||
if (isRealizado) {
|
||||
return hasPending || isPacoteSaldo;
|
||||
}
|
||||
if (isAgendado) {
|
||||
const r = ctx?.reverseArtifacts;
|
||||
if (!r) return false;
|
||||
const hasActiveRecords = (r.activeRecords?.length || 0) > 0;
|
||||
return hasActiveRecords || r.saldoConsumed;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Loaders (read-only DB) ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Carrega contexto pra decisão de status change.
|
||||
*
|
||||
* Read-only. Não dispara toast (caller decide). Tolerante a erros parciais
|
||||
* (loga warn e segue com null).
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {object} opts.supabase instância do client
|
||||
* @param {object} opts.row row do agenda_eventos (pode ser parcial — usa fallbacks)
|
||||
* @param {string} opts.eventoId uuid (null pra ocorrências virtuais não materializadas)
|
||||
* @param {string} opts.status 'realizado' | 'faltou' | 'cancelado' | 'agendado'
|
||||
* @param {string} opts.ownerId auth.uid() (resolvido pelo caller)
|
||||
* @param {string} opts.tenantId activeTenantId
|
||||
*
|
||||
* @returns {Promise<{
|
||||
* regraExcecao,
|
||||
* billingContract,
|
||||
* pendingRecord,
|
||||
* existingPaidRecord,
|
||||
* reverseArtifacts: { previousStatus, activeRecords, saldoConsumed } | null
|
||||
* }>}
|
||||
*/
|
||||
export async function loadStatusChangeContext({ supabase, row, eventoId, status, ownerId, tenantId }) {
|
||||
const ctx = {
|
||||
regraExcecao: null,
|
||||
billingContract: null,
|
||||
pendingRecord: null,
|
||||
existingPaidRecord: null,
|
||||
reverseArtifacts: null
|
||||
};
|
||||
|
||||
// 1) Regra de exceção (faltou → patient_no_show, cancelado → patient_cancellation)
|
||||
const exceptionTypeMap = { faltou: 'patient_no_show', cancelado: 'patient_cancellation' };
|
||||
const excType = exceptionTypeMap[status];
|
||||
if (excType && tenantId) {
|
||||
try {
|
||||
const { data } = await supabase
|
||||
.from('financial_exceptions')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('exception_type', excType)
|
||||
.or(`owner_id.eq.${ownerId},owner_id.is.null`)
|
||||
.order('owner_id', { ascending: false, nullsLast: true })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
ctx.regraExcecao = data ?? null;
|
||||
} catch (e) {
|
||||
console.warn('[agendaBilling] regra de exceção:', e?.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Billing contract — 3 caminhos: row.billing_contract_id direto → query
|
||||
// agenda_eventos.billing_contract_id (recém-materializada) → contrato
|
||||
// ativo do paciente (virtuais).
|
||||
const patientId = row?.patient_id ?? row?.paciente_id ?? null;
|
||||
const contractId = row?.billing_contract_id ?? null;
|
||||
if (contractId) {
|
||||
try {
|
||||
const { data } = await supabase
|
||||
.from('billing_contracts')
|
||||
.select('*')
|
||||
.eq('id', contractId)
|
||||
.maybeSingle();
|
||||
ctx.billingContract = data ?? null;
|
||||
} catch (e) {
|
||||
console.warn('[agendaBilling] contract via id direto:', e?.message);
|
||||
}
|
||||
}
|
||||
if (!ctx.billingContract && eventoId) {
|
||||
try {
|
||||
const { data: ev } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('billing_contract_id')
|
||||
.eq('id', eventoId)
|
||||
.maybeSingle();
|
||||
if (ev?.billing_contract_id) {
|
||||
const { data: c } = await supabase
|
||||
.from('billing_contracts')
|
||||
.select('*')
|
||||
.eq('id', ev.billing_contract_id)
|
||||
.maybeSingle();
|
||||
ctx.billingContract = c ?? null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[agendaBilling] contract via agenda_evento:', e?.message);
|
||||
}
|
||||
}
|
||||
if (!ctx.billingContract && patientId && tenantId) {
|
||||
try {
|
||||
const { data: c } = await supabase
|
||||
.from('billing_contracts')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('patient_id', patientId)
|
||||
.eq('status', 'active')
|
||||
.eq('type', 'package')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
ctx.billingContract = c ?? null;
|
||||
} catch (e) {
|
||||
console.warn('[agendaBilling] contract via patient_id:', e?.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Pending record
|
||||
if (eventoId) {
|
||||
try {
|
||||
const { data } = await supabase
|
||||
.from('financial_records')
|
||||
.select('*')
|
||||
.eq('agenda_evento_id', eventoId)
|
||||
.in('status', ['pending', 'overdue'])
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
ctx.pendingRecord = data ?? null;
|
||||
} catch (e) {
|
||||
console.warn('[agendaBilling] pending record:', e?.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 3b) Paid record pré-existente (caso C12: antecipar pagamento).
|
||||
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('[agendaBilling] existing paid record:', e?.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Reverse transition (status novo='agendado'): artefatos a desfazer.
|
||||
if (status === 'agendado' && eventoId) {
|
||||
ctx.reverseArtifacts = {
|
||||
previousStatus: row?.status || null,
|
||||
activeRecords: [],
|
||||
saldoConsumed: false
|
||||
};
|
||||
try {
|
||||
const { data: evRow } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('status, billing_contract_id')
|
||||
.eq('id', eventoId)
|
||||
.maybeSingle();
|
||||
if (evRow) {
|
||||
ctx.reverseArtifacts.previousStatus = evRow.status;
|
||||
}
|
||||
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 || [];
|
||||
// Heurística saldo consumido: billing_contract_id + previousStatus
|
||||
// ≠ 'agendado' + style=saldo. Falso positivo é mitigado pela escolha
|
||||
// do user no dialog de "devolver saldo".
|
||||
const wasInActiveState = evRow?.billing_contract_id && evRow.status && evRow.status !== 'agendado';
|
||||
ctx.reverseArtifacts.saldoConsumed = wasInActiveState && ctx.billingContract?.charging_style === 'saldo';
|
||||
} catch (e) {
|
||||
console.warn('[agendaBilling] reverse artifacts:', e?.message);
|
||||
}
|
||||
}
|
||||
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// ── Mutations (Fase B2 — side effects DB) ─────────────────────────────────
|
||||
|
||||
const _BRL = (v) => Number(v ?? 0).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||
|
||||
/**
|
||||
* Aplica as decisões tomadas no dialog de status change (reverse / consume
|
||||
* saldo / multa / mark paid / cobrança pacote).
|
||||
*
|
||||
* Recebe deps explícitas (supabase, toast, ownerId, tenantId) em vez de
|
||||
* capturar via closure. Toast pode ser null — quando chamado fora de UI
|
||||
* (ex: background job), erros viram exceções no caller.
|
||||
*
|
||||
* Mantém a lógica idêntica à versão inline original em useMelissaAgenda.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {object} opts.supabase
|
||||
* @param {object} [opts.toast] — `{ add: fn }`. Opcional.
|
||||
* @param {string} opts.eventoId
|
||||
* @param {object} opts.row
|
||||
* @param {string} opts.novoStatus
|
||||
* @param {object} opts.ctx — saída de loadStatusChangeContext
|
||||
* @param {object} opts.decision
|
||||
* @param {string} opts.ownerId
|
||||
* @param {string} opts.tenantId
|
||||
*/
|
||||
export async function applyStatusDecisions({ supabase, toast, eventoId, row, novoStatus, ctx, decision, ownerId, tenantId }) {
|
||||
const uid = ownerId;
|
||||
const patientId = row?.patient_id ?? row?.paciente_id ?? null;
|
||||
const tasks = [];
|
||||
const tx = (entry) => { if (toast?.add) toast.add(entry); };
|
||||
|
||||
// ─── REVERSE TRANSITION (novoStatus='agendado' com artefatos a desfazer) ──
|
||||
if (novoStatus === 'agendado' && ctx.reverseArtifacts) {
|
||||
const r = ctx.reverseArtifacts;
|
||||
// 1) Cancelar records pending/overdue
|
||||
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}`;
|
||||
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('[agendaBilling/reverse] erro cancelando records:', e?.message);
|
||||
tx({ severity: 'warn', summary: 'Cobrança', detail: e?.message || 'Falha ao cancelar cobranças', life: 5000 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Devolver saldo
|
||||
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 };
|
||||
if (currentUsed >= totalSessions) {
|
||||
patch.status = 'active';
|
||||
}
|
||||
const { error: dErr } = await supabase.from('billing_contracts').update(patch).eq('id', ctx.billingContract.id);
|
||||
if (dErr) throw dErr;
|
||||
} catch (e) {
|
||||
console.error('[agendaBilling/reverse] erro decrementando saldo:', e?.message);
|
||||
tx({ severity: 'warn', summary: 'Saldo do pacote', detail: e?.message || 'Falha ao devolver saldo', life: 5000 });
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Desamarrar billing_contract_id (só se devolveu saldo)
|
||||
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('[agendaBilling/reverse] erro desamarrando billing_contract_id:', e?.message);
|
||||
}
|
||||
}
|
||||
|
||||
tx({ severity: 'success', summary: 'Sessão reativada', detail: 'Status revertido pra agendada.', life: 2500 });
|
||||
return;
|
||||
}
|
||||
|
||||
// 1) Consumir saldo
|
||||
if (decision.consumeSaldo && ctx.billingContract?.id) {
|
||||
tasks.push(
|
||||
supabase
|
||||
.from('billing_contracts')
|
||||
.update({ sessions_used: (ctx.billingContract.sessions_used ?? 0) + 1 })
|
||||
.eq('id', ctx.billingContract.id)
|
||||
);
|
||||
}
|
||||
|
||||
// 1b) Amarra evento ao contrato (universal pra forward em pacote)
|
||||
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
|
||||
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,
|
||||
patient_id: patientId,
|
||||
agenda_evento_id: eventoId,
|
||||
amount: decision.fineAmount,
|
||||
final_amount: decision.fineAmount,
|
||||
description: fineDesc.trim(),
|
||||
status: 'pending',
|
||||
due_date: dueIso,
|
||||
type: 'receita'
|
||||
};
|
||||
tasks.push(
|
||||
supabase
|
||||
.from('financial_records')
|
||||
.insert(finePayload)
|
||||
.then(({ error }) => {
|
||||
if (error) {
|
||||
console.warn('[agendaBilling] INSERT multa falhou:', error?.message, 'payload:', finePayload);
|
||||
throw error;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// 2b) Cancelar cobrança original (faltou/cancelado + pendingRecord)
|
||||
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(
|
||||
supabase
|
||||
.from('financial_records')
|
||||
.update({
|
||||
status: 'paid',
|
||||
paid_at: new Date().toISOString(),
|
||||
payment_method: decision.paymentMethod || 'pix',
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', ctx.pendingRecord.id)
|
||||
);
|
||||
}
|
||||
|
||||
// 4-pre) Realizado em pacote saldo + paid pré-existente (C12)
|
||||
const hasAnticipatedPayment = novoStatus === 'realizado' && ctx.billingContract?.charging_style === 'saldo' && ctx.existingPaidRecord?.id;
|
||||
if (hasAnticipatedPayment) {
|
||||
if (tasks.length > 0) {
|
||||
const results = await Promise.allSettled(tasks);
|
||||
const failed = results.filter((r) => r.status === 'rejected');
|
||||
if (failed.length > 0) {
|
||||
console.warn('[agendaBilling/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;
|
||||
tx({ severity: 'success', summary: 'Sessão consumida', detail: `Saldo: ${newUsed}/${freshContract?.total_sessions ?? '?'}. Pagamento já estava registrado.`, life: 4000 });
|
||||
} catch (e) {
|
||||
console.error('[agendaBilling/realizada-paid] erro consumindo saldo:', e?.message);
|
||||
tx({ severity: 'warn', summary: 'Saldo do pacote', detail: e?.message || 'Falha ao consumir saldo', life: 5000 });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 4) Realizado em pacote saldo: amarra + cria cobrança + incrementa saldo
|
||||
if (decision.generatePackageCharge && ctx.billingContract?.id) {
|
||||
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);
|
||||
|
||||
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('[agendaBilling] erro amarrando billing_contract_id:', e?.message);
|
||||
tx({ severity: 'warn', summary: 'Pacote — amarração', detail: e?.message || 'Falha ao amarrar sessão ao pacote', life: 5000 });
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
if (rpcErr) throw rpcErr;
|
||||
} catch (e) {
|
||||
console.error('[agendaBilling] erro RPC create_financial_record_for_session:', e?.message);
|
||||
tx({ severity: 'error', summary: 'Cobrança', detail: e?.message || 'Falha ao gerar cobrança do pacote', life: 7000 });
|
||||
}
|
||||
|
||||
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('[agendaBilling] erro incrementando sessions_used:', e?.message);
|
||||
tx({ severity: 'warn', summary: 'Saldo do pacote', detail: e?.message || 'Falha ao atualizar saldo do pacote', life: 6000 });
|
||||
}
|
||||
}
|
||||
|
||||
// Roda tudo em paralelo
|
||||
const results = await Promise.allSettled(tasks);
|
||||
const failed = results.filter((r) => r.status === 'rejected');
|
||||
if (failed.length > 0) {
|
||||
const firstErr = failed[0].reason?.message || 'sem detalhe';
|
||||
tx({ severity: 'error', summary: 'Erro ao aplicar decisões', detail: `${failed.length} ação(ões) falharam: ${firstErr}`, life: 7000 });
|
||||
console.error('[agendaBilling] falhas em applyStatusDecisions:', failed.map((f) => f.reason));
|
||||
} else if (tasks.length > 0) {
|
||||
tx({ severity: 'success', summary: 'Status atualizado', detail: 'Decisões aplicadas.', life: 2500 });
|
||||
}
|
||||
|
||||
// Pós-processamento do record gerado pelo pacote saldo
|
||||
if (decision.generatePackageCharge && eventoId) {
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
} catch { /* silencioso */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cria billing_contract de pacote (upfront ou saldo). Materializa 1ª
|
||||
* ocorrência + 1 financial_record (estilo upfront), ou só o contrato
|
||||
* (estilo saldo).
|
||||
*
|
||||
* Retorna { toast: { severity, summary, detail, life } } — caller mostra.
|
||||
*/
|
||||
export async function createPackageContract({ supabase, rule, normalized, recorrencia, tenantId, packageStyle = 'upfront', paymentMethod = 'link', markPaidNow = false }) {
|
||||
const { n, packagePrice } = computeSeriePrice(recorrencia);
|
||||
try {
|
||||
const { data: createdContract, error: contractErr } = await supabase
|
||||
.from('billing_contracts')
|
||||
.insert({
|
||||
owner_id: normalized.owner_id,
|
||||
tenant_id: tenantId,
|
||||
patient_id: normalized.paciente_id,
|
||||
type: 'package',
|
||||
total_sessions: n,
|
||||
sessions_used: 0,
|
||||
package_price: packagePrice,
|
||||
status: 'active',
|
||||
charging_style: packageStyle === 'saldo' ? 'saldo' : 'upfront'
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
if (contractErr) throw contractErr;
|
||||
const contractId = createdContract?.id ?? null;
|
||||
|
||||
if (packageStyle === 'saldo') {
|
||||
return {
|
||||
toast: {
|
||||
severity: 'success',
|
||||
summary: 'Pacote criado (saldo)',
|
||||
detail: `${n} sessões — total ${_BRL(packagePrice)}. Cobranças individuais conforme sessões.`,
|
||||
life: 3500
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const durMin = rule.duration_min || 50;
|
||||
const [hh, mm] = String(rule.start_time || '00:00:00').slice(0, 5).split(':').map(Number);
|
||||
const firstISO = rule.start_date;
|
||||
const startDt = new Date(`${firstISO}T00:00:00`);
|
||||
startDt.setHours(hh, mm, 0, 0);
|
||||
const endDt = new Date(startDt.getTime() + durMin * 60 * 1000);
|
||||
|
||||
const { data: createdEvent, error: evErr } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.insert({
|
||||
owner_id: rule.owner_id,
|
||||
tenant_id: tenantId,
|
||||
terapeuta_id: rule.therapist_id ?? null,
|
||||
recurrence_id: rule.id,
|
||||
recurrence_date: firstISO,
|
||||
tipo: 'sessao',
|
||||
status: 'agendado',
|
||||
titulo: normalized.titulo || 'Sessão',
|
||||
inicio_em: startDt.toISOString(),
|
||||
fim_em: endDt.toISOString(),
|
||||
patient_id: normalized.paciente_id,
|
||||
determined_commitment_id: normalized.determined_commitment_id ?? null,
|
||||
modalidade: rule.modalidade || normalized.modalidade || 'presencial',
|
||||
price: packagePrice,
|
||||
billing_contract_id: contractId,
|
||||
visibility_scope: normalized.visibility_scope || 'public'
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
if (evErr) throw evErr;
|
||||
|
||||
const { error: cobErr } = await supabase.rpc('create_financial_record_for_session', {
|
||||
p_tenant_id: tenantId,
|
||||
p_owner_id: rule.owner_id,
|
||||
p_patient_id: normalized.paciente_id ?? null,
|
||||
p_agenda_evento_id: createdEvent.id,
|
||||
p_amount: packagePrice,
|
||||
p_due_date: firstISO
|
||||
});
|
||||
if (cobErr) throw cobErr;
|
||||
|
||||
const paidNow = markPaidNow === true && paymentMethod !== 'link';
|
||||
const { data: recRow } = await supabase
|
||||
.from('financial_records')
|
||||
.select('id')
|
||||
.eq('agenda_evento_id', createdEvent.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.single();
|
||||
if (recRow?.id) {
|
||||
const patch = {
|
||||
updated_at: new Date().toISOString(),
|
||||
payment_method: paymentMethod === 'link' ? 'asaas' : paymentMethod
|
||||
};
|
||||
if (paidNow) {
|
||||
patch.status = 'paid';
|
||||
patch.paid_at = new Date().toISOString();
|
||||
}
|
||||
await supabase.from('financial_records').update(patch).eq('id', recRow.id);
|
||||
}
|
||||
|
||||
const methodLabel = {
|
||||
pix: 'PIX',
|
||||
dinheiro: 'dinheiro',
|
||||
deposito: 'depósito',
|
||||
cartao_maquininha: 'cartão (maquininha)'
|
||||
}[paymentMethod] || null;
|
||||
return {
|
||||
toast: {
|
||||
severity: 'success',
|
||||
summary: paidNow ? 'Pacote pago' : 'Pacote criado',
|
||||
detail: paidNow
|
||||
? `${n} sessões — ${_BRL(packagePrice)} recebido via ${methodLabel}.`
|
||||
: `${n} sessões — total ${_BRL(packagePrice)} com vencimento em ${firstISO.split('-').reverse().join('/')}.`,
|
||||
life: 4000
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
toast: {
|
||||
severity: 'warn',
|
||||
summary: 'Pacote não gerado',
|
||||
detail: e?.message || 'Falha ao criar contrato. Você pode gerar manualmente pelo Financeiro.',
|
||||
life: 5000
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* chargeMode='per_session': materializa todas as N ocorrências + 1 financial_record
|
||||
* por ocorrência. Falha parcial é tolerada (retorna toast warn).
|
||||
*/
|
||||
export async function materializeAndChargePerSession({ supabase, rule, normalized, recorrencia, tenantId }) {
|
||||
const { n, perSessao } = computeSeriePrice(recorrencia);
|
||||
try {
|
||||
const exceptionDates = new Set((recorrencia.conflitos || []).map((c) => c.date));
|
||||
const dates = generateOccurrenceDates(rule, n + exceptionDates.size, exceptionDates).slice(0, n);
|
||||
|
||||
const durMin = rule.duration_min || 50;
|
||||
const [hh, mm] = String(rule.start_time || '00:00:00').slice(0, 5).split(':').map(Number);
|
||||
const rows = dates.map((iso) => {
|
||||
const startDt = new Date(`${iso}T00:00:00`);
|
||||
startDt.setHours(hh, mm, 0, 0);
|
||||
const endDt = new Date(startDt.getTime() + durMin * 60 * 1000);
|
||||
return {
|
||||
owner_id: rule.owner_id,
|
||||
tenant_id: tenantId,
|
||||
terapeuta_id: rule.therapist_id ?? null,
|
||||
recurrence_id: rule.id,
|
||||
recurrence_date: iso,
|
||||
tipo: 'sessao',
|
||||
status: 'agendado',
|
||||
titulo: normalized.titulo || 'Sessão',
|
||||
inicio_em: startDt.toISOString(),
|
||||
fim_em: endDt.toISOString(),
|
||||
patient_id: normalized.paciente_id,
|
||||
determined_commitment_id: normalized.determined_commitment_id ?? null,
|
||||
modalidade: rule.modalidade || normalized.modalidade || 'presencial',
|
||||
price: perSessao,
|
||||
visibility_scope: normalized.visibility_scope || 'public'
|
||||
};
|
||||
});
|
||||
|
||||
const { data: createdEvents, error: evErr } = await supabase.from('agenda_eventos').insert(rows).select('id, inicio_em');
|
||||
if (evErr) throw evErr;
|
||||
|
||||
let okCount = 0;
|
||||
let failCount = 0;
|
||||
for (const ev of createdEvents || []) {
|
||||
try {
|
||||
const dueDate = ev.inicio_em ? new Date(ev.inicio_em).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
|
||||
const { error: cobErr } = await supabase.rpc('create_financial_record_for_session', {
|
||||
p_tenant_id: tenantId,
|
||||
p_owner_id: rule.owner_id,
|
||||
p_patient_id: normalized.paciente_id ?? null,
|
||||
p_agenda_evento_id: ev.id,
|
||||
p_amount: perSessao,
|
||||
p_due_date: dueDate
|
||||
});
|
||||
if (cobErr) throw cobErr;
|
||||
okCount++;
|
||||
} catch {
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (failCount === 0) {
|
||||
return {
|
||||
toast: {
|
||||
severity: 'success',
|
||||
summary: `${okCount} cobranças geradas`,
|
||||
detail: `${_BRL(perSessao)} por sessão. Total: ${_BRL(perSessao * okCount)}.`,
|
||||
life: 4000
|
||||
}
|
||||
};
|
||||
}
|
||||
return {
|
||||
toast: {
|
||||
severity: 'warn',
|
||||
summary: 'Cobranças parcialmente geradas',
|
||||
detail: `${okCount} ok, ${failCount} falharam. Gere as faltantes manualmente pelo Financeiro.`,
|
||||
life: 6000
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
toast: {
|
||||
severity: 'warn',
|
||||
summary: 'Falha ao materializar série',
|
||||
detail: e?.message || 'Sessões podem ter sido criadas em parte. Confira no Financeiro.',
|
||||
life: 6000
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI — paleta de cores da agenda
|
||||
|--------------------------------------------------------------------------
|
||||
| Mapping (tipo, status, isOccurrence) → hex color. Usado pelo card do
|
||||
| FullCalendar (borderColor/backgroundColor) e popovers de evento.
|
||||
|
|
||||
| Status manda mais do que tipo: realizado/faltou/cancelado têm cores
|
||||
| dedicadas (emerald/red/slate) independentes do tipo.
|
||||
|
|
||||
| Extraído de useMelissaAgenda.js (Fase A) pra reuso em Rail/Clínica.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export function pickColor(tipo, status, isOccurrence) {
|
||||
const s = String(status || '').toLowerCase();
|
||||
if (s === 'realizado' || s === 'realizada') return '#10b981'; // emerald-500
|
||||
if (s === 'faltou') return '#ef4444'; // red-500
|
||||
if (s === 'cancelado' || s === 'cancelada') return '#94a3b8'; // slate-400
|
||||
|
||||
const t = String(tipo || '').toLowerCase();
|
||||
if (t === 'bloqueio') return '#64748b'; // slate-500
|
||||
if (t === 'supervisao' || t === 'supervisão') return '#a855f7'; // purple-500
|
||||
if (t === 'reuniao' || t === 'reunião') return '#0ea5e9'; // sky-500
|
||||
|
||||
// Sessão default — distingue virtual (violet-500) vs real (indigo-500)
|
||||
return isOccurrence ? '#8b5cf6' : '#6366f1';
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI — whitelist de campos do agenda_eventos
|
||||
|--------------------------------------------------------------------------
|
||||
| Whitelist canônica de campos aceitos na tabela agenda_eventos pra INSERT/
|
||||
| UPDATE via cliente. Filtra qualquer chave não-prevista (defesa contra bug
|
||||
| onde payload acidentalmente carrega field defaultado pelo banco — como
|
||||
| modalidade='presencial' do bug de 2026-05-16).
|
||||
|
|
||||
| Memoria: project_pickdbfields_whitelist.md — antes era inline em
|
||||
| useMelissaAgenda.js. Extraído na Fase A.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const ALLOWED_FIELDS = [
|
||||
'tenant_id', 'owner_id', 'terapeuta_id', 'patient_id',
|
||||
'tipo', 'status', 'titulo', 'observacoes', 'modalidade',
|
||||
'inicio_em', 'fim_em', 'visibility_scope',
|
||||
'mirror_of_event_id', 'mirror_source',
|
||||
'determined_commitment_id', 'titulo_custom', 'extra_fields',
|
||||
'recurrence_id', 'recurrence_date',
|
||||
'price', 'insurance_plan_id', 'insurance_guide_number',
|
||||
'insurance_value', 'insurance_plan_service_id'
|
||||
];
|
||||
|
||||
export function pickDbFields(obj) {
|
||||
const out = {};
|
||||
for (const k of ALLOWED_FIELDS) {
|
||||
if (obj[k] !== undefined) out[k] = obj[k];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export { ALLOWED_FIELDS };
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI — utils de tipo de evento (agenda)
|
||||
|--------------------------------------------------------------------------
|
||||
| Helpers puros pra classificar/normalizar tipo de evento. Extraídos de
|
||||
| useMelissaAgenda.js (Fase A da decomposição agenda) pra reuso em
|
||||
| Rail/Clínica + utility puro testável.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
export const EVENTO_TIPO = Object.freeze({ SESSAO: 'sessao', BLOQUEIO: 'bloqueio' });
|
||||
export const EVENTO_TIPOS_VALIDOS = new Set([EVENTO_TIPO.SESSAO, EVENTO_TIPO.BLOQUEIO]);
|
||||
|
||||
// Limite máximo de duração de sessão (regra de negócio Melissa). DB constraint
|
||||
// session_duration_min_chk permite 10–240; convencionamos 120 (2h) aqui pra
|
||||
// evitar slots gigantes acidentais. Futuro: ler de agenda_configuracoes se
|
||||
// max_session_duration_min for adicionado.
|
||||
export const MAX_SESSION_MINUTES = 120;
|
||||
|
||||
export function normalizeEventoTipo(t, fallback = EVENTO_TIPO.SESSAO) {
|
||||
const s = String(t || '').trim().toLowerCase();
|
||||
if (!s) return fallback;
|
||||
if (s.includes('sess')) return EVENTO_TIPO.SESSAO;
|
||||
if (s.includes('bloq') || s.includes('ocup')) return EVENTO_TIPO.BLOQUEIO;
|
||||
return EVENTO_TIPOS_VALIDOS.has(s) ? s : fallback;
|
||||
}
|
||||
|
||||
export function deriveEventoTipoForNewEvent(payload) {
|
||||
const vis = String(payload?.visibility_scope || '').toLowerCase();
|
||||
const title = String(payload?.titulo || '').toLowerCase();
|
||||
if (vis === 'busy_only' || title.includes('ocup')) return EVENTO_TIPO.BLOQUEIO;
|
||||
return EVENTO_TIPO.SESSAO;
|
||||
}
|
||||
|
||||
export function deriveTituloDefaultByTipo(tipo) {
|
||||
return normalizeEventoTipo(tipo, EVENTO_TIPO.SESSAO) === EVENTO_TIPO.BLOQUEIO ? 'Ocupado' : 'Sessão';
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI — utils de tempo/data (agenda)
|
||||
|--------------------------------------------------------------------------
|
||||
| Helpers puros pra manipulação de tempo na agenda. Extraídos de
|
||||
| useMelissaAgenda.js (Fase A) pra reuso em Rail/Clínica.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const UUID_RX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
export function isUuid(v) {
|
||||
return UUID_RX.test(String(v || ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Soma minutos a um time "HH:MM" e retorna "HH:MM:SS".
|
||||
* Tolerante a input vazio (default 09:00).
|
||||
*/
|
||||
export function addMinutesToTime(timeStr, minutes) {
|
||||
const [h, m] = String(timeStr || '09:00').split(':').map(Number);
|
||||
const total = h * 60 + m + Number(minutes || 0);
|
||||
const hh = Math.floor(total / 60);
|
||||
const mm = total % 60;
|
||||
return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}:00`;
|
||||
}
|
||||
|
||||
/**
|
||||
* ISO timestamp → hora decimal (ex: "2026-05-21T14:30:00Z" → 14.5).
|
||||
* Usa hora local (não UTC) — propósito de exibição no calendário.
|
||||
*/
|
||||
export function isoToDecimalHour(iso) {
|
||||
if (!iso) return 0;
|
||||
const d = new Date(iso);
|
||||
return d.getHours() + d.getMinutes() / 60;
|
||||
}
|
||||
|
||||
/**
|
||||
* Date object → "YYYY-MM-DD" (formato ISO date sem hora).
|
||||
*/
|
||||
export function dateToISO(d) {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${dd}`;
|
||||
}
|
||||
@@ -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']);
|
||||
@@ -13,12 +13,19 @@ import { ref, watch, computed } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useDocumentGenerate } from '../composables/useDocumentGenerate'
|
||||
import { useDocumentTemplates } from '../composables/useDocumentTemplates'
|
||||
import { loadGeneratedFromDocId } from '@/services/DocumentGenerate.service'
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
patientId: { type: String, default: null },
|
||||
patientName: { type: String, default: '' },
|
||||
agendaEventoId: { type: String, default: null }
|
||||
agendaEventoId: { type: String, default: null },
|
||||
// Modo edicao: ID de um documents.id existente. Quando setado, o dialog
|
||||
// busca o template_id + dados_preenchidos do document_generated vinculado,
|
||||
// pre-seleciona o template e popula as variaveis. Save vira UPDATE
|
||||
// in-place (preserva documents.id e audit). Doc sem registro generated
|
||||
// (uploaded direto) cai no flow normal de "select template".
|
||||
editingDocId: { type: String, default: null }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:visible', 'generated'])
|
||||
@@ -52,13 +59,48 @@ const {
|
||||
// ── Reset ao abrir ──────────────────────────────────────────
|
||||
|
||||
watch(() => props.visible, async (v) => {
|
||||
if (v) {
|
||||
step.value = 'select'
|
||||
reset()
|
||||
await Promise.all([
|
||||
fetchTemplates(),
|
||||
props.patientId ? loadVariables(props.patientId, props.agendaEventoId) : Promise.resolve()
|
||||
])
|
||||
if (!v) return;
|
||||
step.value = 'select'
|
||||
reset()
|
||||
await Promise.all([
|
||||
fetchTemplates(),
|
||||
props.patientId ? loadVariables(props.patientId, props.agendaEventoId) : Promise.resolve()
|
||||
])
|
||||
|
||||
// Modo edicao: tenta carregar o registro do generated, pre-seleciona
|
||||
// template e popula vars com dados_preenchidos (sobrescreve auto-vars
|
||||
// — preserva customizacao anterior do user). Se nao houver linkage
|
||||
// (doc uploaded direto), continua no flow normal de "select template".
|
||||
if (props.editingDocId) {
|
||||
const gen = await loadGeneratedFromDocId(props.editingDocId)
|
||||
if (gen?.template_id) {
|
||||
try {
|
||||
await selectTemplate(gen.template_id)
|
||||
// Merge: dados_preenchidos override auto-loaded variables.
|
||||
// Mantemos as vars que o user nao tinha customizado da vez
|
||||
// anterior (pra caso o template tenha vars novas adicionadas
|
||||
// depois) — pegamos as keys auto + sobrescreve com generated.
|
||||
const saved = gen.dados_preenchidos || {}
|
||||
Object.entries(saved).forEach(([k, val]) => {
|
||||
setVariable(k, val == null ? '' : String(val))
|
||||
})
|
||||
step.value = 'edit'
|
||||
} catch (e) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Template original não encontrado',
|
||||
detail: 'Selecione um template para regenerar o documento.',
|
||||
life: 3500
|
||||
})
|
||||
}
|
||||
} else {
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: 'Documento legado',
|
||||
detail: 'Sem dados de edição. Selecione um template para regenerar.',
|
||||
life: 3500
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -79,11 +121,19 @@ const editableVars = computed(() => {
|
||||
key,
|
||||
label: meta?.label || key,
|
||||
grupo: meta?.grupo || 'Outros',
|
||||
source: meta?.source || '',
|
||||
value: variables.value[key] || ''
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Estatística pro topo: quantos campos vieram do auto-fill vs vazios
|
||||
const varStats = computed(() => {
|
||||
const total = editableVars.value.length
|
||||
const filled = editableVars.value.filter(v => String(variables.value[v.key] || '').trim() !== '').length
|
||||
return { total, filled, empty: total - filled }
|
||||
})
|
||||
|
||||
const varGroups = computed(() => {
|
||||
const groups = {}
|
||||
for (const v of editableVars.value) {
|
||||
@@ -101,8 +151,15 @@ function onVarChange(key, val) {
|
||||
|
||||
async function onGenerate() {
|
||||
try {
|
||||
const result = await generateAndSave(props.patientId)
|
||||
toast.add({ severity: 'success', summary: 'Documento salvo', detail: 'Disponível nos documentos do paciente.', life: 3000 })
|
||||
const result = await generateAndSave(props.patientId, props.editingDocId || null)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: props.editingDocId ? 'Documento atualizado' : 'Documento salvo',
|
||||
detail: props.editingDocId
|
||||
? 'PDF substituído com os novos valores.'
|
||||
: 'Disponível nos documentos do paciente.',
|
||||
life: 3000
|
||||
})
|
||||
emit('generated', result)
|
||||
close()
|
||||
} catch (e) {
|
||||
@@ -145,10 +202,10 @@ function close() {
|
||||
<template #header>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="flex items-center justify-center w-8 h-8 rounded-lg bg-green-500/10">
|
||||
<i class="pi pi-file-pdf text-green-600" />
|
||||
<i :class="editingDocId ? 'pi pi-pencil text-amber-600' : 'pi pi-file-pdf text-green-600'" />
|
||||
</span>
|
||||
<div>
|
||||
<div class="text-base font-semibold">Gerar documento</div>
|
||||
<div class="text-base font-semibold">{{ editingDocId ? 'Editar documento' : 'Gerar documento' }}</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">
|
||||
<template v-if="step === 'select'">Selecione um template</template>
|
||||
<template v-else-if="step === 'edit'">{{ selectedTemplate?.nome_template }} — {{ patientName }}</template>
|
||||
@@ -192,17 +249,54 @@ function close() {
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Editar variaveis -->
|
||||
<div v-else-if="step === 'edit'" class="flex flex-col gap-4">
|
||||
<div v-else-if="step === 'edit'" class="flex flex-col gap-5">
|
||||
<!-- Resumo do preenchimento automático -->
|
||||
<div
|
||||
v-if="varStats.total"
|
||||
class="flex items-center gap-2 text-xs px-3 py-2 rounded-lg"
|
||||
:class="varStats.empty === 0
|
||||
? 'bg-green-500/10 text-green-700 dark:text-green-400'
|
||||
: 'bg-amber-500/10 text-amber-700 dark:text-amber-400'"
|
||||
>
|
||||
<i :class="varStats.empty === 0 ? 'pi pi-check-circle' : 'pi pi-info-circle'" />
|
||||
<span v-if="varStats.empty === 0">
|
||||
Todos os {{ varStats.total }} campos foram preenchidos automaticamente.
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ varStats.filled }} de {{ varStats.total }} preenchidos. Os campos vazios mostram onde cadastrar o dado.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Erro de carregamento de variáveis -->
|
||||
<div
|
||||
v-if="genError"
|
||||
class="flex items-center gap-2 text-xs px-3 py-2 rounded-lg bg-red-500/10 text-red-600"
|
||||
>
|
||||
<i class="pi pi-exclamation-circle" />
|
||||
<span>{{ genError }}</span>
|
||||
</div>
|
||||
|
||||
<div v-for="(vars, grupo) in varGroups" :key="grupo">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-2">{{ grupo }}</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-3">{{ grupo }}</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div v-for="v in vars" :key="v.key" class="flex flex-col gap-1">
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">{{ v.label }}</label>
|
||||
<InputText
|
||||
:modelValue="variables[v.key] || ''"
|
||||
@update:modelValue="onVarChange(v.key, $event)"
|
||||
class="w-full"
|
||||
/>
|
||||
<FloatLabel variant="on">
|
||||
<InputText
|
||||
:id="`docgen-var-${v.key}`"
|
||||
:modelValue="variables[v.key] || ''"
|
||||
@update:modelValue="onVarChange(v.key, $event)"
|
||||
class="w-full"
|
||||
:invalid="!String(variables[v.key] || '').trim()"
|
||||
/>
|
||||
<label :for="`docgen-var-${v.key}`">{{ v.label }}</label>
|
||||
</FloatLabel>
|
||||
<small
|
||||
v-if="!String(variables[v.key] || '').trim() && v.source"
|
||||
class="text-[0.65rem] text-[var(--text-color-secondary)] flex items-center gap-1 ml-1"
|
||||
>
|
||||
<i class="pi pi-link text-[0.55rem]" />
|
||||
{{ v.source }}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -254,7 +348,7 @@ function close() {
|
||||
/>
|
||||
<Button
|
||||
v-if="step === 'preview'"
|
||||
label="Salvar documento"
|
||||
:label="editingDocId ? 'Substituir documento' : 'Salvar documento'"
|
||||
icon="pi pi-check"
|
||||
@click="onGenerate"
|
||||
:loading="generating"
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
listSignatures,
|
||||
getSignatureStatus
|
||||
} from '@/services/DocumentSignatures.service'
|
||||
import { createShareLink, buildShareUrl } from '@/services/DocumentShareLinks.service'
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
@@ -42,6 +43,11 @@ const TIPOS_SIGNATARIO = [
|
||||
const signatarios = ref([])
|
||||
const patientEmails = ref([])
|
||||
|
||||
// Geracao de share link p/ assinatura via portal/whatsapp
|
||||
const generateLink = ref(true)
|
||||
const linkExpiracaoHoras = ref(168) // 7 dias default
|
||||
const generatedShareUrl = ref('')
|
||||
|
||||
function addSignatario() {
|
||||
signatarios.value.push({ tipo: 'paciente', nome: '', email: '' })
|
||||
}
|
||||
@@ -81,6 +87,7 @@ function useEmail(email) {
|
||||
watch(() => props.visible, async (v) => {
|
||||
if (v && props.doc) {
|
||||
signatarios.value = []
|
||||
generatedShareUrl.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
const [sigs, status] = await Promise.all([
|
||||
@@ -99,6 +106,13 @@ watch(() => props.visible, async (v) => {
|
||||
}
|
||||
})
|
||||
|
||||
function copyShareUrl() {
|
||||
if (!generatedShareUrl.value) return
|
||||
navigator.clipboard.writeText(generatedShareUrl.value)
|
||||
.then(() => toast.add({ severity: 'success', summary: 'Link copiado', life: 1800 }))
|
||||
.catch(() => toast.add({ severity: 'warn', summary: 'Falha ao copiar', detail: 'Copie manualmente.', life: 2200 }))
|
||||
}
|
||||
|
||||
// ── Status badge ────────────────────────────────────────────
|
||||
|
||||
const statusColor = computed(() => {
|
||||
@@ -146,9 +160,39 @@ async function submit() {
|
||||
saving.value = true
|
||||
try {
|
||||
const result = await createSignatureRequests(props.doc.id, signatarios.value)
|
||||
toast.add({ severity: 'success', summary: 'Solicitação enviada', detail: `${result.length} signatário(s) adicionado(s).`, life: 3000 })
|
||||
emit('requested', result)
|
||||
emit('update:visible', false)
|
||||
|
||||
// Gera share link público quando habilitado — o paciente abre /shared/document/:token
|
||||
// e assina via fluxo público (RPC sign_document_by_token captura IP/UA server-side).
|
||||
if (generateLink.value) {
|
||||
try {
|
||||
const link = await createShareLink(props.doc.id, {
|
||||
expiracaoHoras: Number(linkExpiracaoHoras.value) || 168,
|
||||
usosMax: Math.max(signatarios.value.length * 3, 5)
|
||||
})
|
||||
generatedShareUrl.value = buildShareUrl(link.token)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Solicitação criada',
|
||||
detail: `${result.length} signatário(s). Link de assinatura gerado.`,
|
||||
life: 3500
|
||||
})
|
||||
} catch (linkErr) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Signatários criados, mas falhou o link',
|
||||
detail: linkErr?.message || 'Tente gerar o link na ação "Compartilhar".',
|
||||
life: 4500
|
||||
})
|
||||
}
|
||||
} else {
|
||||
toast.add({ severity: 'success', summary: 'Solicitação enviada', detail: `${result.length} signatário(s) adicionado(s).`, life: 3000 })
|
||||
}
|
||||
|
||||
emit('requested', { signatures: result, shareUrl: generatedShareUrl.value })
|
||||
|
||||
// Mantém dialog aberto se gerou link — pra terapeuta copiar.
|
||||
// Fecha automaticamente se não gerou link.
|
||||
if (!generatedShareUrl.value) emit('update:visible', false)
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao solicitar assinatura.' })
|
||||
} finally {
|
||||
@@ -263,6 +307,49 @@ function close() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toggle: gerar link público -->
|
||||
<div class="flex items-start gap-3 p-3 rounded-lg border border-blue-200 bg-blue-50/40">
|
||||
<Checkbox v-model="generateLink" :binary="true" inputId="cb-gen-link" class="mt-0.5" />
|
||||
<div class="flex-1">
|
||||
<label for="cb-gen-link" class="text-sm font-medium text-[var(--text-color)] cursor-pointer">
|
||||
Gerar link público para assinatura
|
||||
</label>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5">
|
||||
Cria um link em <code>/shared/document/<token></code> pra enviar via WhatsApp, e-mail ou copiar. O paciente assina sem precisar logar (IP, navegador e timestamp são registrados server-side).
|
||||
</div>
|
||||
<div v-if="generateLink" class="mt-2 flex items-center gap-2">
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">Validade:</label>
|
||||
<Select
|
||||
v-model="linkExpiracaoHoras"
|
||||
:options="[
|
||||
{ value: 24, label: '24 horas' },
|
||||
{ value: 72, label: '3 dias' },
|
||||
{ value: 168, label: '7 dias' },
|
||||
{ value: 720, label: '30 dias' }
|
||||
]"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
class="!text-xs w-32"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Link gerado (após submit) -->
|
||||
<div v-if="generatedShareUrl" class="p-3 rounded-lg border border-emerald-200 bg-emerald-50/40">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<i class="pi pi-link text-emerald-600" />
|
||||
<div class="text-sm font-medium text-emerald-800">Link de assinatura gerado</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<InputText :modelValue="generatedShareUrl" readonly class="w-full !text-xs" />
|
||||
<Button icon="pi pi-copy" size="small" class="!h-8 shrink-0" v-tooltip.top="'Copiar link'" @click="copyShareUrl" />
|
||||
</div>
|
||||
<div class="text-[0.7rem] text-[var(--text-color-secondary)] mt-1.5">
|
||||
Envie este link para o(a) paciente. Eles podem assinar diretamente sem precisar criar conta.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Emails cadastrados do paciente -->
|
||||
<div class="p-3 rounded-lg border border-[var(--surface-border)] bg-[var(--surface-ground)]">
|
||||
<div class="text-xs font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-2">E-mails do paciente</div>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useDocumentTemplates } from '../composables/useDocumentTemplates'
|
||||
import JoditEmailEditor from '@/components/ui/JoditEmailEditor.vue'
|
||||
|
||||
@@ -23,6 +23,8 @@ const emit = defineEmits(['update:modelValue', 'save', 'cancel'])
|
||||
const { TIPOS_TEMPLATE, TEMPLATE_VARIABLES, variablesGrouped, previewHtml } = useDocumentTemplates()
|
||||
|
||||
const activeTab = ref('editor') // editor | preview
|
||||
// Sub-tab do editor (centro do layout 3-col): qual seção renderiza
|
||||
const editorTab = ref('corpo') // cabecalho | corpo | rodape
|
||||
|
||||
// ── Form reativo synced com modelValue ──────────────────────
|
||||
|
||||
@@ -70,13 +72,43 @@ function insertVariable(varKey) {
|
||||
rodape_html: editorRodape
|
||||
}
|
||||
const editorRef = editorMap[cursorField.value]
|
||||
|
||||
// No mobile: fecha drawer + defere insertHTML pós-transição.
|
||||
// Restaura a selection capturada quando o drawer abriu (cursor
|
||||
// original do usuário) antes de inserir → variável aparece no
|
||||
// ponto certo do texto, não no final.
|
||||
if (isMobile.value) {
|
||||
drawerOpen.value = false;
|
||||
const markers = savedSelection.value;
|
||||
setTimeout(() => {
|
||||
try {
|
||||
if (markers && editorRef?.value?.restoreSelection) {
|
||||
editorRef.value.restoreSelection(markers);
|
||||
}
|
||||
if (editorRef?.value?.insertHTML) {
|
||||
editorRef.value.insertHTML(tag);
|
||||
} else {
|
||||
// Fallback se a API expose falhar
|
||||
form.value[cursorField.value] = (form.value[cursorField.value] || '') + tag;
|
||||
}
|
||||
} catch {
|
||||
form.value[cursorField.value] = (form.value[cursorField.value] || '') + tag;
|
||||
}
|
||||
if (!form.value.variaveis.includes(varKey)) {
|
||||
form.value.variaveis = [...form.value.variaveis, varKey];
|
||||
}
|
||||
savedSelection.value = null;
|
||||
}, 280);
|
||||
return;
|
||||
}
|
||||
|
||||
// Desktop: insertHTML mantém posição do cursor (foco já tá no editor)
|
||||
if (editorRef?.value?.insertHTML) {
|
||||
editorRef.value.insertHTML(tag)
|
||||
} else {
|
||||
form.value[cursorField.value] = (form.value[cursorField.value] || '') + tag
|
||||
}
|
||||
|
||||
// Adiciona a variavel na lista se nao estiver
|
||||
if (!form.value.variaveis.includes(varKey)) {
|
||||
form.value.variaveis = [...form.value.variaveis, varKey]
|
||||
}
|
||||
@@ -87,129 +119,756 @@ function insertVariable(varKey) {
|
||||
function onSave() {
|
||||
emit('save', { ...form.value })
|
||||
}
|
||||
|
||||
// ── Mobile drawer (espelha padrão MelissaBloqueios/Templates) ─
|
||||
// No mobile, form (col 1) + variáveis (col 3) viram tabs dentro
|
||||
// de um drawer único. Só o editor (col 2) fica visível na tela.
|
||||
const drawerOpen = ref(false);
|
||||
const drawerTab = ref('form'); // form | vars
|
||||
const isMobile = ref(false);
|
||||
let _mqMobile = null;
|
||||
function _onMqMobileChange(e) {
|
||||
isMobile.value = e.matches;
|
||||
if (!e.matches) drawerOpen.value = false;
|
||||
}
|
||||
// Selection salva do editor ativo no momento de abrir o drawer de
|
||||
// variáveis. Permite inserir na posição original do cursor mesmo
|
||||
// depois do user navegar pelo drawer/perder foco.
|
||||
const savedSelection = ref(null);
|
||||
|
||||
function openDrawer(tab) {
|
||||
drawerTab.value = tab || 'form';
|
||||
// Quando abre "Variáveis", salva selection do editor ativo agora
|
||||
// (cursor original do usuário) pra restaurar depois da inserção.
|
||||
if (tab === 'vars') {
|
||||
const editorMap = {
|
||||
cabecalho_html: editorCabecalho,
|
||||
corpo_html: editorCorpo,
|
||||
rodape_html: editorRodape
|
||||
};
|
||||
const editorRef = editorMap[cursorField.value];
|
||||
savedSelection.value = editorRef?.value?.saveSelection?.() || null;
|
||||
} else {
|
||||
savedSelection.value = null;
|
||||
}
|
||||
drawerOpen.value = true;
|
||||
}
|
||||
function fecharDrawer() {
|
||||
drawerOpen.value = false;
|
||||
savedSelection.value = null;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof window !== 'undefined' && window.matchMedia) {
|
||||
_mqMobile = window.matchMedia('(max-width: 1023px)');
|
||||
isMobile.value = _mqMobile.matches;
|
||||
try { _mqMobile.addEventListener('change', _onMqMobileChange); }
|
||||
catch { _mqMobile.addListener(_onMqMobileChange); }
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (_mqMobile) {
|
||||
try { _mqMobile.removeEventListener('change', _onMqMobileChange); }
|
||||
catch { _mqMobile.removeListener(_onMqMobileChange); }
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ═══════ Page chrome (preenche o espaço do container pai) ═══════ */
|
||||
.dte-page {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
/* padding pra não grudar nas bordas do container pai (mdt-body) */
|
||||
padding: 12px;
|
||||
/* fallback pra quando o pai não é flex */
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dte-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dte-toolbar__title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
.dte-toolbar__title > i {
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.dte-toolbar__tabs {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ═══════ 3-col grid (form / editor / variáveis) ═══════ */
|
||||
.dte-cols {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 280px) minmax(0, 1fr) minmax(220px, 260px);
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
/* COL 1 — Form metadados */
|
||||
.dte-side {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dte-side__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dte-side__head > i {
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.dte-side__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.dte-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.dte-field label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
/* COL 2 — Editor com sub-tabs */
|
||||
.dte-main {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dte-main__tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
background: var(--surface-ground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dte-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 18px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: color 140ms ease, border-color 140ms ease, background-color 140ms ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
.dte-tab:hover {
|
||||
color: var(--text-color);
|
||||
background: color-mix(in srgb, var(--p-primary-color) 4%, transparent);
|
||||
}
|
||||
.dte-tab.is-active {
|
||||
color: var(--p-primary-color);
|
||||
border-bottom-color: var(--p-primary-color);
|
||||
background: color-mix(in srgb, var(--p-primary-color) 6%, transparent);
|
||||
}
|
||||
.dte-tab > i { font-size: 0.82rem; }
|
||||
|
||||
.dte-main__editor {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 14px;
|
||||
background: var(--surface-card);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.dte-editor-wrap {
|
||||
flex: 1;
|
||||
min-height: 450px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.dte-editor-wrap > * {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Força o Jodit interno a expandir 100% da altura disponível
|
||||
(substitui o height: minHeight em pixels que o JoditEmailEditor seta) */
|
||||
.dte-editor-wrap :deep(.jodit-container) {
|
||||
flex: 1 !important;
|
||||
height: 100% !important;
|
||||
min-height: 450px !important;
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
}
|
||||
.dte-editor-wrap :deep(.jodit-workplace) {
|
||||
flex: 1 !important;
|
||||
min-height: 0 !important;
|
||||
}
|
||||
.dte-editor-wrap :deep(.jodit-wysiwyg) {
|
||||
min-height: 100% !important;
|
||||
}
|
||||
|
||||
/* COL 3 — Variáveis */
|
||||
.dte-vars {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dte-vars__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 14px 8px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dte-vars__head > i {
|
||||
color: var(--p-primary-color);
|
||||
}
|
||||
.dte-vars__hint {
|
||||
margin: 0 14px 6px;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-color-secondary);
|
||||
line-height: 1.4;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dte-vars__hint strong {
|
||||
color: var(--text-color);
|
||||
}
|
||||
.dte-vars__list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 4px 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.dte-vars__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.dte-vars__group-title {
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.75;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.dte-vars__group-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.dte-vars__btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--text-color);
|
||||
font-size: 0.74rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease, color 120ms ease;
|
||||
font-family: inherit;
|
||||
}
|
||||
.dte-vars__btn:hover {
|
||||
background: color-mix(in srgb, var(--p-primary-color) 10%, transparent);
|
||||
color: var(--p-primary-color);
|
||||
}
|
||||
.dte-vars__btn-brace {
|
||||
font-family: 'JetBrains Mono', 'Consolas', monospace;
|
||||
font-size: 0.66rem;
|
||||
color: var(--p-primary-color);
|
||||
opacity: 0.6;
|
||||
}
|
||||
.dte-vars__btn-label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ═══════ Preview ═══════ */
|
||||
/* Container externo: scroll vertical interno + fundo sutil.
|
||||
NÃO usa flex (que limitava a altura intrínseca do doc) — usa
|
||||
block normal com o doc centralizado via margin auto. */
|
||||
.dte-preview {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
background: var(--surface-ground);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 10px;
|
||||
padding: 24px;
|
||||
}
|
||||
.dte-preview__doc {
|
||||
background: white;
|
||||
color: #1a1a1a;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
||||
width: 100%;
|
||||
max-width: 794px; /* ≈ A4 a 96dpi */
|
||||
margin: 0 auto;
|
||||
padding: 48px 56px;
|
||||
font-family: 'Segoe UI', Arial, sans-serif;
|
||||
font-size: 12pt;
|
||||
line-height: 1.6;
|
||||
min-height: 500px;
|
||||
/* Garante que o background-white cresce com o conteúdo
|
||||
(em vez de ficar travado no min-height quando o doc é grande) */
|
||||
box-sizing: border-box;
|
||||
height: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
.dte-preview__header {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #ccc;
|
||||
}
|
||||
.dte-preview__body {
|
||||
min-height: 300px;
|
||||
}
|
||||
.dte-preview__footer {
|
||||
margin-top: 32px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #ccc;
|
||||
text-align: center;
|
||||
font-size: 10pt;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
/* ═══════ Toolbar mobile actions (botões "Identificação" / "Variáveis") ═══════ */
|
||||
.dte-toolbar__mobile-actions {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.dte-mobile-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-ground);
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
font-family: inherit;
|
||||
transition: background-color 120ms ease;
|
||||
}
|
||||
.dte-mobile-btn:hover { background: color-mix(in srgb, var(--p-primary-color) 8%, transparent); }
|
||||
.dte-mobile-btn > i { color: var(--p-primary-color); font-size: 0.82rem; }
|
||||
|
||||
/* ═══════ Mobile drawer (form + variáveis em tabs) ═══════ */
|
||||
.dte-mobile-drawer {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
width: min(360px, 92vw);
|
||||
z-index: 80;
|
||||
background: var(--surface-card);
|
||||
border-right: 1px solid var(--surface-border);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
color: var(--text-color);
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.dte-mobile-drawer.is-open { transform: translateX(0); }
|
||||
/* Durante a transição de saída, drawer ignora eventos pra não capturar
|
||||
touch/click "perdidos" e prevenir trava no Jodit. */
|
||||
.dte-mobile-drawer:not(.is-open) { pointer-events: none; }
|
||||
|
||||
.dte-mobile-drawer__tabs {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
background: var(--surface-ground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dte-drawer-tab {
|
||||
flex: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 12px 14px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: color 140ms ease, border-color 140ms ease, background-color 140ms ease;
|
||||
}
|
||||
.dte-drawer-tab:hover {
|
||||
color: var(--text-color);
|
||||
background: color-mix(in srgb, var(--p-primary-color) 4%, transparent);
|
||||
}
|
||||
.dte-drawer-tab.is-active {
|
||||
color: var(--p-primary-color);
|
||||
border-bottom-color: var(--p-primary-color);
|
||||
}
|
||||
.dte-drawer-tab > i { font-size: 0.82rem; }
|
||||
|
||||
.dte-drawer-close {
|
||||
width: 44px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-color-secondary);
|
||||
cursor: pointer;
|
||||
border-left: 1px solid var(--surface-border);
|
||||
transition: background-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
.dte-drawer-close:hover {
|
||||
background: var(--surface-ground);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.dte-mobile-drawer__pane {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.dte-mobile-drawer__pane > .dte-side,
|
||||
.dte-mobile-drawer__pane > .dte-vars {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dte-mobile-drawer__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
z-index: 79;
|
||||
}
|
||||
.dte-drawer-fade-enter-active,
|
||||
.dte-drawer-fade-leave-active { transition: opacity 200ms ease; }
|
||||
.dte-drawer-fade-enter-from,
|
||||
.dte-drawer-fade-leave-to { opacity: 0; }
|
||||
|
||||
/* ═══════ Mobile (<1024px): só o editor visível ═══════ */
|
||||
@media (max-width: 1023px) {
|
||||
/* Editor ocupa tela inteira — col 1 e col 3 viram drawer */
|
||||
.dte-cols {
|
||||
grid-template-columns: 1fr;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dte-cols > .dte-side,
|
||||
.dte-cols > .dte-vars { display: none; }
|
||||
|
||||
/* Mostra os botões "Identificação" / "Variáveis" no header */
|
||||
.dte-toolbar__mobile-actions { display: inline-flex; }
|
||||
|
||||
/* Esconde o título canônico no mobile (espaço pros botões) */
|
||||
.dte-toolbar__title > span { display: none; }
|
||||
.dte-toolbar__title > i { display: none; }
|
||||
|
||||
.dte-preview {
|
||||
padding: 12px;
|
||||
}
|
||||
.dte-preview__doc {
|
||||
padding: 24px 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3 xl:gap-4">
|
||||
<!-- ══ Card: Identificação ══════════════════════════════ -->
|
||||
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
|
||||
<div class="flex items-center gap-2 px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
|
||||
<i class="pi pi-tag text-[var(--text-color-secondary)] opacity-60" />
|
||||
<span class="font-semibold text-sm">Identificação</span>
|
||||
<!-- ══ Mobile drawer (form + variáveis em tabs) ════════════ -->
|
||||
<Transition name="dte-drawer-fade">
|
||||
<div
|
||||
v-show="isMobile && drawerOpen"
|
||||
class="dte-mobile-drawer"
|
||||
:class="{ 'is-open': drawerOpen }"
|
||||
>
|
||||
<div class="dte-mobile-drawer__tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="dte-drawer-tab"
|
||||
:class="{ 'is-active': drawerTab === 'form' }"
|
||||
@click="drawerTab = 'form'"
|
||||
>
|
||||
<i class="pi pi-tag" />
|
||||
<span>Identificação</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="dte-drawer-tab"
|
||||
:class="{ 'is-active': drawerTab === 'vars' }"
|
||||
@click="drawerTab = 'vars'"
|
||||
>
|
||||
<i class="pi pi-code" />
|
||||
<span>Variáveis</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="dte-drawer-close"
|
||||
v-tooltip.bottom="'Fechar'"
|
||||
@click="fecharDrawer"
|
||||
>
|
||||
<i class="pi pi-times" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-4 flex flex-col gap-3">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-[1fr_220px] gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">Nome do template</label>
|
||||
<div id="dte-mobile-drawer-form" v-show="drawerTab === 'form'" class="dte-mobile-drawer__pane" />
|
||||
<div id="dte-mobile-drawer-vars" v-show="drawerTab === 'vars'" class="dte-mobile-drawer__pane" />
|
||||
</div>
|
||||
</Transition>
|
||||
<Transition name="dte-drawer-fade">
|
||||
<div
|
||||
v-show="isMobile && drawerOpen"
|
||||
class="dte-mobile-drawer__backdrop"
|
||||
@click="fecharDrawer"
|
||||
/>
|
||||
</Transition>
|
||||
|
||||
<div class="dte-page">
|
||||
<!-- ══ Toggle Editor / Preview no topo ══════════════════ -->
|
||||
<div class="dte-toolbar">
|
||||
<!-- Botões "Identificação" e "Variáveis" — mobile-only -->
|
||||
<div class="dte-toolbar__mobile-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="dte-mobile-btn"
|
||||
v-tooltip.bottom="'Identificação do template'"
|
||||
@click="openDrawer('form')"
|
||||
>
|
||||
<i class="pi pi-tag" />
|
||||
<span>Identificação</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="dte-mobile-btn"
|
||||
v-tooltip.bottom="'Inserir variáveis'"
|
||||
@click="openDrawer('vars')"
|
||||
>
|
||||
<i class="pi pi-code" />
|
||||
<span>Variáveis</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="dte-toolbar__title">
|
||||
<i class="pi pi-file-edit" />
|
||||
<span>Conteúdo do documento</span>
|
||||
</div>
|
||||
<div class="dte-toolbar__tabs">
|
||||
<Button
|
||||
label="Editor"
|
||||
icon="pi pi-pencil"
|
||||
:severity="activeTab === 'editor' ? undefined : 'secondary'"
|
||||
:outlined="activeTab !== 'editor'"
|
||||
size="small"
|
||||
class="rounded-full"
|
||||
@click="activeTab = 'editor'"
|
||||
/>
|
||||
<Button
|
||||
label="Preview"
|
||||
icon="pi pi-eye"
|
||||
:severity="activeTab === 'preview' ? undefined : 'secondary'"
|
||||
:outlined="activeTab !== 'preview'"
|
||||
size="small"
|
||||
class="rounded-full"
|
||||
@click="activeTab = 'preview'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ EDITOR — 3 colunas (form / editor / variáveis) ══ -->
|
||||
<div v-show="activeTab === 'editor'" class="dte-cols">
|
||||
<!-- ─── COL 1 (esquerda): Form de metadados — teleporta pro drawer no mobile ─── -->
|
||||
<Teleport to="#dte-mobile-drawer-form" :disabled="!isMobile">
|
||||
<aside class="dte-side">
|
||||
<div class="dte-side__head">
|
||||
<i class="pi pi-tag" />
|
||||
<span>Identificação</span>
|
||||
</div>
|
||||
<div class="dte-side__body">
|
||||
<div class="dte-field">
|
||||
<label>Nome do template</label>
|
||||
<InputText v-model="form.nome_template" placeholder="Ex: Declaração de comparecimento" class="w-full" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">Tipo</label>
|
||||
<div class="dte-field">
|
||||
<label>Tipo</label>
|
||||
<Select v-model="form.tipo" :options="TIPOS_TEMPLATE" optionLabel="label" optionValue="value" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">Descrição</label>
|
||||
<InputText v-model="form.descricao" placeholder="Breve descrição do template" class="w-full" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">URL do logo (opcional)</label>
|
||||
<InputText v-model="form.logo_url" placeholder="https://..." class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ Card: Conteúdo ═══════════════════════════════════ -->
|
||||
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
|
||||
<div class="flex items-center justify-between gap-2 px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<i class="pi pi-file-edit text-[var(--text-color-secondary)] opacity-60" />
|
||||
<span class="font-semibold text-sm">Conteúdo do documento</span>
|
||||
</div>
|
||||
<!-- Tabs: Editor / Preview -->
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
:label="'Editor'"
|
||||
icon="pi pi-pencil"
|
||||
:severity="activeTab === 'editor' ? undefined : 'secondary'"
|
||||
:outlined="activeTab !== 'editor'"
|
||||
size="small"
|
||||
class="rounded-full"
|
||||
@click="activeTab = 'editor'"
|
||||
/>
|
||||
<Button
|
||||
:label="'Preview'"
|
||||
icon="pi pi-eye"
|
||||
:severity="activeTab === 'preview' ? undefined : 'secondary'"
|
||||
:outlined="activeTab !== 'preview'"
|
||||
size="small"
|
||||
class="rounded-full"
|
||||
@click="activeTab = 'preview'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor -->
|
||||
<div v-show="activeTab === 'editor'" class="p-4 flex flex-col lg:flex-row gap-4">
|
||||
<!-- Campos HTML -->
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-1" @focusin="cursorField = 'cabecalho_html'">
|
||||
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">Cabeçalho</label>
|
||||
<JoditEmailEditor ref="editorCabecalho" v-model="form.cabecalho_html" :minHeight="120" layoutButtons :logoUrl="form.logo_url" />
|
||||
<div class="dte-field">
|
||||
<label>Descrição</label>
|
||||
<Textarea v-model="form.descricao" placeholder="Breve descrição do template" class="w-full" rows="3" autoResize />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1" @focusin="cursorField = 'corpo_html'">
|
||||
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">Corpo do documento</label>
|
||||
<JoditEmailEditor ref="editorCorpo" v-model="form.corpo_html" :minHeight="350" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1" @focusin="cursorField = 'rodape_html'">
|
||||
<label class="text-xs font-semibold text-[var(--text-color-secondary)]">Rodapé</label>
|
||||
<JoditEmailEditor ref="editorRodape" v-model="form.rodape_html" :minHeight="120" layoutButtons :logoUrl="form.logo_url" />
|
||||
<div class="dte-field">
|
||||
<label>URL do logo (opcional)</label>
|
||||
<InputText v-model="form.logo_url" placeholder="https://..." class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</Teleport>
|
||||
|
||||
<!-- Painel de variáveis -->
|
||||
<div class="w-full lg:w-[240px] shrink-0">
|
||||
<div class="lg:sticky lg:top-3 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)]/50 overflow-hidden">
|
||||
<div class="flex items-center gap-2 px-3 py-2 border-b border-[var(--surface-border,#e2e8f0)]">
|
||||
<i class="pi pi-code text-[var(--text-color-secondary)] opacity-60 text-xs" />
|
||||
<span class="font-semibold text-xs">Variáveis</span>
|
||||
</div>
|
||||
<div class="px-3 pt-2 text-[0.68rem] text-[var(--text-color-secondary)] opacity-75 italic">Clique para inserir no campo ativo</div>
|
||||
<div class="flex flex-col gap-3 p-3 max-h-[500px] overflow-y-auto">
|
||||
<div v-for="(vars, grupo) in variablesGrouped" :key="grupo">
|
||||
<div class="text-[0.62rem] font-semibold uppercase tracking-wider text-[var(--text-color-secondary)] mb-1">{{ grupo }}</div>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<button
|
||||
v-for="v in vars"
|
||||
:key="v.key"
|
||||
class="text-left text-xs px-2 py-1 rounded-md bg-transparent border-none hover:bg-[var(--primary-color,#6366f1)]/10 hover:text-[var(--primary-color,#6366f1)] transition-colors truncate cursor-pointer"
|
||||
:title="v.key"
|
||||
@click="insertVariable(v.key)"
|
||||
>
|
||||
<span class="font-mono text-[0.62rem] opacity-60">{{</span>
|
||||
{{ v.label }}
|
||||
<span class="font-mono text-[0.62rem] opacity-60">}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- ─── COL 2 (centro): Tabs Cabeçalho/Corpo/Rodapé + editor ─── -->
|
||||
<main class="dte-main">
|
||||
<div class="dte-main__tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="dte-tab"
|
||||
:class="{ 'is-active': editorTab === 'cabecalho' }"
|
||||
@click="editorTab = 'cabecalho'"
|
||||
>
|
||||
<i class="pi pi-align-left" />
|
||||
<span>Cabeçalho</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="dte-tab"
|
||||
:class="{ 'is-active': editorTab === 'corpo' }"
|
||||
@click="editorTab = 'corpo'"
|
||||
>
|
||||
<i class="pi pi-align-justify" />
|
||||
<span>Corpo</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="dte-tab"
|
||||
:class="{ 'is-active': editorTab === 'rodape' }"
|
||||
@click="editorTab = 'rodape'"
|
||||
>
|
||||
<i class="pi pi-align-center" />
|
||||
<span>Rodapé</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="dte-main__editor">
|
||||
<div v-show="editorTab === 'cabecalho'" class="dte-editor-wrap" @focusin="cursorField = 'cabecalho_html'">
|
||||
<JoditEmailEditor ref="editorCabecalho" v-model="form.cabecalho_html" :minHeight="450" layoutButtons :logoUrl="form.logo_url" />
|
||||
</div>
|
||||
<div v-show="editorTab === 'corpo'" class="dte-editor-wrap" @focusin="cursorField = 'corpo_html'">
|
||||
<JoditEmailEditor ref="editorCorpo" v-model="form.corpo_html" :minHeight="450" />
|
||||
</div>
|
||||
<div v-show="editorTab === 'rodape'" class="dte-editor-wrap" @focusin="cursorField = 'rodape_html'">
|
||||
<JoditEmailEditor ref="editorRodape" v-model="form.rodape_html" :minHeight="450" layoutButtons :logoUrl="form.logo_url" />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- ─── COL 3 (direita): Variáveis disponíveis — teleporta pro drawer no mobile ─── -->
|
||||
<Teleport to="#dte-mobile-drawer-vars" :disabled="!isMobile">
|
||||
<aside class="dte-vars">
|
||||
<div class="dte-vars__head">
|
||||
<i class="pi pi-code" />
|
||||
<span>Variáveis</span>
|
||||
</div>
|
||||
<p class="dte-vars__hint">
|
||||
Clique para inserir no
|
||||
<strong>{{ editorTab === 'cabecalho' ? 'Cabeçalho' : editorTab === 'rodape' ? 'Rodapé' : 'Corpo' }}</strong>.
|
||||
</p>
|
||||
<div class="dte-vars__list">
|
||||
<div v-for="(vars, grupo) in variablesGrouped" :key="grupo" class="dte-vars__group">
|
||||
<div class="dte-vars__group-title">{{ grupo }}</div>
|
||||
<div class="dte-vars__group-items">
|
||||
<button
|
||||
v-for="v in vars"
|
||||
:key="v.key"
|
||||
class="dte-vars__btn"
|
||||
:title="v.key"
|
||||
@click="insertVariable(v.key)"
|
||||
>
|
||||
<span class="dte-vars__btn-brace">{{</span>
|
||||
<span class="dte-vars__btn-label">{{ v.label }}</span>
|
||||
<span class="dte-vars__btn-brace">}}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview -->
|
||||
<div v-show="activeTab === 'preview'" class="p-4">
|
||||
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-white overflow-hidden">
|
||||
<div class="p-6 text-black" style="font-family: 'Segoe UI', Arial, sans-serif; font-size: 12pt; line-height: 1.6;">
|
||||
<div v-if="form.cabecalho_html" class="text-center mb-4 pb-3 border-b border-gray-300" v-html="renderedCabecalho" />
|
||||
<div class="min-h-[300px]" v-html="renderedPreview" />
|
||||
<div v-if="form.rodape_html" class="mt-8 pt-3 border-t border-gray-300 text-center text-[10pt] text-gray-500" v-html="renderedRodape" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</Teleport>
|
||||
</div>
|
||||
|
||||
<!-- ══ PREVIEW — full width ════════════════════════════ -->
|
||||
<div v-show="activeTab === 'preview'" class="dte-preview">
|
||||
<div class="dte-preview__doc">
|
||||
<div v-if="form.cabecalho_html" class="dte-preview__header" v-html="renderedCabecalho" />
|
||||
<div class="dte-preview__body" v-html="renderedPreview" />
|
||||
<div v-if="form.rodape_html" class="dte-preview__footer" v-html="renderedRodape" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -47,8 +47,15 @@ export function useDocumentGenerate() {
|
||||
error.value = null;
|
||||
try {
|
||||
variables.value = await loadAllVariables(patientId, agendaEventoId);
|
||||
// Hint útil pra diagnostico: se vier objeto mas todos campos vazios,
|
||||
// sinaliza que perfil/clínica/paciente provavelmente nao tem dados.
|
||||
const filled = Object.values(variables.value).filter(v => String(v ?? '').trim() !== '').length;
|
||||
if (filled === 0) {
|
||||
error.value = 'Nenhum dado foi encontrado pra auto-preencher. Verifique o cadastro do paciente, perfil e clínica.';
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao carregar dados do paciente.';
|
||||
console.error('[useDocumentGenerate.loadVariables] falha:', e);
|
||||
error.value = e?.message || 'Erro ao carregar dados pra preenchimento.';
|
||||
variables.value = {};
|
||||
} finally {
|
||||
loading.value = false;
|
||||
@@ -92,9 +99,12 @@ export function useDocumentGenerate() {
|
||||
// ── Gerar PDF (client-side) ────────────────────────────
|
||||
|
||||
/**
|
||||
* Gera PDF blob, faz download, salva no Storage + banco.
|
||||
* Gera PDF blob, salva no Storage + banco.
|
||||
* @param {string} patientId
|
||||
* @param {string|null} editingDocId - se setado, UPDATE no doc existente
|
||||
* (in-place replace de PDF + metadados, preserva documents.id e audit).
|
||||
*/
|
||||
async function generateAndSave(patientId) {
|
||||
async function generateAndSave(patientId, editingDocId = null) {
|
||||
if (!selectedTemplate.value) throw new Error('Nenhum template selecionado.');
|
||||
|
||||
loading.value = true;
|
||||
@@ -111,7 +121,9 @@ export function useDocumentGenerate() {
|
||||
patientId,
|
||||
dadosPreenchidos: { ...variables.value },
|
||||
pdfBlob: blob,
|
||||
templateNome
|
||||
templateNome,
|
||||
templateTipo: selectedTemplate.value.tipo,
|
||||
editingDocId
|
||||
});
|
||||
generatedDocs.value.unshift(result);
|
||||
return result;
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/features/documents/composables/useDocumentSignatures.js
|
||||
| Composable Tipo A (thin wrapper) sobre DocumentSignatures.service.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
import {
|
||||
createSignatureRequests,
|
||||
listSignatures,
|
||||
getSignatureStatus,
|
||||
refuseSignature,
|
||||
signByPortal,
|
||||
signByToken,
|
||||
getSignableDocumentByToken,
|
||||
listMySignatures,
|
||||
hashDocument
|
||||
} from '@/services/DocumentSignatures.service';
|
||||
|
||||
export function useDocumentSignatures() {
|
||||
const signatures = ref([]);
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
const status = ref(null); // { total, assinados, pendentes, status }
|
||||
|
||||
async function fetchForDocument(documentoId) {
|
||||
if (!documentoId) {
|
||||
signatures.value = [];
|
||||
status.value = null;
|
||||
return [];
|
||||
}
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const [list, st] = await Promise.all([
|
||||
listSignatures(documentoId),
|
||||
getSignatureStatus(documentoId)
|
||||
]);
|
||||
signatures.value = Array.isArray(list) ? list : [];
|
||||
status.value = st || null;
|
||||
return signatures.value;
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao carregar assinaturas.';
|
||||
signatures.value = [];
|
||||
status.value = null;
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function requestSignatures(documentoId, signatarios = []) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const rows = await createSignatureRequests(documentoId, signatarios);
|
||||
signatures.value = [...signatures.value, ...rows];
|
||||
return rows;
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao solicitar assinaturas.';
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function sign(signatureId, { hashDocumento = null } = {}) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const updated = await signByPortal(signatureId, hashDocumento);
|
||||
const idx = signatures.value.findIndex(s => s.id === signatureId);
|
||||
if (idx >= 0) signatures.value.splice(idx, 1, updated);
|
||||
return updated;
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao assinar documento.';
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refuse(signatureId) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const updated = await refuseSignature(signatureId);
|
||||
const idx = signatures.value.findIndex(s => s.id === signatureId);
|
||||
if (idx >= 0) signatures.value.splice(idx, 1, updated);
|
||||
return updated;
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao recusar assinatura.';
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function signWithToken(token, signatureId = null, { hashDocumento = null } = {}) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
return await signByToken(token, signatureId, hashDocumento);
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao assinar via link.';
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMine(statusFilter = null) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const rows = await listMySignatures(statusFilter);
|
||||
signatures.value = Array.isArray(rows) ? rows : [];
|
||||
return signatures.value;
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao carregar minhas assinaturas.';
|
||||
signatures.value = [];
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadByToken(token) {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const payload = await getSignableDocumentByToken(token);
|
||||
if (!payload?.valid) {
|
||||
error.value = payload?.error === 'expired_or_invalid'
|
||||
? 'Link expirado ou inválido.'
|
||||
: payload?.error === 'document_not_found'
|
||||
? 'Documento não encontrado.'
|
||||
: 'Token inválido.';
|
||||
return null;
|
||||
}
|
||||
signatures.value = Array.isArray(payload.signatures) ? payload.signatures : [];
|
||||
return payload;
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao validar token.';
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
signatures,
|
||||
status,
|
||||
loading,
|
||||
error,
|
||||
fetchForDocument,
|
||||
requestSignatures,
|
||||
sign,
|
||||
refuse,
|
||||
signWithToken,
|
||||
loadByToken,
|
||||
loadMine,
|
||||
hashDocument
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user