25 Commits

Author SHA1 Message Date
Leonardo 646cec5833 HANDOFF: estado completo MelissaPaciente Fases 1-8 + iteracao pos-Fase 8
Reescreve HANDOFF.md com:
- Status final: 24 commits no branch, working tree limpa
- Historico completo dos commits (mais recente -> mais antigo)
- Lista de arquivos novos/modificados (composables, utils, paginas)
- Pendentes pra proxima sessao
- 5 decisoes arquiteturais documentadas
- Hotspots de drift no AgendaEventDialog
- Comandos uteis pra retomar

Adiciona entry no log.md descrevendo a iteracao pos-Fase 8 (16 commits
de UX/funcionalidades novas + debugging do AgendaEventDialog reuse).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:52:00 -03:00
Leonardo 6ad91e7853 MelissaPaciente: passa preset-commitment-id pro AgendaEventDialog (fix botao Salvar sumido)
User: "Botao pra salvar nao esta aparecendo".

CAUSA: o footer com botao Salvar tem v-if="step === 2". O lifecycle
do composer (linha 359 do useAgendaEventLifecycle) decide step inicial
assim:

  if (composer.isEdit.value) step.value = 2;
  else if (props.presetCommitmentId) {
    composer.form.value.commitment_id = preset;
    composer.step.value = 2;
  } else step.value = 1;

Eu setava determined_commitment_id no eventRow (que populava
form.commitment_id via resetForm), mas NAO passava props.presetCommitmentId.
Resultado: lifecycle ia pra step=1 (escolha de tipo). E como lockType=true
escondia o conteudo do step 1 com v-if, o dialog ficava com Body vazio
+ footer step=2 nao renderizando.

FIX: passar :preset-commitment-id="sessaoDialogEventRow?.determined_commitment_id".
Como ja resolvo o id do commitment "Sessão" no goAgendar, reuso aqui
direto sem ter que duplicar o lookup.

Resultado: dialog abre direto em step=2, footer aparece, botao Salvar
visivel (com :disabled="!canSave" — ainda exige paciente_id +
items/billing valido, comportamento normal).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:43:10 -03:00
Leonardo cf1cd67314 MelissaPaciente: pre-popula eventRow com commitment_id + paciente nome/avatar/status
User detectou bug: jornada/freq/billing continuavam ocultos mesmo apos
o fix do commit anterior, e o resumo lateral nao mostrava o nome do
paciente apesar de aparecer no subtitle do header. Diagnostico correto:
form.paciente_nome estava vazio.

CAUSA: meu watch lockType (commit 73788c7) chamava selectCommitment
APOS o lifecycle watcher do composer rodar resetForm(). Mas resetForm
le do props.eventRow — e eu so passava paciente_id + tipo. Sem
paciente_nome/avatar/status no eventRow, o form ficava com paciente_id
solto e nome vazio. E sem determined_commitment_id, o lifecycle setava
step=1 antes do meu watch tentar consertar via selectCommitment, gerando
race condition (lifecycle await nextTick + resetForm DESFAZIA o trabalho
do watch sync).

FIX em goAgendar() do MelissaPaciente:
1. Acha o commitment "Sessão" (native_key='session') em
   melissaAgenda.commitmentOptions e pre-popula determined_commitment_id
   no eventRow. resetForm le isso e ja deixa form.commitment_id setado
   na inicializacao — isSessionEvent fica true imediatamente, sem
   precisar do watch lockType.
2. Pre-popula paciente_nome/avatar/status no eventRow direto dos
   computeds (nomeCompleto, avatarUrl, statusPaciente) que ja existem
   no MelissaPaciente desde a Fase 3. Composer s o faz fetch async de
   nome quando isEdit=true — pra criacao precisa vir no eventRow.

Resultado: dialog abre ja com:
- paciente_id + nome + avatar + status preenchidos no resumo lateral
- commitment_id setado, isSessionEvent=true
- Jornada de trabalho aparece
- Billing radio (particular/convenio/gratuito) aparece
- Frequencia aparece

O watch lockType continua valido como redundancia (caso commitmentOptions
chegue async), mas agora nao e mais o caminho principal.

301 specs passando. ESLint 0 errors da minha mudanca.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:38:41 -03:00
Leonardo 73788c7031 AgendaEventDialog: lockType auto-seleciona commitment "Sessão" (fix jornada/billing/freq sumidos)
User apontou que jornada de trabalho, frequencia e billing (particular/
convenio/gratuito) sumiam quando o dialog abria do prontuario. Causa:
meu watch original do commit 30d09eb so forcava step.value=2 sem
inicializar form.commitment_id. Sem commitment, o computed
isSessionEvent virava false e esses 3 blocos do template (que dependem
de isSessionEvent) ficavam ocultos:

- jornadaDialog: <Message v-if="jornadaDialog && isSessionEvent">
- frequencia: bloco v-if="!hasSerie" tem gates internos de billing/
  patient que dependem de isSessionEvent
- billing radio (particular/convenio/gratuito): isSessionEvent

FIX: watch agora chama selectCommitment(sessao) — exatamente o que o
user faria clicando no card "Sessão" no step 1. Isso seta:
- form.commitment_id pro id do native_key='session'
- form.extra_fields = {} populado pelos fields do commitment
- step.value = 2

Adicionei props.commitmentOptions ao watch dep — necessario pq quando
o dialog abre antes do tenant load terminar, commitmentOptions chega
vazio inicialmente. Watch re-roda quando popula.

Idempotente: so chama selectCommitment se ainda nao tem commitment_id
ou se id atual nao bate com sessao.id (re-open com mesmo lockType
nao reinicializa redundantemente).

301 specs do agenda continuam passando. ESLint: 31 errors pre-existentes
(mesmos do commit anterior).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:33:08 -03:00
Leonardo 30d09eb2ac AgendaEventDialog: props lockType + lockPatient + slot #headerLeft (additivos)
User escolheu caminho A: modificar AgendaEventDialog em vez de copiar.
Mudancas SAO ADITIVAS — comportamento atual dos 5 callsites legacy
(TherapistDashboard, PatientsListPage, MelissaAgenda,
MelissaAgendamentosRecebidos, MelissaLayout) preservado.

VALIDACAO: rodei os 7 spec files do agenda — 301 testes passaram.
Zero regressao.

ADICIONADO em src/features/agenda/components/AgendaEventDialog.vue
- Prop lockType (Boolean, default false): pula step 1 (escolha de tipo)
  e vai direto pro form. Watch immediate em [lockType, modelValue]
  forca step.value=2 quando lockType=true e dialog abre.
- Prop lockPatient (Boolean, default false): esconde botoes "trocar"/
  "limpar" do paciente. Mostra icon de lock com tooltip "Paciente do
  prontuario". Cobre o cenario "criar sessao pra paciente fixo" sem
  precisar do isEdit que o patientLocked computed exige.
- Slot #headerLeft: substitui o conteudo esquerdo do header (default
  era header-dot + headerTitle + previewRange). Permite callsites
  customizar com icon+title+subtitle proprios sem mexer no resto do
  header (X / actions).
- v-if no Step 1: "step === 1 && !lockType"
- v-if nos buttons trocar/limpar: "!patientLocked && !lockPatient"
- Lock icon: "patientLocked || lockPatient" + tooltip dinamico

MELISSAPACIENTE.VUE
- Reverte o inject-only do commit 88dff50.
- Mantem o inject(MELISSA_AGENDA_KEY) APENAS pra LER dados pesados
  (commitmentOptions, workRules, allEvents, agendaSettings, feriados,
  ownerId, tenantId) — evita re-fetch.
- State LOCAL pro dialog: sessaoDialogOpen, sessaoDialogEventRow,
  sessaoDialogStartISO, sessaoDialogEndISO. Nao colide com o dialog
  global do MelissaLayout que continua na Agenda.
- goAgendar(): inicializa eventRow com paciente_id fixo + tipo='sessao'
  + defaults razoaveis (proximo slot 15min + duracao da agenda),
  abre o dialog local.
- Handlers onSessaoDialogSave / onSessaoDialogDelete delegam pros
  handlers globais (M.onDialogSave/Delete) e ao final refetcham
  sessions+recorrencias do paciente in-place.
- Render <AgendaEventDialog> com lock-type=true + lock-patient=true
  + slot #headerLeft custom (icon pi-calendar-plus em quadrado
  primary 40x40 + "Nova sessão" + nome do paciente como subtitulo).

Resultado: prontuario tem o MESMO componente da Agenda (form completo
de sessao, frequencia com preview de ocorrencias + conflitos,
vinculacao de servicos/billing, edicao de serie, etc) mas pre-fixado
no contexto do paciente, com header proprio e single source of truth.

ESLint: 31 errors pre-existentes em ambos arquivos (variaveis declaradas
nao usadas — confirmado via git stash baseline). 0 errors da minha
mudanca.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:27:32 -03:00
Leonardo 88dff50223 MelissaPaciente: usa AgendaEventDialog GLOBAL via inject (em vez de dialog local)
User pediu pra trazer o AgendaEventDialog completo da Agenda pra dentro
do prontuario. Estrategia: NAO duplicar o dialog (que ja vive no
MelissaLayout). Em vez disso, reusar via provide/inject — pattern que
ja existe (MELISSA_AGENDA_KEY).

NOVO em src/layout/melissa/composables/useMelissaAgenda.js
- onCreateEventoForPatient(patientId) — espelha onCreateEvento (defaults
  hoje proximo slot 15min, duracao default), mas injeta paciente_id no
  dialogEventRow. Adicionada ao return do composable.

MELISSAPACIENTE.VUE
- inject(MELISSA_AGENDA_KEY) pra acessar a instancia do useMelissaAgenda
  do MelissaLayout.
- goAgendar(): chama melissaAgenda.onCreateEventoForPatient(patientId)
  (defensive: warn toast se nao tem inject ou funcao).
- Watch em melissaAgenda.dialogOpen pra refetchar sessions+recorrencias
  quando o dialog fecha (true -> false), independente se foi save ou
  cancel.

REMOVIDO (sem mais necessario — AgendaEventDialog faz tudo)
- Refs novaSessaoOpen, novaSessaoForm
- Catalogos FREQ_OPCOES, DIAS_SEMANA_OPCOES, QTD_SESSOES_OPCOES,
  SESSAO_TIPOS, SESSAO_DURACOES, SESSAO_MODALIDADES
- Helpers toggleDiaSelecionado, qtdSessoesEfetiva, novaSessaoCtaLabel
- Function salvarSessao (~110L de logica avulsa+recorrencia)
- Import supabase (nao usado direto mais)
- Import useRecurrence (era pro createRule no salvarSessao)
- Import WEEKDAY_LABEL_BLOCK (era pro preview de freq)
- Template <Dialog> Nova Sessao com header custom + form + freq chips +
  qtd sessoes + footer (~180L)

Resultado: MelissaPaciente fica mais enxuto e usa exatamente o mesmo
dialog completo que MelissaAgenda — todos os recursos do AgendaEventDialog
(tipos de evento, paciente picker, comprometimento de servicos/billing,
freq com preview de ocorrencias + conflitos, validacao por work rules,
edicao de serie etc) ficam disponiveis no prontuario sem duplicacao.

ESLint: 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:05:25 -03:00
Leonardo b040e15c9b MelissaPaciente: header custom do dialog Nova Sessao (icone + titulo + nome)
Antes: header simples "Nova sessão".
Agora: layout 3-col com:
- Icon pi-calendar-plus em quadrado primary 40x40
- Title "Nova sessão" (1rem font-weight 700)
- Subtitle: nome completo do paciente (truncate com ellipsis)

CSS .mpa-dlg-head + variants. Reusavel se outros dialogs precisarem
do mesmo padrao (Lancamento poderia usar tambem futuramente).

ESLint: 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 18:56:30 -03:00
Leonardo 42a39ed3ea MelissaPaciente: dialog Nova Sessao usa "Frequencia" estilo AgendaEventDialog
User pediu pra trocar o checkbox "Repetir semanalmente" + radios pelo
mesmo widget de Frequencia que existe no AgendaEventDialog. Replicado
1:1 o pattern (chips + qtd sessoes).

REMOVIDO
- Checkbox "Repetir semanalmente"
- 3 radios de fim_tipo (open/count/data)
- Inputs inline associados (fim_count, fim_data)

ADICIONADO no form
- novaSessaoForm.freq: 'avulsa' (default) | 'semanal' | 'quinzenal' |
  'diasEspecificos'
- novaSessaoForm.diasSelecionados: array<int> (so usado em
  diasEspecificos)
- novaSessaoForm.qtdMode: '4' | '8' | '12' | 'personalizar'
- novaSessaoForm.qtdCustom: number (so usado em personalizar)

ADICIONADO catalogos (FREQ_OPCOES, DIAS_SEMANA_OPCOES, QTD_SESSOES_OPCOES)
e helpers (toggleDiaSelecionado, qtdSessoesEfetiva computed,
novaSessaoCtaLabel computed).

ADICIONADO no template:
- Chips horizontais "Avulsa / Semanal / Quinzenal / Dias específicos"
  (estilo .mpa-freq-chip — pill arredondado, primary quando active)
- Preview com icon refresh: "Toda quarta, às 14:00" / "A cada 2 semanas,
  toda quarta..."
- Grid de dias da semana (Seg Ter Qua Qui Sex Sab Dom) so quando
  diasEspecificos
- Quantidade de sessoes: chips "4 sessoes / 8 / 12 / Personalizar"
  + InputNumber show-buttons quando personalizar
- Label dinamica do CTA: "Agendar sessão" (avulsa) / "Criar recorrência"

LOGICA salvarSessao mapeia freq -> recurrence_rules:
- avulsa: caminho original (createSession + INSERT agenda_eventos)
- semanal: type='weekly', interval=1, weekdays=[dow]
- quinzenal: type='biweekly', interval=2, weekdays=[dow]
- diasEspecificos: type='custom_weekdays', interval=1, weekdays=[selecionados]
Sempre com max_occurrences (qtd efetiva) — sem mais open-ended por
default. Toast detalha "{N} sessoes previstas".

Validacoes:
- diasEspecificos exige >=1 dia selecionado (toast warn)
- qtd efetiva >= 1 (cobrindo personalizar invalido)

CSS: ~120L (substitui o bloco .mpa-recur antigo). Usa accent var
--p-primary-color pra match do app theme. .mpa-freq-chip / .mpa-dia-chip
hover/active states. .mpa-freq-preview com bg color-mix do primary.

ESLint: 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:51:43 -03:00
Leonardo 9e76e4e6ea MelissaPaciente: bloco "Recorrencias do paciente" na Tab Agenda
User aprovou a ideia. Adiciona contexto "este paciente tem sessao toda
segunda 14h" direto no prontuario, evitando duplicacao de regras e
deixando claro o estado da serie.

NOVO src/features/patients/composables/usePatientRecurrences.js (~110L)
- load(patientId): SELECT recurrence_rules WHERE patient_id (DESC start_date)
- cancel(ruleId) / reactivate(ruleId): UPDATE status + auto-reload
- Computeds derivados: ativas, canceladas, totalAtivas, totalCanceladas
- busy flag pra disable de buttons

EXTENSAO src/features/patients/utils/patientFormatters.js
- WEEKDAY_LABEL + WEEKDAY_LABEL_SHORT (arrays 0=Domingo..6=Sabado)
- fmtRecurrenceLabel(rule): "Toda segunda às 14:00", "Quinzenal · Terça
  às 09:00", "Qua, Sex às 16:00" (custom_weekdays), "Mensal às 14:00",
  "Anual" — cobre todos os types do useRecurrence.
- fmtRecurrenceFim(rule): "Sem data de fim" / "Até DD/MM/YYYY" /
  "N sessões no total"

MELISSAPACIENTE.VUE
- Composable + handlers (onCancelRecurrence, onReactivateRecurrence) com
  toast feedback.
- recorrenciasShowCanc ref + recorrenciasVisiveis computed (toggle "ver
  canceladas").
- loadAll inclui recorrenciasHook.load.
- salvarSessao no caminho recorrente recarrega sessions+recorrencias em
  Promise.all (regra recem-criada aparece na lista imediatamente).
- 5o KPI na Tab Agenda: "Recorrencias" com count ativas + cap dinamica
  (cor #a855f7 quando > 0, cinza quando 0).
- Bloco <section class="mpa-panel"> entre KPIs e filter chips listando
  rules ativas (default) ou todas (toggle "Ver canceladas" no header,
  so aparece quando ha canceladas):
  - Icon roxo .mpa-recur-item__icon
  - Top: label + Tag status (verde Ativa / amarelo Cancelada)
  - Meta: duracao + modalidade + fim + "desde DATE"
  - Obs (quando preenchido): block textual
  - Actions: pi-ban (cancelar) ou pi-undo (reativar) com tooltip
- border-left adaptativa (#a855f7 ativo / cinza cancelado) + opacity 0.7
  pros cancelados.
- Mobile: stack icon+main em 2-col 2-row; actions full-width abaixo.

CSS: ~120L novos. Padrao Melissa: status pills, icon roxo distintivo
(diferente das sessoes que usam cinza), border-left por status.

ESLint: 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:37:45 -03:00
Leonardo f1d6fbad73 MelissaPaciente: dialog nova sessao integra useRecurrence (recorrencia semanal)
User apontou que ja existe sistema de recorrencia pronto (useRecurrence.js
+ tabela recurrence_rules + MelissaRecorrencias). Integrei no dialog de
nova sessao.

NOVO no dialog:
- Checkbox "Repetir semanalmente" + texto explicativo (cria serie no
  mesmo dia da semana e horario)
- Quando ativado, mostra 3 opcoes radio:
  - "Sem data de fim" (open-ended — continua ate cancelar)
  - "Apos N sessoes" (max_occurrences)
  - "Ate <data>" (end_date)
- Cada opcao com input inline disabled quando nao selecionada
- Label do botao salvar muda dinamicamente: "Agendar sessao" -> "Criar
  recorrencia"

LOGICA salvarSessao() ramificada:
- Se repetir = false: caminho original (createSession + INSERT em
  agenda_eventos)
- Se repetir = true: caminho NOVO via useRecurrence.createRule:
  - type: 'weekly', interval: 1
  - weekdays: [inicio.getDay()] (calculado do dia da semana selecionado)
  - start_date: f.data
  - end_date / max_occurrences conforme fim_tipo
  - start_time: f.hora
  - duration_min, modalidade, titulo_custom, observacoes, status: 'ativo'
  - Insere row em recurrence_rules; ocorrencias sao geradas dinamicamente
    pelo expandRules() do composable. Sessoes confirmadas/realizadas
    viram rows reais sob demanda.

Validacoes adicionais:
- fim_tipo='data' exige fim_data preenchido (toast warn)
- fim_tipo='count' exige fim_count >= 1 (toast warn)

Reload das sessoes ao final pra refletir caso start_date seja hoje
(occurrence ja entra na timeline).

Toast de sucesso aponta pra "Recorrencias" como destino pra gerenciar
a serie.

ESLint: 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:32:36 -03:00
Leonardo a8ab13b201 MelissaPaciente: dialog inline nova sessao + createSession mutation
Espelha o padrao do "Lancamento" mas pra agenda — botao "Agendar" agora
navega pra aba Agenda e abre dialog de nova sessao.

NOVO em src/features/patients/composables/usePatientSessions.js
- createSession(patientId, payload) — INSERT agenda_eventos com
  status='agendado', resolve owner_id (auth.getUser) e tenant_id (lazy
  import tenantStore). Auto-reload via _lastPatientId.
  Validacao: inicio_em + fim_em obrigatorios.
  Retorna {ok, data?, error?}.

NOVO em MelissaPaciente.vue
- Refs novaSessaoOpen + novaSessaoForm (tipo/data/hora/duracao_min/
  modalidade/titulo_custom/observacoes)
- 3 catalogos:
  - SESSAO_TIPOS: Sessao/Primeira/Retorno/Avaliacao/Devolutiva
  - SESSAO_DURACOES: 30/40/45/50/55/60/90/120 min
  - SESSAO_MODALIDADES: Presencial/Online
- goAgendar() agora alem de navegar pra aba Agenda, tambem inicializa
  o form (default amanha 09:00, sessao 50min presencial) e abre o dialog.
- salvarSessao() handler com validacao (data + hora) e construcao de
  inicio_em/fim_em a partir de data + hora + duracao_min. Local time
  -> ISO via Date constructor.
- <Dialog> 460px com form: Tipo + grid 2-col (data + hora) + grid 2-col
  (duracao + modalidade) + titulo opcional + observacoes Textarea.
- CSS .mpa-novo-lanc__opt pra "(opcional)" em cinza.

Validacoes:
- Data e hora obrigatorios (warn toast)
- Date constructor invalido -> warn toast

Pra criar sessoes mais complexas (recorrencia, multi-paciente, conflitos
de agenda), o user vai pra MelissaAgenda direto que tem o
AgendaEventDialog completo. Aqui no prontuario eh o caminho rapido.

ESLint: 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:20:00 -03:00
Leonardo 21c71f75d6 MelissaPaciente: addFinancial navega pra Financeiro + novo botao Agendar
- addFinancial(): antes so abria o dialog inline. Agora primeiro navega
  pra activeTab='financ' (da contexto visual), fecha drawer mobile e
  entao abre o dialog. User ve a aba Financeiro atualizar imediatamente
  apos salvar.
- goAgendar() novo: navega pra activeTab='agenda', fecha drawer mobile.
  Sem dialog — a aba Agenda ja tem KPIs + lista por mes + acoes inline
  (realizada/falta/cancelar). Pra criar nova sessao o user usa
  MelissaAgenda direto (fora do prontuario).
- Botao "Agendar" novo na sidebar Acoes Rapidas, abaixo de "Lancamento",
  com icon pi-calendar-plus verde #10b981.

ESLint: 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:16:22 -03:00
Leonardo 64005a5b07 MelissaPaciente: fix openWhatsapp + dialog inline novo lancamento financeiro
DOIS BUGS DE COMPORTAMENTO:

1. openWhatsapp nao abria o drawer
   conversationDrawerStore.openForPatient(patientId) espera STRING id,
   nao objeto. Eu passava { id, name, phone, avatar_url } — store
   ignorava e drawer nunca abria.
   FIX: passar String(props.patientId) (mesmo pattern que MelissaPacientes).
   BONUS: a store seta this.error sem dar throw quando paciente nao tem
   telefone cadastrado. Detectamos com `if (err && !isOpen)` e mostramos
   toast warn com a mensagem da store ("Paciente sem telefone cadastrado").
   Funcao virou async pra aguardar o openForPatient.

2. addFinancial era placeholder "Em breve"
   User correto: o sistema ja tem suporte (composables/useFinancialRecords
   tem createManualRecord). Implementado dialog inline simples no
   prontuario.

NOVO em src/features/patients/composables/usePatientFinancial.js
- createRecord(patientId, payload) — INSERT financial_records com
  type='receita', resolve owner_id (auth.getUser) e tenant_id (lazy
  import tenantStore pra evitar circular). Auto-reload via _lastPatientId.
  Retorna {ok, data?, error?}.

NOVO em MelissaPaciente.vue
- Refs novoLancOpen + novoLancForm (description/amount/due_date/payment_method)
- PAYMENT_METHODS array (Pix/Cartao/Dinheiro/Transferencia/Boleto/Convenio)
- addFinancial() agora abre o dialog (era toast "em breve")
- salvarLancamento() handler com validacao (valor > 0, due_date obrigatorio)
- <Dialog> v-model:visible 420px com form: descricao + grid 2-col
  (valor InputNumber BRL + vencimento date input) + select forma
- CSS .mpa-novo-lanc + responsive (1-col em <540px)

ESLint: 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:12:22 -03:00
Leonardo 301a7124a7 MelissaPaciente: editPatient abre PatientCadastroDialog INLINE (sem sair)
Bug reportado: ao clicar "Editar dados" no prontuario, o user era
redirecionado pra /melissa/pacientes?edit=X (que entao abria o cadastro
em MelissaPacientes). Isso saia da tela do prontuario — comportamento
incorreto.

FIX: importar PatientCadastroDialog no MelissaPaciente e abrir por cima
da pagina (z-index PrimeVue ~1100 > .mpa-page z-index 40). Ao salvar,
recarrega os dados do paciente in-place via detail.load().

ADICIONADO
- Import PatientCadastroDialog
- Refs locais cadastroOpen + cadastroPatientId
- editPatient() agora seta refs e abre dialog (era router.push)
- onPatientSaved() handler que fecha o dialog e refetcha o detail
- <PatientCadastroDialog v-model="cadastroOpen" ...> renderizado depois
  da .mpa-page no template

O watch route.query.edit em MelissaPacientes (Fase 8) continua valido
pra deep-links externos, mas o fluxo MelissaPaciente -> editar nao usa
mais essa rota.

ESLint: 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:06:49 -03:00
Leonardo 5d2c389486 MelissaPaciente: fix sidebar cards encolhendo + gap das abas main
DOIS BUGS DE LAYOUT corrigidos via CSS (post-Fase 8 polish):

1. CARDS DA SIDEBAR sendo encolhidos
   .mpa-side__scroll eh display:flex flex-direction:column. Os cards
   .mpa-w filhos NAO tinham flex-shrink:0, entao quando havia muitos
   cards stacked (Acoes + Nav 7 tabs + Sub-nav Perfil 6 + Vinculos),
   o flex shrink default (1) reduzia cada card proporcionalmente.
   Combinado com .mpa-w { overflow:hidden } (necessario pro radius),
   itens internos das listas eram cortados/escondidos.
   FIX: .mpa-side__scroll > .mpa-w { flex-shrink: 0; height: auto; }
   Agora cada card cresce ate o tamanho real do conteudo, e o scroll
   vertical do .mpa-side__scroll lida com overflow.

2. ABAS DO MAIN sem gap entre elementos
   <div class="mpa-tab"> nao tinha CSS definido. Os filhos (KPIs grid,
   panels, cards) ficavam colados. .mpa-main eh flex-col com gap, mas
   como cada aba envolve seus elementos num <div .mpa-tab>, esse div
   precisa replicar o spacing.
   FIX: .mpa-tab { display: flex; flex-direction: column; gap: 12px; }

Visivel em todas as 7 abas. Fase 1 ja deveria ter incluido — passou
despercebido.

ESLint: 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:55:38 -03:00
Leonardo 159b80db6c MelissaPaciente: full-width + sidebar "Voltar pra Pacientes" no lugar de Configuracoes
Feedback do user pos-Fase 8:
1. Janela full-width (prontuario tem KPIs + tabelas + timeline — precisa
   de espaco). Removido o `right: max(6px, min(50%, calc(100% - 1006px)))`
   da .mpa-page no @media >=1024px. Mantém apenas inset 6px nos 4 lados.
2. Botao "Configuracoes" da sidebar removido (prontuario pertence a
   Pacientes, nao a Configuracoes — nao faz sentido o atalho global de
   cfg-* aqui). No mesmo lugar visual entra o botao "Voltar para Pacientes"
   com mesma classe .mpa-cfg-btn (reaproveita estilo) + modifier
   .mpa-cfg-btn--back pra hover sutilmente diferente.

REMOVIDO
- Import MelissaConfigList (nao usado mais)
- Refs cfgOpen + funcoes toggleCfg/fecharCfg
- Template do dual-mode (cfgOpen ? MelissaConfigList : cards)
- CSS .mpa-cfg-btn.is-open + .mpa-cfg-btn__chev + .mpa-side__scroll--cfg

ADICIONADO
- close() agora faz history.back se houver historia, fallback pra
  /melissa/pacientes (cobre deep-link direto). Antes ia sempre pra
  /melissa/pacientes — agora respeita de onde o user veio (Agenda OU
  Pacientes).
- goToPacientes() handler novo pro botao "Voltar pra Pacientes".
- .mpa-cfg-btn--back hover style.

Tooltip do X mudou de "Voltar (Esc)" pra "Fechar (Esc)" — semantica mais
clara (o X fecha; o botao da sidebar voltar EXPLICITO).

ESLint: 0 errors da minha mudanca.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:52:42 -03:00
Leonardo 71ee51d38f MelissaPaciente Fase 8: wire-up final (Dialog -> route /melissa/paciente?id=X)
PLANO DE 8 FASES COMPLETO. Os 2 callsites Melissa do PatientProntuario.vue
legacy (3593L Dialog) trocam por navegacao pra MelissaPaciente nativo via
router.push. PatientProntuario continua intocado pros 2 callsites legacy
fora do Melissa (TherapistDashboard, PatientsListPage).

MELISSAPACIENTE.VUE — wire-up interno
- Imports: useRouter + useConversationDrawerStore
- close(): emit + router.push('/melissa/pacientes')
- editPatient(): emit + router.push('/melissa/pacientes', query: {edit: id})
  pra MelissaPacientes auto-abrir o cadastroFullDialog
- openWhatsapp(): emit + conversationDrawerStore.openForPatient({id, name,
  phone, avatar_url}) — drawer global desce sobre Melissa
- addFinancial(): emit + toast "Em breve" (Fase 9 — dialog inline)

MELISSAPACIENTES.VUE
- Removeu import PatientProntuario + refs prontuarioOpen/prontuarioPatient
- Removeu <PatientProntuario> template (substituido por comentario)
- abrirProntuario(p): router.push('/melissa/paciente', query: {id})
- onMounted detecta route.query.edit -> abre cadastroFullDialog +
  router.replace pra limpar query (handshake com MelissaPaciente)
- Comentario header atualizado

MELISSAAGENDA.VUE
- Removeu import PatientProntuario + refs prontuarioOpen/prontuarioPatient
- Removeu <PatientProntuario> template
- abrirProntuarioPorId(id): router.push pra rota Melissa nativa
- abrirProntuarioPaciente / openProntuario / kebab "Prontuario" delegam
  pra abrirProntuarioPorId

MELISSALAYOUT.VUE
- Render <MelissaPaciente> simplificado: so @close="fecharSecao".
  Acoes edit/open-whatsapp/add-financial ficam internas.

ESLint: 0 errors da minha mudanca (9 pre-existentes nos arquivos tocados
sao baseline; confirmados via git stash — mesmos errors em ambos lados).

PLANO COMPLETO. Total de 8 commits no branch (Fases 1-8). MelissaPaciente.vue
~2400L + 5 composables (~407L) + utils ~280L. PatientProntuario.vue
intocado pra fallback legacy (TherapistDashboard, PatientsListPage).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:21:35 -03:00
Leonardo 167e864b8a MelissaPaciente Fase 7: Tabs Documentos + Conversas (KPIs + embed componentes existentes)
Duas tabs entregues numa sessao — sao mais leves porque reusam
DocumentsListPage e PatientConversationsTab existentes (testados em
producao no PatientProntuario legacy) com KPIs Melissa por cima.

EXTENSAO src/features/patients/utils/patientFormatters.js
- fmtSize(bytes): legivel B/KB/MB/GB
- DOC_TYPE_LABEL map: atestado/receita/laudo/encaminhamento/termo/etc
- chConvLabel(c): whatsapp -> WhatsApp / sms -> SMS / email -> E-mail

EXTENSAO src/features/patients/composables/usePatientDocuments.js
- topType computed: { tipo, count, label } do tipo mais comum
- pendentes computed: count status_revisao === 'pendente'
- sizeTotalFormatted computed: fmtSize(totalBytes)

EXTENSAO src/features/patients/composables/usePatientMessages.js
- primeiraMensagem computed (mais antiga)
- canais computed: Set de m.channel unicos

MELISSAPACIENTE.VUE — Tab Documentos
- 4 KPIs adaptativos (so renderizam com dados):
  Total + sizeTotalFormatted / Mais comum / Ultimo / Revisao pendente
- DocumentsListPage embedded no card Melissa (mpa-embed wrapper).
  Reusa upload/preview/listagem testados.

MELISSAPACIENTE.VUE — Tab Conversas
- 4 KPIs: Mensagens com canais / Recebidas % / Enviadas % / Ultima
- CTA "Abrir conversa no drawer" estilo WhatsApp pill verde #25d366
  que emite open-whatsapp pro parent (Fase 8 integra com
  conversationDrawerStore.openForPatient)
- PatientConversationsTab embedded — thread completa com filter/media

CSS: ~50L novos (mpa-conv-cta + mpa-embed wrapper).

Removido kpiDocumentos nao usado (substituido por documentsHook.total
direto).

ESLint: 0 errors da minha mudanca.

PROXIMA: Fase 8 wire-up final (Dialog -> router.push em MelissaPacientes/
MelissaAgenda; decisao sobre TherapistDashboard + PatientsListPage).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:06:29 -03:00
Leonardo e7c0f6c4f5 MelissaPaciente Fase 6: Tab Financeiro completa + mark paid (mutation que legacy nao tem)
EXTENSAO src/features/patients/utils/patientFormatters.js
- recordStatus(r): pago / vencido (paid_at NULL && due_date < hoje) / pendente
- RECORD_STATUS_LABEL map
- fmtPaymentMethod(v): PIX/Cartao/Dinheiro/Boleto/Transferencia/Convenio
  cobrindo variantes pt-br + camelCase

EXTENSAO src/features/patients/composables/usePatientFinancial.js
- ref `busy` + `_lastPatientId` interno
- recordsOrdenados computed: DESC por due_date com fallback created_at
- markPaid(recordId): UPDATE financial_records SET paid_at=NOW() +
  auto-reload via _lastPatientId. Retorna {ok, error?}
- markUnpaid(recordId): reverte (paid_at=NULL) + auto-reload

MELISSAPACIENTE.VUE — script
- Imports: recordStatus, RECORD_STATUS_LABEL, fmtPaymentMethod
- markRecordPaid(r): chama financialHook.markPaid + toast success/error
- revertRecordPaid(r): chama markUnpaid + toast

MELISSAPACIENTE.VUE — Tab Financeiro reescrita (substitui placeholder Fase 1)
- Loading state
- Empty state com CTA "Novo lancamento" (mpa-quick-btn--cta)
- 3 KPIs: Pago / Pendente com proxVenc / Em atraso (cor adaptativa
  vermelho quando > 0, cinza quando 0)
- Header "Lancamentos" com badge count + botao "+ Novo" no canto
- Tabela 6-col responsiva:
  - Vencimento (date mono + relative)
  - Descricao
  - Forma (PIX/Cartao/etc)
  - Valor (mono right-aligned)
  - Status pill colorida (verde pago / vermelho vencido / azul pendente)
  - Action button (pi-check verde marca pago / pi-undo amarelo reverte)
- border-left adaptativa por status
- Mobile: tabela colapsa em cards 2-col 4-row

DIFERENCA DO LEGACY: o PatientProntuario.vue exibe a tabela mas NAO
permite marcar pago/reverter direto dela. MelissaPaciente adiciona essa
acao inline (mutation auto-reload).

CSS: ~190L novos. Padrao Melissa: status pills com color-mix, JetBrains
Mono pra valores, header cell uppercase letter-spacing.

ESLint: 0 errors da minha mudanca.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:57:42 -03:00
Leonardo 8a8d2e05bd MelissaPaciente Fase 5: Tab Agenda completa (KPIs + filtros + grupos por mes + acoes)
EXTENSAO src/features/patients/utils/patientFormatters.js: +2 helpers
- fmtHourShort (HH:MM 24h pt-br) — usado na coluna data dos cards
- fmtDayShort (DOW abreviado pt-br sem ponto) — usado na coluna data

EXTENSAO src/features/patients/composables/usePatientSessions.js
- Novo ref `busy` pra disable de buttons durante mutation
- _lastPatientId guardado internamente pra auto-reload
- Nova funcao `updateStatus(sessionId, novoStatus)` que faz
  supabase.from('agenda_eventos').update({status}) + auto-reload da
  lista de sessoes. Retorna {ok, error?}.

MELISSAPACIENTE.VUE — script
- agendaFilter ref ('all' default) + AGENDA_FILTERS array com 6 opcoes
  (Todas, Proximas, Passadas, Realizadas, Faltas, Canceladas)
- agendaSessoesFiltradas computed: filtra por future/past/status (regex)
- agendaAgrupadas computed: agrupa por "Mes de YYYY" DESC
- updateSessionStatus(ev, status, msg): chama sessionsHook.updateStatus +
  toast de sucesso/erro
- Removido `void toast` (toast usado de verdade agora)

MELISSAPACIENTE.VUE — Tab Agenda reescrita (substitui placeholder Fase 1)
- 4 KPI cards no padrao Visao Geral (numerados 01-04):
  Total / Realizadas (% do total) / Faltas (cor adaptativa) / Proxima
- 6 filter chips redondas (cor primary quando active)
- Empty state contextual (sem sessoes vs filtro vazio)
- Grupos por mes com header (label + badge count)
- Cards 3-col: data column (DOW + dia + hora) | main (status tag + chips
  modalidade/duracao + relative + titulo + note 2-line clamp) | actions
  (3 buttons: ok/warn/danger com tooltip + cor adaptativa no hover)
- Mobile: stack date+main em 2 cols; actions full-width abaixo

CSS: ~150L novos. Padrao visual Melissa: data column estilo calendario,
actions hover muda cor por intent (verde realiz / amarelo falta / vermelho
cancel), border-left por status.

ESLint: 0 errors da minha mudanca.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:53:59 -03:00
Leonardo 1278e93b01 MelissaPaciente Fase 4: Tab Prontuario MVP (evolucao via session.observacoes)
O legacy PatientProntuario.vue tem a aba Prontuario como PLACEHOLDER
("Em breve" rich empty state). O MVP entregue aqui SUPERA o legacy: usa
agenda_eventos.observacoes como nota evolutiva — funcional ja hoje sem
precisar de schema novo.

ESTADO + COMPUTEDS adicionados ao MelissaPaciente.vue:
- pronFilter ref ('com-evolucao' default) + PRON_FILTERS com 5 opcoes
  (Com evolucao / Todas / Realizadas / Faltas / Cancelamentos)
- pronSessions computed: filtra sessoes por status/presenca de observacoes
- sessoesComEvolucao computed: count de sessoes com observacoes nao-vazia

TEMPLATE Tab Prontuario (substitui placeholder Fase 1):
- Hint banner explicativo no topo (icon info + "Prontuario em construcao")
- 4 mini-stats em grid: com evolucao / realizadas / faltas / total
- 5 filter chips redondas — selecao default 'com-evolucao' filtra so
  sessoes que tem nota
- Empty states contextuais (sem sessoes / sem evolucao / filtro vazio)
- Lista de sessoes:
  - border-left colorida por status (verde/vermelho/amarelo/cinza)
  - head com data + relative + chips status/modalidade/duracao
  - block "Evolucao" destacado quando tem observacoes (bg medium + border
    primary + label uppercase + texto pre-wrap)
  - "Sem evolucao registrada" italico cinza quando nao tem
- Roadmap card (border dashed) listando 4 features futuras: anamnese
  estruturada / plano terapeutico / evolucao por temas / assinatura
  digital + LGPD Art. 18.

CSS: ~200L novos. Padrao Melissa (chips estilo MelissaTags, border-left
adaptativa, label uppercase nos blocks de evolucao).

ESLint: 0 errors da minha mudanca.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:46:58 -03:00
Leonardo 4fc0e3a02b MelissaPaciente Fase 3: Tab Perfil completa (6 sections stacked + anchors)
EXTENSAO: src/features/patients/utils/patientFormatters.js
- +5 formatters: pickField (compartilhado), onlyDigits, fmtCPF (000.000.000-00),
  fmtRG (passthrough), fmtPhoneMobile ((XX) 9XXXX-XXXX), fmtGender
  (Masculino/Feminino/Nao-binario/Outro), fmtMarital (Solteiro/Casado/
  Divorciado/Viuvo/Uniao estavel).

MELISSAPACIENTE.VUE — script
- 30+ field computeds usando pickField (cobre snake_case + camelCase):
  birthValue, telefone/Alternativo, email/Alternativo, genero, estadoCivil,
  naturalidade, ondeNosConheceu, encaminhadoPor, observacoes, notasInternas
  + 8 campos de endereco + 5 dados adicionais + 4 responsavel.
- groupNames/groupLabel/groupCountLabel pra bloco Origem.
- scrollToProfileSection(key): liga sidebar sub-nav -> scrollIntoView do
  anchor #mpa-perfil-XXX. Em mobile fecha o drawer.

MELISSAPACIENTE.VUE — Tab Perfil reescrita
Diferente do PatientProntuario legacy que usa PrimeVue Accordion (1 painel
aberto por vez), o Melissa nativo mostra os 6 cards stacked com scroll
suave do sidebar sub-nav. Mais legivel em desktop, mais rapido de escanear.

- 1. Informacoes Pessoais: 2-col com Dados de cadastro (nome/data nasc
  com idade inline/genero/estado civil/CPF/RG/naturalidade) + Contato +
  Origem (grupos/tags chips/onde nos conheceu/encaminhado por). tel: e
  mailto: links onde ha valor. Observacoes full-width quando preenchido.
- 2. Endereco: grid 2-col com 8 fields.
- 3. Dados Adicionais: grid 2-col com escolaridade/profissao/parente/grau/
  tel parente.
- 4. Responsavel: 1-col com nome/CPF/tel + observacao block textual.
- 5. Anotacoes Internas: card com hint lock + textblock min-height.
- 6. Sessoes: lista compacta scrollable (max-height 360px) com titulo/
  data/duracao/modalidade chips + tag status.

CSS: ~250L novos pros componentes (mpa-fields/field-row/field-grid-2/
field-block/sess/sess-list). Pattern visual Melissa: cards com label
uppercase, separadores horizontais sutis, links primary, monospace pra
CPF/RG/CEP.

ESLint: 0 errors da minha mudanca.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:43:03 -03:00
Leonardo ab7526b8d7 MelissaPaciente Fase 2: Tab Visao Geral completa (4 KPIs + timeline + msgs + notas)
Reescreveu o placeholder da aba Visao Geral por uma versao 1:1 do
PatientProntuario.vue legado, com estilo Melissa nativo e dados
alimentados pelos composables criados na Fase 1.

NOVO: src/features/patients/utils/patientFormatters.js (~165L)
- Helpers compartilhaveis extraidos do PatientProntuario:
  parseDateLoose, fmtDateBR, fmtDateTimeBR, fmtCurrency, fmtRelative
  (pt-br: "agora"/"ha 5 min"/"em 2 dias"/"ha 3 sem"), sessionDuration,
  calcAge.
- STATUS_LABEL e STATUS_SEVERITY pra mapear status de sessao (cobre
  variantes: realizado/realizada, falta/faltou, cancelado/cancelada).
- tagStyle com contraste auto (luminance WCAG-ish: bg colorido +
  texto preto/branco baseado em luminance < 0.45).
- Sera reutilizado pelas Fases 3-7 e na Fase 8 substitui as funcoes
  duplicadas do PatientProntuario.

EXTENSAO de composables (Fase 1):
- usePatientSessions: novo computed `ultimasAtendidas` (top 6 sessoes
  com status realiz/falt/cancel/remarc pra Timeline). totalRealizadas/
  Faltas/Canceladas refinados pra usar regex (cobre variantes pt-br).
- usePatientFinancial: novo computed `statusFinanceiro` que retorna
  { emDia: bool, proxVenc: record, totalPendente, totalPago, vencidos }
  pra alimentar KPI 02 com info detalhada de status financeiro.

MELISSAPACIENTE.VUE — Visao Geral reescrita:
- 4 KPI cards ricos (substituem os simples da Fase 1):
  - 01 Sessoes: realizadas / total + faltas + canceladas
  - 02 Pagamento: status (Em dia/atraso) + prox venc + cor adaptativa
    (vermelho atrasado / primary ok)
  - 03 Proxima sessao: relative + datetime + modalidade
  - 04 Mensagens: ultima relative + direction + count
- Grid 2-col abaixo (1.4fr / 1fr em >=900px):
  - Timeline coluna esquerda: dots coloridos por status, tags severity,
    chips modalidade + duracao, nota observacoes inline.
  - Coluna direita: Mensagens recentes (4) com border-left in/out +
    meta direction/relative + body 3-line clamp; Notas e observacoes
    em card papel com label uppercase e icone lock.
- Removeu kpiEmAberto/Atrasado nao usados (statusFinanceiro encapsula).

CSS: ~280L novos pros componentes (KPIs ricos, panel base, empty rich,
timeline, mensagens, notas). Mantem o pattern visual Melissa.

ESLint: 0 errors da minha mudanca.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:31:36 -03:00
Leonardo df61cc4d99 MelissaPaciente Fase 1: foundation (5 composables + skeleton 7 tabs + slug paciente)
Inicio do port do PatientProntuario.vue (3593L Dialog) pra Melissa nativo.
Plano em 8 fases — esta entrega cobre apenas a Fase 1 (foundation).
PatientProntuario continua intocado nos 4 callsites (TherapistDashboard,
MelissaAgenda, MelissaPacientes, PatientsListPage); migration acontece
nas fases 2-8.

5 COMPOSABLES NOVOS em src/features/patients/composables/
- usePatientDetail.js (108L): patients + groups + tags
- usePatientSessions.js (83L): agenda_eventos + computeds proxima/ultima/totais
- usePatientFinancial.js (82L): financial_records + computeds totalRecebido/Aberto/Atrasado
- usePatientMessages.js (64L): conversation_messages + computeds recentes/totalIn/Out
- usePatientDocuments.js (70L): documents + computeds total/Bytes/tiposCount

Cada composable encapsula a query original do PatientProntuario.vue +
adiciona computeds derivados. Reutilizaveis em outros lugares no futuro
(dashboards, relatorios, etc).

MELISSAPACIENTE.VUE NOVO (1190L) em src/layout/melissa/
- Prefixo CSS .mpa-*. Chrome glass + drawer mobile + right: max(...) >=1024px
  (mesmo padrao MelissaAgendador/Negocio).
- Header: avatar + nome + ageLabel + pronomes + Tag status/convenio +
  risco-elevado pill + actions (Conversar / Editar / Close).
- Subheader condicional: banner risco elevado.
- Body 2-col: sidebar 320px (esquerda, drawer no mobile) + main flex 1.
- Sidebar com 4 cards: Acoes Rapidas / Navegacao 7 tabs / Sub-nav Perfil /
  Vinculos (chips grupos+tags).
- Main: 7 tabs (Visao Geral / Perfil / Prontuario / Agenda / Financeiro /
  Documentos / Conversas). Visao Geral ja mostra 4 KPIs reais via composables.
  Outras 6 abas com placeholders "Em desenvolvimento — Fase X".

MELISSALAYOUT.VUE
- Import MelissaPaciente.
- SECOES.paciente entry novo.
- 'paciente' adicionado ao MELISSA_NON_CONFIG_SLUGS.
- Render condicional com :patient-id="String(route.query.id || '')"
  — navegacao via /melissa/paciente?id=xxx.

ESLint: 0 errors da mudanca. 2 errors pre-existentes em MelissaLayout
(duplicate key 'financeiro' L242, empty block L1130) — nao toquei essas
linhas. PatientProntuario tem outros pre-existentes nao tocados.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:23:48 -03:00
Leonardo f3f0d831d2 Melissa: preview teleport 3-way no Agendador/LinkExterno + chrome 6 paginas
PADRAO PREVIEW 3-WAY (mobile/sidebar/floating)
- Replica o pattern do MelissaNegocio em MelissaAgendador e MelissaLinkExterno.
- Mobile: preview teleporta pro topo do main, acima de tudo (diferente do
  Negocio que vai pro drawer).
- Mid-desktop (1024-1339): teleporta pro fim da sidebar inline.
- Wide-desktop (>=1340): painel flutuante glass fora do fake dialog,
  ancorado a +14px do right edge da .X-page com width 320px.

MELISSAAGENDADOR (.mag-page)
- Importa AgendadorPreview (componente legacy do ConfiguracoesAgendadorPage).
- isWideDesktop ref + matchMedia('(min-width: 1340px)') + previewTarget computed.
- 3 placeholders + Teleport com card mag-w--side mag-w--preview.
- Adiciona right: max(6px, min(50%, calc(100% - 1006px))) em .mag-page no
  @media >=1024px (necessario pra abrir espaco pro floating).

MELISSALINKEXTERNO (.ml-page)
- Restruturacao: sidebar (Como funciona / Boas praticas) movida da DIREITA
  pra ESQUERDA + mobile drawer pattern (botao Menu, Teleport, transitions,
  backdrop) espelhando MelissaAgendador.
- 3-way teleport do preview com placeholders nos 3 alvos.
- ml-side ganha width 320px + scroll proprio.
- Right rule + floating preview CSS.

COMPONENTE NOVO: src/components/cadastro/CadastroExternoPreview.vue (~350L)
- Phone-frame 260px estilo AgendadorPreview replicando o CadastroPacienteExterno
  publico: nav (logo Psi + chip verificado), hero (avatar 38px + nome split
  firstName/lastName em accent + work_description label + clinic name),
  stepper 4 dots (1 active), card etapa 1 (numero decorativo + tag "Etapa
  1 de 4" + title "Sobre voce" + 3 input bars + CTA "Continuar"), powered by.
- Recebe :token e busca info via mesma edge function que o publico
  (get-intake-invite-info), watch refetcha quando token rotaciona.
- Sem token ou sem dados, fallback gracioso pra placeholders ("Profissional"
  + iniciais).

CHROME EM 6 PAGINAS TABULARES (sem preview)
- Apenas o right: max(6px, min(50%, calc(100% - 1006px))) no @media >=1024px,
  fazendo a janela ficar do mesmo tamanho do MelissaAgendador.
- MelissaCadastrosRecebidos (.mcr), MelissaRecorrencias (.mr), MelissaGrupos
  (.mg), MelissaTags (.mt), MelissaCompromissos (.mc), MelissaMedicos (.mm).
- +9 a 12 linhas por arquivo. Cada um nao tinha @media >=1024px ainda.

ESLint: 0 errors da minha mudanca. 2 errors pre-existentes em
MelissaRecorrencias.vue (totalDone unused L235, v-for/v-bind:key L584) -
nao toquei aquelas linhas.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:08:56 -03:00
24 changed files with 7341 additions and 297 deletions
+102 -184
View File
@@ -1,23 +1,18 @@
# HANDOFF — 2026-05-06 (Melissa Pages aplicando blueprint + ConversationDrawer WhatsApp redesign + commits)
# HANDOFF — 2026-05-08 (MelissaPaciente — port completo + iteração de UX)
Documento de continuidade. **Quando voltar, comece lendo esta página.**
> **🟢 ENTREGUE HOJE** — Blueprint tabular aplicado nas **6 Melissa Pages restantes**
> (Compromissos, Grupos, Tags, Médicos, Conversas, Recorrências) + dialogs
> harmonizados com `FloatLabel + IconField + section dividers` + dialogs
> "Pacientes do grupo/tag/médico" com cor primary nos avatares + redesign
> completo do `ConversationDrawer` pra estilo WhatsApp (avatar circular, bg
> "papel de parede", bolhas com tail simulada, time/status overlay no canto,
> compose pill + send circular verde) + fix de cor de tags/grupos no
> MelissaPacientes (`g.cor → g.color` em 20 lugares).
> **🟢 PLANO DE 8 FASES COMPLETO** — `MelissaPaciente.vue` (~2400L) é a versão
> Melissa nativa do `PatientProntuario.vue` legacy (3593L). Todas as 7 abas
> entregues, wire-up final feito (Dialog → route `/melissa/paciente?id=X`).
> 5 composables + utils compartilhados extraídos.
> **🟢 COMMITADO** — Working tree estava com 4 sprints acumulados (~50 arquivos).
> Foram criados **5 commits** lógicos antes do push, do mais antigo pro mais
> recente. Ver seção "Histórico de commits" abaixo.
> **🟢 ITERAÇÃO PÓS-FASE 8** — Várias rodadas de feedback do user com fixes:
> full-width, sidebar "Voltar pra Pacientes" (no lugar de Configurações),
> editar inline, openWhatsapp fix, dialog Lançamento, dialog Nova Sessão
> com `AgendaEventDialog` real, recorrências do paciente.
> **🟡 AINDA PENDENTE** — Sub-sessão 2 do A66 (V2 dialog): user não gostou
> do design do esqueleto entregue em 2026-05-05. Aguarda feedback específico
> antes de iterar. Detalhes na seção "Sessões dedicadas pendentes".
> **🟢 COMMITADO + PUSHED** — Working tree limpa.
---
@@ -29,201 +24,124 @@ Your branch is up to date with 'origin/main'.
nothing to commit, working tree clean
```
(após `git push`. Antes do push: 5 commits ahead.)
---
## 📦 Histórico de commits da sessão (mais recente → mais antigo)
| # | Hash | Resumo |
|---|------|--------|
| 24 | `6ad91e7` | passa preset-commitment-id pro AgendaEventDialog (fix botão Salvar sumido) |
| 23 | `cf1cd67` | pré-popula eventRow com commitment_id + paciente nome/avatar/status |
| 22 | `73788c7` | AgendaEventDialog: lockType auto-seleciona commitment "Sessão" (fix jornada/billing/freq sumidos) |
| 21 | `30d09eb` | AgendaEventDialog: props lockType + lockPatient + slot #headerLeft (aditivos) |
| 20 | `88dff50` | (REVERTIDO em 30d09eb) usa AgendaEventDialog GLOBAL via inject |
| 19 | `b040e15` | header custom do dialog Nova Sessão (ícone + título + nome) |
| 18 | `42a39ed` | dialog Nova Sessão usa "Frequência" estilo AgendaEventDialog |
| 17 | `9e76e4e` | bloco "Recorrências do paciente" na Tab Agenda |
| 16 | `f1d6fba` | dialog nova sessão integra useRecurrence (recorrência semanal) |
| 15 | `a8ab13b` | dialog inline nova sessão + createSession mutation |
| 14 | `21c71f7` | addFinancial navega pra Financeiro + novo botão Agendar |
| 13 | `64005a5` | fix openWhatsapp + dialog inline novo lançamento financeiro |
| 12 | `301a712` | editPatient abre PatientCadastroDialog INLINE (sem sair) |
| 11 | `5d2c389` | fix sidebar cards encolhendo + gap das abas main |
| 10 | `159b80d` | full-width + sidebar "Voltar pra Pacientes" no lugar de Configurações |
| 9 | `71ee51d` | **Fase 8** wire-up final (Dialog → route /melissa/paciente?id=X) |
| 8 | `167e864` | **Fase 7** Tabs Documentos + Conversas (KPIs + embed componentes existentes) |
| 7 | `e7c0f6c` | **Fase 6** Tab Financeiro + mark paid (mutation que legacy não tem) |
| 6 | `8a8d2e0` | **Fase 5** Tab Agenda (KPIs + filtros + grupos por mês + ações) |
| 5 | `1278e93` | **Fase 4** Tab Prontuário MVP (evolução via session.observacoes) |
| 4 | `4fc0e3a` | **Fase 3** Tab Perfil (6 sections stacked + anchors) |
| 3 | `ab7526b` | **Fase 2** Tab Visão Geral (4 KPIs + timeline + msgs + notas) |
| 2 | `df61cc4` | **Fase 1** Foundation (5 composables + skeleton 7 tabs + slug paciente) |
| 1 | `f3f0d83` | (pré-MelissaPaciente) preview teleport 3-way no Agendador/LinkExterno + chrome 6 páginas |
---
## 📦 Histórico de commits criados hoje
## 📂 Arquivos novos / modificados
Em ordem cronológica de criação (mais antigo → mais novo):
### NOVOS — composables + utils compartilhados
- `src/features/patients/composables/usePatientDetail.js` (~108L) — patient + groups + tags
- `src/features/patients/composables/usePatientSessions.js` (~155L) — agenda_eventos + computeds + `updateStatus`/`createSession` mutations
- `src/features/patients/composables/usePatientFinancial.js` (~155L) — financial_records + computeds + `markPaid`/`markUnpaid`/`createRecord` mutations
- `src/features/patients/composables/usePatientMessages.js` (~80L) — conversation_messages + computeds
- `src/features/patients/composables/usePatientDocuments.js` (~110L) — documents + computeds (topType, pendentes, sizeTotalFormatted)
- `src/features/patients/composables/usePatientRecurrences.js` (~110L) — recurrence_rules + cancel/reactivate + ativas/canceladas computeds
- `src/features/patients/utils/patientFormatters.js` (~280L) — fmt* helpers + STATUS_LABEL/SEVERITY + tagStyle (luminance) + DOC_TYPE_LABEL + chConvLabel + recordStatus + RECORD_STATUS_LABEL + WEEKDAY_LABEL + fmtRecurrenceLabel/Fim
1. **`957e912`** — `Melissa polish + Prontuario Visao Geral + agenda historico`
- Sprints B (05-03) + C (05-04) acumulados:
- NotificationDrawer/Item redesign
- Dock pins compose (`useMelissaDockPins`) + cache store global (`melissaCacheStore`)
- MelissaAgenda timeline FullCalendar parity + cards resumo + histórico card
- `useFeriados` cache opt-in
- PatientProntuario aba Visão Geral nova
- DB migration `20260504000001_fix_cancel_notifications_excluido.sql`
- 19 files, +5203 285
### NOVO — página Melissa
- `src/layout/melissa/MelissaPaciente.vue` (~2400L) — 7 abas funcionais, header custom, sidebar com Voltar pra Pacientes, dialog Lançamento inline, dialog Nova Sessão usando AgendaEventDialog real
2. **`6d9b36d`** — `A66 WIP: AgendaEventDialog quebrado em 5 composables + 265 specs + V2 esqueleto`
- 5 composables (1986L total): `agendaEventHelpers`, `useAgendaEventComposer`, `useAgendaEventActions`, `useAgendaEventPickerBilling`, `useAgendaEventLifecycle`
- 5 specs em `__tests__/` (75+76+28+43+43 = **265 testes**, 495/495 passando)
- AgendaEventDialog 3522 → 2632 linhas (-25%)
- `AgendaEventDialogV2.vue` esqueleto (~1100L, 3 zonas) + preview em `/preview/agenda-dialog-v2`
- Backup byte-idêntico em `AgendaEventDialog.vue.bak`
- Dialogs auxiliares: `InsurancePlanQuickCreateDialog`, `ServiceQuickCreateDialog`
- 17 files, +10966 1298
3. **`269b531`** — `Melissa: blueprint tabular + Cadastros/Agendamentos/Pacientes + restore`
- Sprint E (05-05): Blueprint canônico em `blueprints/melissa-table-page-blueprint.md` (~530L, 18 seções)
- MelissaCadastrosRecebidos refator pro blueprint
- **MelissaAgendamentosRecebidos** novo (substitui o embed)
- MelissaPacientes refator parcial (subheader, sombras, status pills coloridas, email/phone colunas próprias, mobile pencil+popover, fix scroll com `min-height: 0`, restore de arquivados)
- `restorePatient` no `patientsRepository`
- 10 files, +4824 301
4. **`98f7252`** — `Melissa: 6 Pages aplicando blueprint + dialogs unificados + Conversa estilo WhatsApp`
- Sprint F (05-06, esta sessão):
- **MelissaCompromissos**: blueprint mantendo row design original (color stripe + name + badges + descrição + meta inline)
- **MelissaGrupos** + **MelissaTags**: blueprint completo + dialog "Pacientes do grupo/tag" com lista vinculada via `patient_group_patient` / `patient_patient_tag`
- **MelissaMedicos**: blueprint + dialog "Pacientes encaminhados" usando cor primary; dialog editar com 4 seções (Identificação/Contato/Localização/Obs) espelhando PatientsCadastroPage
- **MelissaConversas**: subheader, sidebar reestruturada, alerta unlinked no topo, kanban mobile com `min-height` nas colunas, fix bug `filters` é `ref({})` então no script precisa `.value`
- **MelissaRecorrencias**: button list de status, busca por nome do paciente, footer Limpar filtros
- **ConversationDrawer**: redesign WhatsApp (avatar primary, bg "papel de parede", bolhas com tail, time/status overlay, compose pill + send circular #00a884)
- 7 files, +7879 1467
5. **`15103ed`** — `Cleanup: backups antigos removidos + dashboard config + HANDOFF/log`
- Backups `database-novo/backups/2026-03-27` e `2026-03-29` removidos
- `db.config.json` + `generate-dashboard.cjs` + `dashboard.html` atualizados
- HANDOFF.md (estado 05-05) + log.md
- 11 files, +435 87172
### MODIFICADOS — wire-up
- `src/layout/melissa/MelissaLayout.vue` — entry SECOES.paciente + render condicional `<MelissaPaciente :patient-id="String(route.query.id || '')" @close="fecharSecao" />`
- `src/layout/melissa/MelissaPacientes.vue``abrirProntuario` agora navega via `router.push('/melissa/paciente', query: { id })`. Removeu `<PatientProntuario>` Dialog. Watch em `route.query.edit` pra abrir cadastro full quando vem de `MelissaPaciente.editPatient`.
- `src/layout/melissa/MelissaAgenda.vue``abrirProntuarioPorId` igual. Removeu Dialog legacy.
- `src/layout/melissa/composables/useMelissaAgenda.js` — adicionou `onCreateEventoForPatient(patientId)` (não usado mais após reverter inject, mas mantido)
- `src/features/agenda/components/AgendaEventDialog.vue`**3 props aditivas** (lockType, lockPatient, slot #headerLeft) + watch que auto-seleciona commitment "Sessão" quando lockType. **Zero regressão (301 specs passando)**.
---
## 📋 RESUMO da sessão 2026-05-06
## 🟡 PENDENTES PRA PRÓXIMA SESSÃO
### Padrões consolidados nas 6 páginas Melissa restantes
User mencionou: **"tem alguns ajustes pra fazer nessa tela ainda"** após o último fix
do botão Salvar (commit `6ad91e7`). Ajustes específicos não foram detalhados ainda
— próxima sessão começa pelo feedback do user no dialog Nova Sessão.
Cada página agora segue o blueprint:
- **Subheader explicativo** logo abaixo do header (1-2 frases descrevendo a página + ações principais com `<strong>`)
- **Sidebar reestruturada** em 2 zonas:
- `.xx-side` com `bg: var(--m-bg-soft)` + `border-right` (visual de coluna lateral)
- `.xx-side__scroll` (flex 1, overflow auto) com cards `xx-w--side` (margin lateral 12px + sombra)
- `.xx-side__footer` (flex-shrink 0, padding 12px, bg-soft, border-top) com botão **"Limpar filtros"** global
- **Xs inline** ao lado do título de cada filter card (vermelho 18×18, aparece só quando filtro ativo)
- **Transition `xx-clear`** no footer (fade + collapse 240ms)
- **Body sem padding/gap** (sidebar fica colada à esquerda; main column tem padding interno próprio)
- **Mobile drawer** com sidebar teleportada perde bg/border-right (drawer já tem chrome) + footer vira `position: sticky; bottom: 0` com bg blur
### Dialogs harmonizados (Tags / Grupos / Médicos)
Espelhando o pattern do **PatientsCadastroPage > Identidade**:
- **Section dividers**: `<span class="text-[0.7rem] font-bold uppercase tracking-widest text-[var(--p-primary-color)]">` + linha `h-px` primary-tinted
- **Cada campo**: `FloatLabel variant="on"` + `IconField` + `InputIcon` + InputText/Select com `variant="filled"`
- **Grid**: `grid grid-cols-1 gap-6 xl:grid-cols-2 mb-7`
- **Erro inline**: `<small class="text-red-500">` + `pi-exclamation-circle`
- **Footer**: Button PrimeVue padrão (Cancelar secondary text + Salvar com `pi-check`)
- **Bordas dos inputs**: padrão do PrimeVue (sem CSS scoped sobreescrevendo)
### Dialog "Pacientes vinculados" (Tags / Grupos / Médicos)
Pattern unificado:
- **Borda 2px na cor da entidade** (cor da tag/grupo via `:pt root style`); médicos usam `var(--p-primary-color)` (sem cor própria)
- **Header**: avatar quadrado/circular colorido + título com cor da entidade + sub com count
- **Toolbar**: search + count pill colorido
- **Estados**: loading (cor da entidade), erro (vermelho), empty (icon tinted), sem-resultado-de-busca
- **DataTable interna**: Paciente (avatar com iniciais primary-tinted + nome + email) / Telefone / Botão "Abrir" outlined
- **Click "Abrir"** → reusa `PatientCadastroDialog` com `:patient-id`
- **Sem footer "Fechar"** — o X do header é o único botão de fechar
- **X do header** estilizado como `.xx-close` (32×32, bg --m-bg-soft, border, hover bg-soft-hover) via `:pt="{ pcCloseButton: { root: { class: 'xx-pdlg-close-btn' } } }"` + CSS `:global()` (Dialog é teleportado pra body)
### ConversationDrawer redesign (estilo WhatsApp)
- **Header**: avatar circular 40×40 com iniciais + nome em destaque + sub (canal icon + número formatado mono)
- **Container de mensagens**: bg "papel de parede" (`color-mix` bege esverdeado WA + radial-gradient pattern de pontos)
- **Bolhas**:
- Inbound light `#ffffff` / dark `#202c33` — top-left zerado simulando tail
- Outbound light `#d9fdd3` / dark `#005c4b` — top-right zerado simulando tail
- Padding `6/10/18/10` (extra bottom pra meta)
- Border-radius 8px + sombra `0 1px 0.5px rgba(0,0,0,0.13)`
- Detecção dark via `:global(.p-dark) / html.dark / [data-theme="dark"]`
- **Meta** (HH:MM + status checks): `position: absolute` no canto inferior direito DENTRO do balão
- ✓ enviada / ✓✓ entregue / ✓✓ azul `#53bdeb` lida / ✗ vermelho falhou
- **Compose**:
- Botões emoji + templates à esquerda do input
- Textarea com `border-radius: 22px` (pill)
- Botão **Send circular 40×40** verde `#00a884` (cor send WA), translate-up no hover
### Bug fix: cores de tags/grupos no MelissaPacientes
`patientsRepository.listGroups()` e `listTags()` mapeiam `cor → color` (camelCase frontend-friendly). O template do MelissaPacientes lia `g.cor` / `t.cor` (PT-BR) em **20 lugares** — sempre `undefined` → fallback caía no cinza/hex hardcoded. Trocado pra `g.color` / `t.color` via `replace_all`. Outros consumers (PatientsCadastroPage) já usavam `.color` correto, não foram afetados.
### Pendentes conhecidos (não ditos pelo user, mas observados)
- **PatientProntuario.vue legacy (3593L)** continua existindo intocado. Usado por:
- `TherapistDashboard.vue` (homepage role therapist sem Melissa)
- `PatientsListPage.vue` (rota `/therapist/patients`)
- Quando user troca pra Melissa em `/account/profile`, vê a versão nativa
- Pra deletar de vez precisa portar TherapistDashboard + PatientsListPage também
- **Tab Prontuário** é MVP usando `agenda_eventos.observacoes` como evolução. Quando schema clínico (`anamnese`, `clinical_notes`, `plano_terapeutico`) for adicionado, vira o real
- **2 errors pré-existentes em MelissaLayout.vue** (duplicate key 'financeiro' L242, empty block L1130) — não foram tocados durante o port
---
## 🛠️ Sessões dedicadas pendentes
## 🧠 Conhecimento adicional acumulado nesta sessão
### A66 — Refactor `AgendaEventDialog` V2 (3 sub-sessões)
### Decisões arquiteturais
1. **`MelissaPaciente` segue o padrão Melissa** (mesmo prefix `mpa-`, glass chrome, sidebar 320px à esquerda, drawer mobile). Diferente das outras Melissa Pages: **largura total** (sem `right: max(...)`) porque conteúdo (KPIs grid + tabelas + timeline) precisa de espaço.
**Estado**:
- ✅ Sub-sessão 1 (composables) — 5 composables + 265 testes, 495/495 suite passando, AgendaEventDialog 3522→2632 linhas (-25%)
- 🟡 Sub-sessão 2 (template V2) — esqueleto entregue 2026-05-05, **user não gostou do design**, aguarda feedback específico
- ⏳ Sub-sessão 3 (migração nos 9 consumers) — depende do V2 estabilizar
2. **Reuso do `AgendaEventDialog`** via 2 props aditivas (`lockType` + `lockPatient`) + slot `#headerLeft` — caminho A escolhido após discussão honesta sobre drift risk de duplicação.
**Próxima ação**: user dá feedback design → eu itero V2.
3. **Inject vs state local**`MelissaPaciente` injeta `MELISSA_AGENDA_KEY` SÓ pra ler dados pesados (commitmentOptions, workRules, etc) e mantém state LOCAL pro dialog (sessaoDialogOpen/EventRow/StartISO/EndISO). Não colide com dialog global da Agenda.
Perguntas em aberto:
- Estrutura: 3 zonas (PACIENTE/QUANDO/O QUÊ) tá errado? Prefere 2 zonas? 1 coluna scroll? Tabs?
- Hierarquia: hero PACIENTE muito grande/pequeno?
- Densidade: airy demais ou apertado demais?
- Chips de duração/scope/status: muito visuais?
- Mobile: já testou viewport pequeno?
- Referência visual: Win11? Cleaner? Mais como V1? Algum app?
4. **Inicialização do dialog**: precisa passar **TANTO** `determined_commitment_id` no eventRow **QUANTO** prop `presetCommitmentId` separada. O resetForm lê o primeiro pra popular `form.commitment_id`; o lifecycle lê o segundo pra decidir step inicial. Sem ambos, cair em race condition (lifecycle reset desfaz selectCommitment).
5. **Pré-popular paciente_nome/avatar/status no eventRow** é obrigatório pra não-edit — o composer só faz fetch async do nome quando isEdit=true.
### Hotspots de drift no `AgendaEventDialog`
Arquivo tem 5 composables que fazem o trabalho pesado: `useAgendaEventComposer` (state + computeds), `useAgendaEventActions` (save/delete), `useAgendaEventLifecycle` (watchers + init), `useAgendaEventPickerBilling` (selectCommitment, paciente picker), `agendaEventHelpers` (utils). Mexer aqui é seguro pelo coverage de **301 specs**.
### Slug `/melissa/paciente?id=<uuid>`
Registrado em `MelissaLayout.vue` SECOES + adicionado a `MELISSA_NON_CONFIG_SLUGS`. ID vem via query param. Funciona pra deep-link.
---
## PRÓXIMOS PASSOS (sugestão)
## 🛠 Comandos úteis
### 1. Restore arquivados na `PatientsListPage.vue` (layout Rail)
```bash
# Specs do agenda (regression check pro AgendaEventDialog)
npx vitest run src/features/agenda/composables/__tests__
A `PatientsListPage.vue` tem KPI "Arquivados" mas SEM botão Restaurar. Replicar o pattern da MelissaPacientes:
- Helper `isArquivado(p)` (case-insensitive)
- Botão condicional ↶ "Restaurar" baseado em `p.status === 'Arquivado'`
- Click → confirm → `restorePatient(id, { tenantId })` do mesmo repository → toast + refetch
- Toggle visual: ↶ undo primary quando arquivado / 🗑 trash vermelho quando ativo
# Lint só dos arquivos do MelissaPaciente
npx eslint src/layout/melissa/MelissaPaciente.vue src/features/patients/composables/usePatient*.js src/features/patients/utils/patientFormatters.js
### 2. Decidir A66 V2 design
Aguarda feedback. Sem feedback, posso:
- Tentar uma direção alternativa (ex: 1 coluna scroll mais minimalista)
- Comparar com referências externas (Outlook, Cal.com, Linear)
- Voltar pro V1 polido em vez de redesenhar
### 3. Outras Melissa Pages?
Todas as 9 páginas tabulares Melissa já estão alinhadas ao blueprint:
- ✅ Cadastros Recebidos, Agendamentos Recebidos, Pacientes (Sprint E)
- ✅ Compromissos, Grupos, Tags, Médicos, Conversas, Recorrências (Sprint F)
Não há mais páginas pendentes do plano original.
---
## 📚 Tracking persistente
- **A66** — sub-sessão 2 (V2 design) aguardando feedback do user
- **Blueprint tabular Melissa** — referência canônica: `MelissaCadastrosRecebidos.vue`. Todas as 9 páginas alinhadas.
- **Restore pacientes** — implementado no Melissa; replicar no Rail (`PatientsListPage.vue`)
- **Migration aplicada local**: `20260504000001_fix_cancel_notifications_excluido.sql`. Já aplicada no DB local.
---
## 📦 Setup pra retomar
```powershell
# Limpa cache do Vite (recomendado depois de muita mudança em styles)
Remove-Item -Recurse -Force node_modules\.vite -ErrorAction SilentlyContinue
# Sobe dev
# Testar visualmente
npm run dev
# Build sanity check (opcional, mas roda em ~25s)
npm run build
# → http://localhost:5173/melissa/paciente?id=<uuid-real-de-paciente>
```
**Suite de testes** (495 testes incluindo o A66):
```powershell
npm run test
```
**Login**: user com `layout_variant=melissa` no profile pra testar
direto em `/melissa/...`. Pra testar Rail (regressão), troca em
`/account/profile` → terceiro card "Layout".
---
**Estado limpo, push pendente. Quando voltar, próximo passo natural é o feedback do A66 V2 ou o restore na PatientsListPage. Sua escolha.**
## ▶️ Próxima sessão — onde retomar
1. **Ler primeiro**: este HANDOFF.md (você já está nele)
2. **Aguardar feedback do user** sobre ajustes específicos no dialog Nova Sessão (mencionou que tem mais alguma coisa)
3. **Possíveis frentes**:
- Polish do dialog Nova Sessão pós-feedback
- Port do TherapistDashboard pra remover dependência do PatientProntuario legacy
- Schema clínico (anamnese/evolução) pra Tab Prontuário sair do MVP
4. **Antes de mexer em `AgendaEventDialog`**: rodar `npx vitest run src/features/agenda/composables/__tests__` (301 specs) pra confirmar baseline limpo
Boa sessão!
+494
View File
@@ -49,3 +49,497 @@ Touched: none
## [2026-05-08 00:00] session | Melissa cfg-* nativas + temas + cronometro DB
Touched: none
## [2026-05-08 21:00] session | MelissaPaciente iteracao pos-Fase 8 + AgendaEventDialog reuse
Touched: none (iteracao de UX, sem novas paginas wiki)
Detalhes: 16 commits adicionais apos Fase 8 cobrindo ajustes de UX e
funcionalidades novas pedidas pelo user em sequencia:
LAYOUT/CHROME
- Full-width (removido right:max) — prontuario tem KPIs+tabelas+timeline,
precisa espaco. Outras Melissa Pages mantem o constraint.
- Sidebar: substituido botao "Configuracoes" por "Voltar para Pacientes"
(prontuario nao pertence a config). X faz history.back; sidebar btn
forca /melissa/pacientes.
- Cards da sidebar com flex-shrink:0 (estavam encolhendo, cortando conteudo).
- .mpa-tab com display:flex+gap:12px (filhos estavam colados).
FUNCIONALIDADES NOVAS
- editPatient abre PatientCadastroDialog INLINE (antes redirecionava pra
MelissaPacientes?edit=).
- openWhatsapp passa string id (era objeto) + detecta store.error pra
toast warn quando paciente sem telefone.
- addFinancial: dialog inline com createRecord mutation no
usePatientFinancial.
- goAgendar: dialog inline com createSession mutation, depois evoluiu
pra usar AgendaEventDialog real via lockType/lockPatient props.
- Botao "Agendar" novo na sidebar Acoes Rapidas (antes so tinha Lancamento).
DIALOG NOVA SESSAO — evolucao em 4 etapas
1. Inline simples (createSession direto)
2. Frequencia integrando useRecurrence (recorrencia semanal)
3. Frequencia estilo AgendaEventDialog (chips Avulsa/Semanal/Quinzenal/
diasEspecificos + qtd sessoes 4/8/12/personalizar)
4. Header custom (icon + Nova sessao + nome paciente)
5. **Decisao final**: trocar tudo por reuso do AgendaEventDialog real.
User pediu pra ver dialog completo da Agenda dentro do prontuario.
Caminho A escolhido: 2 props aditivas (lockType, lockPatient) + slot
#headerLeft no AgendaEventDialog. 301 specs continuam passando — zero
regressao nos 5 callsites legacy.
BLOCO DE RECORRENCIAS NA TAB AGENDA
- 5o KPI "Recorrencias" (count de ativas).
- Lista de cards mostrando regras com label legivel (fmtRecurrenceLabel)
+ meta (duracao/modalidade/fim/desde) + acoes inline (cancelar/reativar).
- Toggle "Ver canceladas".
DEBUGGING DIALOG (etapas finais)
- jornada/billing/freq sumiram: causa = lockType so setava step=2 sem
inicializar form.commitment_id. Fix: watch chama selectCommitment(sessao).
- Resumo lateral sem nome paciente: causa = nao passar paciente_nome/
avatar/status no eventRow. Fix: pre-popular dos computeds.
- Botao Salvar sumido: causa = footer tem v-if step=2; sem
presetCommitmentId prop, lifecycle ia step=1. Fix: passar
:preset-commitment-id no template.
TOTAL: 24 commits no branch. HANDOFF.md reescrito com estado completo +
historico + arquivos novos/modificados + decisoes arquiteturais +
hotspots de drift. Push pendente.
## [2026-05-08 19:30] session | MelissaPaciente Fase 8 — wire-up final (Dialog -> route)
Touched: none
Detalhes: PLANO DE 8 FASES COMPLETO. Os 2 callsites Melissa do
PatientProntuario.vue legacy (3593L Dialog) trocam por router.push pra
/melissa/paciente?id=X. PatientProntuario continua intocado pros 2
callsites legacy (TherapistDashboard, PatientsListPage) quando user nao
esta no layout Melissa.
MELISSAPACIENTE.VUE — wire-up
- useRouter + useConversationDrawerStore.
- close(): emit + router.push('/melissa/pacientes') (volta pra lista).
- editPatient(): emit + router.push('/melissa/pacientes', query: {edit: id})
pra MelissaPacientes detectar e abrir o cadastroFullDialog automaticamente.
- openWhatsapp(): emit + conversationDrawerStore.openForPatient({id, name,
phone, avatar_url}) — drawer global desce sobre Melissa sem fechar.
- addFinancial(): emit + toast "Em breve" (Fase 9 — dialog inline).
MELISSAPACIENTES.VUE — wire-up
- Removeu import PatientProntuario, refs prontuarioOpen/prontuarioPatient,
template PatientProntuario.
- abrirProntuario(p): router.push('/melissa/paciente', query: {id}).
- onMounted: detecta route.query.edit -> abre cadastroFullDialog +
router.replace pra limpar a query. Permite navegacao MelissaPaciente
-> MelissaPacientes?edit=X -> auto-open do cadastro.
- Comentario header atualizado.
MELISSAAGENDA.VUE — wire-up
- Removeu import PatientProntuario, refs prontuarioOpen/prontuarioPatient,
template PatientProntuario.
- abrirProntuarioPorId(id): router.push('/melissa/paciente', query: {id}).
- abrirProntuarioPaciente() / openProntuario(patient) / item kebab
"Prontuario" todos delegam pra abrirProntuarioPorId.
MELISSALAYOUT.VUE
- Render do <MelissaPaciente> simplificado: so passa @close="fecharSecao".
Acoes edit/open-whatsapp/add-financial agora ficam internas no MelissaPaciente.
ESLint: 0 errors da minha mudanca (9 errors pre-existentes nos arquivos
tocados, mesmos de antes do diff — confirmados via git stash baseline).
PLANO COMPLETO. Status final por fase:
1. Foundation (composables + skeleton) — done (Fase 1)
2. Tab Visao Geral (KPIs ricos + timeline + msgs) — done (Fase 2)
3. Tab Perfil (6 sections stacked + anchors) — done (Fase 3)
4. Tab Prontuario MVP (evolucao via observacoes) — done (Fase 4)
5. Tab Agenda (KPIs + filtros + grupos + acoes) — done (Fase 5)
6. Tab Financeiro (KPIs + tabela + mark paid) — done (Fase 6)
7. Tabs Documentos + Conversas (KPIs + embeds) — done (Fase 7)
8. Wire-up final (Dialog -> route) — done (Fase 8)
PatientProntuario.vue (3593L) NAO foi deletado — continua usado pelo
TherapistDashboard.vue (homepage do role therapist) e PatientsListPage.vue
(rota /therapist/patients fora do Melissa). Quando user troca pra Melissa
em /account/profile, ele ve a versao nativa (MelissaPaciente).
## [2026-05-08 18:30] session | MelissaPaciente Fase 7 — Tabs Documentos + Conversas
Touched: none
Detalhes: Duas tabs entregues numa sessao (sao mais leves: KPIs + embed
de componentes existentes ja testados).
EXTENSAO patientFormatters.js:
- fmtSize(bytes): B/KB/MB/GB legivel
- DOC_TYPE_LABEL: atestado/receita/laudo/encaminhamento/termo/etc
- chConvLabel: whatsapp/sms/email -> WhatsApp/SMS/E-mail
EXTENSAO usePatientDocuments.js:
- topType computed: { tipo, count, label } do mais comum (DOC_TYPE_LABEL)
- pendentes computed: count de status_revisao === 'pendente'
- sizeTotalFormatted computed: fmtSize(totalBytes)
- Import patientFormatters dentro do composable.
EXTENSAO usePatientMessages.js:
- primeiraMensagem computed (mais antiga, [length-1])
- canais computed: Set de m.channel unicos
MELISSAPACIENTE.VUE — script
- Imports: DocumentsListPage, PatientConversationsTab, chConvLabel
- Removido kpiDocumentos (nao usado mais — substituido por
documentsHook.total.value direto)
MELISSAPACIENTE.VUE — Tab Documentos (Fase 7)
- Loading state.
- 4 KPIs adaptativos (so renderizam quando ha dados):
- 01 Total + sizeTotalFormatted
- 02 Mais comum (label do tipo + count) — opcional
- 03 Ultimo + relative + dateBR — opcional
- 04 Revisao pendente (laranja) — opcional, so quando > 0
- DocumentsListPage embedded no card Melissa (mpa-embed) — reusa o
componente existente que ja faz upload/preview/listagem completa.
Wrapper ze-ra padding pra ele preencher tudo.
MELISSAPACIENTE.VUE — Tab Conversas (Fase 7)
- Loading state.
- 4 KPIs (so renderizam quando ha mensagens):
- 01 Mensagens total + canais ("via WhatsApp, SMS")
- 02 Recebidas + % do total
- 03 Enviadas + % do total
- 04 Ultima relative + direction + 1ª contato dim
- CTA "Abrir conversa no drawer" estilo WhatsApp (verde #25d366) que
emite open-whatsapp pro parent (futuro: integra com
conversationDrawerStore.openForPatient na Fase 8).
- PatientConversationsTab embedded no mesmo wrapper mpa-embed —
thread completa com filter/scroll/media.
CSS: ~50L novos pros componentes (mpa-conv-cta + mpa-embed wrapper).
Padrao Melissa: CTA WhatsApp circular pill, embed wrapper transparente.
ESLint: 0 errors da minha mudanca.
PROXIMA: Fase 8 (wire-up final) — substituir Dialog do PatientProntuario
por router.push('/melissa/paciente?id=X') nos 4 callsites Melissa
(MelissaPacientes, MelissaAgenda); decidir se TherapistDashboard e
PatientsListPage tambem migram. PatientProntuario.vue pode ficar (legacy
fallback) ou deletar.
## [2026-05-08 17:30] session | MelissaPaciente Fase 6 — Tab Financeiro completa + mark paid mutation
Touched: none
Detalhes: Tab Financeiro espelha o legacy + adiciona mutation que o
legacy NAO tem (mark/unmark pago direto da tabela).
EXTENSAO patientFormatters.js:
- recordStatus(r): pago | vencido | pendente
- RECORD_STATUS_LABEL map.
- fmtPaymentMethod(v): PIX/Cartao/Dinheiro/Boleto/Transferencia/Convenio
cobrindo variantes.
EXTENSAO usePatientFinancial.js:
- ref `busy` + `_lastPatientId` interno.
- recordsOrdenados computed (DESC por due_date com fallback created_at).
- markPaid(recordId): UPDATE financial_records SET paid_at=NOW() + auto-
reload. Retorna {ok, error?}.
- markUnpaid(recordId): UPDATE SET paid_at=NULL + auto-reload (reverte).
MELISSAPACIENTE.VUE — script
- Imports: recordStatus, RECORD_STATUS_LABEL, fmtPaymentMethod.
- markRecordPaid(record) handler: chama markPaid + toast success/error.
- revertRecordPaid(record): chama markUnpaid + toast.
MELISSAPACIENTE.VUE — Tab Financeiro reescrita
- Loading state.
- Empty state com CTA "Novo lancamento" (botao mpa-quick-btn--cta).
- 3 KPIs (Pago / Pendente com proxVenc / Em atraso com cor adaptativa).
- Header "Lancamentos" com badge count + botao "+ Novo" no canto.
- Tabela 6-col: Vencimento (date mono+rel) | Descricao | Forma | Valor
(mono right) | Status pill colorida (pago verde / vencido vermelho /
pendente azul) | Action button.
- Action: pi-check (verde) pra marcar pago, pi-undo (amarelo) pra reverter.
- border-left adaptativa por status (verde pago / vermelho vencido /
azul pendente).
- Mobile: tabela colapsa em cards 2-col 4-row (date|amount / desc /
method|status / action).
CSS: ~190L novos pros componentes (mpa-fin__table/row/date/desc/method/
amount/status/action + responsive). Padrao Melissa: status pills com
color-mix, JetBrains Mono pra valores, header cell uppercase letter-
spacing.
ESLint: 0 errors da minha mudanca.
## [2026-05-08 16:30] session | MelissaPaciente Fase 5 — Tab Agenda completa
Touched: none
Detalhes: Tab Agenda com KPIs, filtros, agrupamento por mes e acoes
rapidas (mark realizada/falta/cancelar). Espelha o legacy.
EXTENSAO patientFormatters.js: +2 helpers
- fmtHourShort (HH:MM 24h pt-br) e fmtDayShort (DOW abbreviado pt-br
sem ponto) — usados na coluna data dos cards.
EXTENSAO usePatientSessions.js: mutation + busy flag
- Novo ref `busy` pra disable de buttons durante mutation.
- _lastPatientId guardado pra auto-reload depois de mutation.
- Nova funcao `updateStatus(sessionId, novoStatus)` que faz
supabase.from('agenda_eventos').update({status}) + auto-reload da
lista. Retorna {ok, error?}.
MELISSAPACIENTE.VUE — script
- agendaFilter ref ('all' default) + AGENDA_FILTERS array com 6 opcoes
(Todas, Proximas, Passadas, Realizadas, Faltas, Canceladas).
- agendaSessoesFiltradas computed: filtra sessoes por future/past/status.
- agendaAgrupadas computed: agrupa por "Mes de YYYY" mantendo ordem DESC.
- updateSessionStatus(ev, status, msg) handler que chama
sessionsHook.updateStatus + toast de sucesso/erro.
- Removido `void toast` (toast usado de verdade agora).
MELISSAPACIENTE.VUE — Tab Agenda reescrita (substitui placeholder Fase 1)
- 4 KPI cards no padrao Visao Geral (numerados 01-04):
- 01 Total + cap "sessoes registradas"
- 02 Realizadas + cap "% do total"
- 03 Faltas + cap "+ N cancel." (cor vermelha quando > 0, cinza quando 0)
- 04 Proxima + relative + datetime
- 6 filter chips redondas (estilo Melissa: cor primary quando active).
- Empty state contextual (sem sessoes vs filtro vazio).
- Grupos por mes com header (label + badge count).
- Cards com 3 colunas: data column (DOW + dia + hora curta) | main
(status tag + chips modalidade/duracao + relative + titulo + note) |
actions (3 buttons: ok/warn/danger com tooltip + cor adaptativa hover).
- Mobile: stack date+main em 2 cols; actions full-width abaixo.
CSS: ~150L novos pros componentes (mpa-ag__group/list/item/date/main/
actions). Padrao visual Melissa: data column estilo calendario, actions
hover muda cor por intent (verde realiz / amarelo falta / vermelho cancel).
ESLint: 0 errors da minha mudanca.
## [2026-05-08 15:30] session | MelissaPaciente Fase 4 — Tab Prontuario MVP
Touched: none
Detalhes: O legacy PatientProntuario.vue tem a aba Prontuario como
PLACEHOLDER ("Em breve"). MVP entregue aqui supera o legacy: usa
agenda_eventos.observacoes como nota evolutiva (pq nao tem schema de
anamnese/clinical_notes ainda).
ESTADO + COMPUTEDS adicionados:
- pronFilter ref ('com-evolucao' default) + PRON_FILTERS array com 5
opcoes (Com evolucao, Todas, Realizadas, Faltas, Cancelamentos).
- pronSessions computed: filtra sessions por status/observacoes presentes.
- sessoesComEvolucao computed: count de sessoes com observacoes nao-vazia.
TEMPLATE Tab Prontuario (substitui placeholder Fase 1):
- Hint banner top: "Prontuario em construcao", explica que usa observacoes
de sessoes como historico evolutivo.
- 4 mini-stats em grid responsivo: com evolucao / realizadas / faltas /
total. Cada uma colorida + icone + value 800.
- 5 filter chips redondas (estilo Melissa): com-evolucao default; troca
pra todas/realiz/falt/cancel.
- Empty state contextual:
- Se nao tem sessoes: "Quando atender este paciente..."
- Se filtro 'com-evolucao' e zero: "Use o campo Observacoes ao editar
sessao..."
- Outro filtro: "Tente outro filtro acima."
- Lista de sessoes (pron-list) com:
- border-left colorida por status (verde realiz / vermelho falta /
amarelo cancel / cinza default)
- head com data + relative + chips status/modalidade/duracao
- titulo opcional (titulo_custom || titulo)
- block "Evolucao" quando tem observacoes (background medium, border-
left primary, label uppercase com icone, texto pre-wrap)
- mensagem "Sem evolucao registrada" italico cinza quando nao tem
- Roadmap card (border-dashed) listando 4 features futuras: anamnese
estruturada / plano terapeutico / evolucao por temas / assinatura
digital + LGPD Art. 18.
CSS: ~200L novos pros componentes (mpa-pron-hint/stats/filters/list/
item/roadmap). Padrao visual Melissa: chips redondas estilo MelissaTags,
border-left adaptativa, monospace inutilizado.
ESLint: 0 errors da minha mudanca.
## [2026-05-08 14:30] session | MelissaPaciente Fase 3 — Tab Perfil (6 sections stacked)
Touched: none
Detalhes: Substituiu o placeholder da aba Perfil por 6 sections stacked
com anchors no MelissaPaciente. Diferente do PatientProntuario legacy que
usava PrimeVue Accordion (1 painel aberto por vez), o Melissa nativo
mostra todos os 6 cards stacked com scroll suave do sidebar sub-nav pra
cada anchor. Mais legivel em desktop, mais rapido pra escanear.
EXTENSAO de patientFormatters.js: +5 formatters
- pickField (ja existia computed local; agora helper compartilhavel)
- onlyDigits, fmtCPF (000.000.000-00), fmtRG (passthrough), fmtPhoneMobile
((XX) 9XXXX-XXXX), fmtGender (Masculino/Feminino/Nao-binario/Outro),
fmtMarital (Solteiro/Casado/Divorciado/Viuvo/Uniao estavel).
MELISSAPACIENTE.VUE — script:
- 30+ field computeds usando pickField (cobre snake_case + camelCase do
schema): birthValue, telefone/Alternativo, email/Alternativo, genero,
estadoCivil, naturalidade, ondeNosConheceu, encaminhadoPor, observacoes,
notasInternas + 8 campos de endereco (cep/pais/cidade/estado/endereco/
numero/bairro/complemento) + 5 dados adicionais (escolaridade/profissao/
nomeParente/grauParentesco/telefoneParente) + 4 responsavel.
- groupNames/groupLabel/groupCountLabel pra Origem.
- scrollToProfileSection(key) que liga sidebar sub-nav -> nextTick ->
scrollIntoView do anchor #mpa-perfil-XXX. Em mobile fecha o drawer.
MELISSAPACIENTE.VUE — template Tab Perfil:
- 1. Informacoes Pessoais: 2-col (Dados de cadastro: nome/data nasc com
idade/genero/estado civil/CPF/RG/naturalidade) + (Contato: tel/tel-alt
com tel: links + e-mail principal/alt com mailto: + Origem: grupos/tags
chips/onde nos conheceu/encaminhado por). Observacoes full-width quando
preenchido.
- 2. Endereco: grid 2-col com 8 fields (CEP/pais/cidade/estado/endereco/
numero/bairro/complemento).
- 3. Dados Adicionais: grid 2-col com escolaridade/profissao/parente/grau/
tel parente.
- 4. Responsavel: 1-col com nome/CPF/tel + observacao block textual.
- 5. Anotacoes Internas: card com hint lock + textblock min-height.
- 6. Sessoes: lista compacta scrollable (max-height 360px) com titulo/
data/duracao/modalidade chips + tag status.
CSS: ~250L novos pros componentes (mpa-fields/field-row/field-grid-2/
field-block/sess/sess-list). Pattern visual Melissa: cards com label
uppercase, separadores horizontais sutis, links com cor primary, monospace
pra CPF/RG/CEP.
ESLint: 0 errors da minha mudanca.
## [2026-05-08 13:00] session | MelissaPaciente Fase 2 — Tab Visao Geral completa
Touched: none
Detalhes: Reescreveu a aba Visao Geral do MelissaPaciente substituindo o
placeholder por uma versao 1:1 do PatientProntuario.vue legado, mas com
estilo Melissa nativo.
NOVO: src/features/patients/utils/patientFormatters.js (~165L)
- Helpers compartilhaveis extraidos do PatientProntuario: parseDateLoose,
fmtDateBR, fmtDateTimeBR, fmtCurrency, fmtRelative, sessionDuration,
calcAge. STATUS_LABEL/SEVERITY pra sessoes. tagStyle com luminance auto
(texto preto/branco baseado em contraste WCAG-ish). Sera usado pelas
Fases 3-7 e finalmente pelo PatientProntuario tambem (Fase 8).
EXTENSAO de composables:
- usePatientSessions ganha computed `ultimasAtendidas` (top 6 sessoes
realizadas/faltadas/canceladas pra Timeline). Refinou totalRealizadas/
Faltas/Canceladas pra usar regex (cobre variantes pt-br).
- usePatientFinancial ganha computed `statusFinanceiro` ({ emDia, proxVenc,
totalPendente, totalPago, vencidos }) pra alimentar o KPI 02 com info
detalhada.
MELISSAPACIENTE.VUE — Visao Geral (Fase 2 done):
- 4 KPI cards ricos (era 4 simples na Fase 1):
- 01 Sessoes: realizadas + total + faltas + cancel.
- 02 Pagamento: emDia/atraso + proxVenc + pendente, com cor adaptativa
(vermelho quando atrasado, primary quando ok).
- 03 Proxima sessao: relative + datetime + modalidade.
- 04 Mensagens: ultimaMensagem relative + direction + count.
- Grid 2-col abaixo: Timeline (1.4fr) + coluna direita (1fr) com
Mensagens recentes + Notas/observacoes.
- Timeline com dot colorido por status (verde/vermelho/amarelo) +
STATUS_LABEL/SEVERITY do utils + chips modalidade/duracao + nota
observacoes inline.
- Mensagens recentes com border-left colorida (verde=in / azul=out) +
meta direction + relative + body 3-line clamp.
- Notas e observacoes com card papel + label uppercase + icone lock
pras internas.
- Removeu kpiEmAberto/Atrasado nao usados (statusFinanceiro encapsula).
ESLint: 0 errors. Working tree limpa antes do commit.
## [2026-05-08 11:30] session | MelissaPaciente Fase 1 (foundation: composables + skeleton + slug)
Touched: none (sem mudanca de wiki)
Detalhes: User escolheu "Full rewrite Melissa nativo" pra portar
PatientProntuario.vue (3593L Dialog) pro Melissa. Plano em 8 fases (2-8 sao
cada tab/wireup, sessao dedicada). Fase 1 entregue:
5 COMPOSABLES NOVOS em src/features/patients/composables/:
- usePatientDetail.js (108L) — patients + groups + tags via 4 queries
(getPatientById, getPatientRelations, getGroupsByIds, getTagsByIds).
Espelha 1:1 a logica de loadDetail() do PatientProntuario L893-953.
- usePatientSessions.js (83L) — agenda_eventos limit 100 ordenado desc +
computeds proximaSessao/ultimaSessao/totalSessoes/totalRealizadas/
totalFaltas/totalCanceladas.
- usePatientFinancial.js (82L) — financial_records (type=receita) limit 100
+ computeds totalRecebido/EmAberto/Atrasado/ultimoPago.
- usePatientMessages.js (64L) — conversation_messages limit 200 + computeds
recentes (top 4)/totalIn/totalOut/ultimaMensagem.
- usePatientDocuments.js (70L) — documents (deleted_at IS NULL) limit 200
+ computeds total/totalBytes/tiposCount/ultimo.
MELISSAPACIENTE.VUE NOVO (1190L) em src/layout/melissa/:
- Prefixo CSS .mpa-* (Melissa PAciente). Chrome glass + drawer mobile +
right: max(...) >=1024px (mesmo padrao MelissaAgendador/Negocio).
- Header: avatar + nome + ageLabel + pronomes + status/convenio Tag +
risco-elevado pill + actions (Conversar / Editar / Close).
- Subheader condicional: banner risco elevado.
- Body 2-col: sidebar 320px (esquerda, drawer no mobile) + main flex 1.
- Sidebar contém 4 cards: Acoes Rapidas (Conversar/Editar/Lancamento) +
Navegacao (7 tabs com icones coloridos) + Sub-nav Perfil (visivel so
quando aba Perfil ativa, 6 secoes) + Vinculos (chips de grupos+tags).
- Main: 7 tabs com placeholders ("Em desenvolvimento — Fase X"). Aba
Visao Geral ja mostra 4 KPIs reais via composables (sessoes totais,
em aberto, mensagens, documentos).
- Props :patient-id; emits close/edit/add-financial/open-whatsapp.
- Watch immediate em props.patientId, dispara loadAll() via Promise.all
dos 5 composables.
MELISSALAYOUT.VUE atualizado:
- Import MelissaPaciente.
- SECOES.paciente entry novo (label/icon/descricao).
- 'paciente' adicionado ao MELISSA_NON_CONFIG_SLUGS.
- Render condicional com :patient-id="String(route.query.id || '')" —
navegacao via /melissa/paciente?id=xxx.
NAO ALTERADO: PatientProntuario.vue continua intocado nos 4 callsites
(TherapistDashboard, MelissaAgenda, MelissaPacientes, PatientsListPage).
Migration acontece nas Fases 2-8. Fase 8 troca os callsites no Melissa.
ESLint: 0 errors da minha mudanca. 2 errors pre-existentes em MelissaLayout
(duplicate key 'financeiro' L242, empty block L1130) — nao toquei aquelas
linhas. PatientProntuario tem 2 outros pre-existentes. Working tree:
MelissaLayout.vue + 6 arquivos novos.
## [2026-05-08 09:30] session | Chrome+preview em 7 paginas Melissa (LinkExterno preview novo)
Touched: none (sem mudanca de wiki - aplicacao do pattern existente)
Detalhes: Aplicou o chrome `right: max(6px, min(50%, calc(100% - 1006px)))`
em 6 paginas tabulares (CadastrosRecebidos .mcr / Recorrencias .mr / Grupos
.mg / Tags .mt / Compromissos .mc / Medicos .mm) - so o tamanho de janela,
sem preview, conforme pedido pelo user. Adicionou novo @media (min-width:
1024px) ao final de cada arquivo (cada um nao tinha esse breakpoint ainda).
MelissaLinkExterno (.ml) ganhou tratamento completo: chrome + sidebar
restruturada (2-col com aside agora a ESQUERDA, antes era a direita) +
mobile drawer pattern (Teleport pro #ml-mobile-drawer-target, transitions,
backdrop, botao Menu mobile-only) + 3-way teleport do preview (mobile=topo
do main / mid-desktop=bottom da sidebar / wide-desktop>=1340=floating glass).
Sidebar agora com Como funciona + Boas praticas (movidos da .ml-side direita)
+ scroll proprio. Sem cfg toggle (nao havia necessidade conceitual).
Componente novo: src/components/cadastro/CadastroExternoPreview.vue (~350L).
Phone-frame 260px estilo AgendadorPreview replicando o CadastroPacienteExterno
publico: nav (logo Psi + chip verificado), hero (avatar 38px + nome split
firstName/lastName em accent + work_description label + clinic name), stepper
4 dots (1 active), card etapa 1 (numero decorativo + tag "Etapa 1 de 4" +
title "Sobre voce" + 3 input bars + CTA "Continuar"), powered by. Recebe
:token prop e busca info do convite via mesma edge function que o publico
(get-intake-invite-info), watch refetcha quando token rotaciona. Sem token
ou sem dados, fallbacks pra "Profissional" + iniciais.
ESLint: 0 errors da minha mudanca. 2 errors pre-existentes em
MelissaRecorrencias.vue (totalDone unused L235, v-for/v-bind:key L584) -
nao toquei aquelas linhas. Working tree: 9 arquivos modificados +
src/components/cadastro/ (untracked). Nao commitado, nao testado em browser.
## [2026-05-08 07:55] session | MelissaAgendador preview celular teleport 3-way
Touched: none (aplicacao do padrao MelissaNegocio - sem mudanca de wiki)
Detalhes: Replicou o padrao floating preview do MelissaNegocio em
MelissaAgendador.vue (+145L). Importou AgendadorPreview (phone-frame ja
existente do legacy ConfiguracoesAgendadorPage). Adicionou ref isWideDesktop
+ matchMedia('(min-width: 1340px)') + computed previewTarget com 3-way
branching: mobile -> #mag-main-preview-target (topo do main, acima de tudo,
DIFERENTE do MelissaNegocio que vai pro drawer); mid-desktop (1024-1339) ->
#mag-sidebar-preview-target (dentro da sidebar apos Status/Resumos);
wide-desktop (>=1340) -> #mag-floating-preview-target (painel flutuante glass
fora do fake dialog, 320px de largura, ancorado a +14px do right edge da
.mag-page). Adicionou regra `right: max(6px, min(50%, calc(100% - 1006px)))`
em .mag-page no @media >=1024px (necessario pra abrir espaco a direita pro
floating). CSS: .mag-floating-preview com glass igual ao fake dialog;
placeholders com display:contents; hide rules por breakpoint. Card de preview
usa mag-w--side e perde fundo/borda no floating (glass do painel ja faz papel).
ESLint 0 errors. Working tree: src/auto-imports.d.ts (auto-gerado) +
MelissaAgendador.vue. Nao commitado, nao testado em browser ainda.
@@ -0,0 +1,476 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Phone-frame preview da página pública de auto-cadastro do paciente
| (CadastroPacienteExterno). Mostra como o paciente o link gerado
| em MelissaLinkExterno.
|
| Recebe :token e busca info do convite (terapeuta + clínica) via mesma
| edge function que o público usa (get-intake-invite-info). Sem token
| ou sem dados, renderiza placeholders ilustrativos.
|--------------------------------------------------------------------------
| © 2026 Agência Psi
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, watch, onMounted } from 'vue';
import { supabase } from '@/lib/supabase/client';
const props = defineProps({
token: { type: String, default: '' }
});
const TOKEN_RX = /^[0-9a-f]{32}$|^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const info = ref(null);
const loading = ref(false);
const avatarFailed = ref(false);
const therapist = computed(() => info.value?.therapist || null);
const clinic = computed(() => info.value?.clinic || null);
const displayName = computed(() => therapist.value?.display_name || 'Profissional');
const firstName = computed(() => String(displayName.value || '').trim().split(/\s+/)[0] || '');
const lastName = computed(() => String(displayName.value || '').trim().split(/\s+/).slice(1).join(' '));
const avatar = computed(() => (!avatarFailed.value ? therapist.value?.avatar_url || '' : ''));
const initials = computed(() => {
const parts = String(displayName.value || '').trim().split(/\s+/).filter(Boolean);
if (!parts.length) return '·';
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
});
const WORK_DESCRIPTION_LABEL = {
psicologo_clinico: 'Psicólogo(a) Clínico(a)',
psicanalista: 'Psicanalista',
psiquiatra: 'Psiquiatra',
psicoterapeuta: 'Psicoterapeuta',
neuropsicologo: 'Neuropsicólogo(a)',
psicologo_organizacional: 'Psic. Organizacional',
psicologo_escolar: 'Psic. Escolar',
psicologo_hospitalar: 'Psic. Hospitalar',
coach_mentor: 'Coach / Mentor(a)',
terapeuta_holistico: 'Terapeuta Holístico(a)',
outro: 'Profissional da saúde mental'
};
const workLabel = computed(() => {
const key = therapist.value?.work_description;
if (!key) return '';
return WORK_DESCRIPTION_LABEL[key] || 'Profissional da saúde mental';
});
async function fetchInviteInfo() {
if (!props.token || !TOKEN_RX.test(props.token)) {
info.value = null;
return;
}
loading.value = true;
avatarFailed.value = false;
try {
const { data, error } = await supabase.functions.invoke('get-intake-invite-info', {
body: { token: props.token }
});
if (error) return;
if (data?.ok && data.info) info.value = data.info;
} catch {
/* silencioso — preview cai pros placeholders */
} finally {
loading.value = false;
}
}
watch(() => props.token, fetchInviteInfo);
onMounted(fetchInviteInfo);
</script>
<template>
<!-- Frame de celular -->
<div class="phone-frame">
<div class="phone-notch" />
<div class="cep-root">
<!-- Nav -->
<div class="cep-nav">
<div class="cep-nav__brand">
<span class="cep-nav__logo">Ψ</span>
<span class="cep-nav__name">Agência PSI</span>
</div>
<span class="cep-nav__chip">
<i class="pi pi-check" />
</span>
</div>
<!-- Hero -->
<div class="cep-hero">
<div class="cep-hero__eyebrow">Você foi convidado(a) por</div>
<div class="cep-hero__identity">
<div class="cep-hero__avatar-wrap">
<img
v-if="avatar"
:src="avatar"
:alt="displayName"
class="cep-hero__avatar"
@error="avatarFailed = true"
/>
<div v-else class="cep-hero__avatar cep-hero__avatar--initials">{{ initials }}</div>
</div>
<div class="cep-hero__content">
<div v-if="clinic?.name" class="cep-hero__clinic">{{ clinic.name }}</div>
<div class="cep-hero__title">
<span>{{ firstName }}</span>
<span v-if="lastName" class="cep-accent">{{ lastName }}</span>
</div>
<div v-if="workLabel" class="cep-hero__work">{{ workLabel }}</div>
</div>
</div>
</div>
<!-- Stepper -->
<div class="cep-stepper">
<div class="cep-stepper__item is-active">
<span class="cep-stepper__num">01</span>
</div>
<span class="cep-stepper__line" />
<div class="cep-stepper__item">
<span class="cep-stepper__num">02</span>
</div>
<span class="cep-stepper__line" />
<div class="cep-stepper__item">
<span class="cep-stepper__num">03</span>
</div>
<span class="cep-stepper__line" />
<div class="cep-stepper__item">
<span class="cep-stepper__num">04</span>
</div>
</div>
<!-- Step card -->
<article class="cep-card">
<div class="cep-card__num" aria-hidden="true">01</div>
<div class="cep-card__head">
<span class="cep-card__tag">Etapa 1 de 4</span>
<span class="cep-card__progress">25%</span>
</div>
<div class="cep-card__body">
<h2 class="cep-card__title">
<span>Sobre</span>
<span class="cep-accent">você</span>
</h2>
<p class="cep-card__desc">Preencha seus dados básicos.</p>
<div class="cep-fields">
<div class="cep-field">
<label class="cep-field__label">Nome completo</label>
<div class="cep-input"></div>
</div>
<div class="cep-field">
<label class="cep-field__label">E-mail</label>
<div class="cep-input"></div>
</div>
<div class="cep-field">
<label class="cep-field__label">Telefone</label>
<div class="cep-input"></div>
</div>
</div>
<button type="button" class="cep-cta" disabled>
<span>Continuar</span>
<i class="pi pi-arrow-right text-[0.55rem]" />
</button>
</div>
</article>
<p class="cep-powered">Powered by <strong>Agência Psi</strong></p>
</div>
</div>
</template>
<style scoped>
/* ── Frame de celular ──────────────────── */
.phone-frame {
position: relative;
width: 260px;
min-height: 500px;
margin: 0 auto;
border-radius: 2.5rem;
border: 8px solid #1e293b;
background: #1e293b;
box-shadow:
0 0 0 2px #334155,
0 32px 64px rgba(0, 0, 0, 0.35),
0 8px 24px rgba(0, 0, 0, 0.2);
overflow: hidden;
}
.phone-notch {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 72px;
height: 10px;
background: #1e293b;
border-radius: 0 0 10px 10px;
z-index: 10;
}
/* ── Root (mimica fundo do CadastroExterno light) ─── */
.cep-root {
background: linear-gradient(180deg, #fafafa 0%, #f1f5f9 100%);
min-height: 100%;
padding: 12px 10px 14px;
overflow-y: auto;
max-height: 560px;
display: flex;
flex-direction: column;
gap: 10px;
}
/* ── Nav ──────────────────────────────── */
.cep-nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 6px 0;
}
.cep-nav__brand {
display: flex;
align-items: center;
gap: 4px;
color: #111827;
font-weight: 800;
}
.cep-nav__logo {
font-size: 0.92rem;
color: #6366f1;
}
.cep-nav__name {
font-size: 0.62rem;
letter-spacing: 0.04em;
}
.cep-nav__chip {
display: grid;
place-items: center;
width: 16px;
height: 16px;
border-radius: 50%;
background: #10b98122;
color: #10b981;
font-size: 0.5rem;
}
.cep-nav__chip > i { font-size: 0.5rem; }
/* ── Hero ──────────────────────────────── */
.cep-hero {
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px 4px 0;
}
.cep-hero__eyebrow {
font-size: 0.52rem;
font-weight: 700;
color: #6366f1;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.cep-hero__identity {
display: flex;
align-items: center;
gap: 8px;
}
.cep-hero__avatar-wrap {
flex-shrink: 0;
}
.cep-hero__avatar {
width: 38px;
height: 38px;
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
background: #f1f5f9;
object-fit: cover;
display: grid;
place-items: center;
color: #6366f1;
font-weight: 800;
font-size: 0.62rem;
}
.cep-hero__avatar--initials { background: linear-gradient(135deg, #e0e7ff 0%, #c7d2fe 100%); }
.cep-hero__content {
flex: 1;
min-width: 0;
}
.cep-hero__clinic {
font-size: 0.5rem;
color: #6b7280;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.cep-hero__title {
font-size: 0.78rem;
font-weight: 800;
letter-spacing: -0.02em;
color: #111827;
line-height: 1.1;
}
.cep-hero__title .cep-accent {
color: #6366f1;
margin-left: 3px;
}
.cep-hero__work {
font-size: 0.55rem;
color: #6b7280;
margin-top: 1px;
}
/* ── Stepper ───────────────────────────── */
.cep-stepper {
display: flex;
align-items: center;
justify-content: center;
gap: 2px;
padding: 2px 0;
}
.cep-stepper__item {
width: 22px;
height: 22px;
border-radius: 50%;
background: #fff;
border: 1.5px solid #e5e7eb;
display: grid;
place-items: center;
color: #9ca3af;
transition: all 150ms ease;
}
.cep-stepper__item.is-active {
background: #6366f1;
border-color: #6366f1;
color: #fff;
box-shadow: 0 4px 10px rgba(99, 102, 241, 0.32);
}
.cep-stepper__num {
font-size: 0.5rem;
font-weight: 800;
}
.cep-stepper__line {
width: 8px;
height: 1.5px;
background: #e5e7eb;
}
/* ── Card de etapa ─────────────────────── */
.cep-card {
position: relative;
background: #fff;
border-radius: 0.875rem;
border: 1px solid rgba(0, 0, 0, 0.06);
box-shadow:
0 6px 18px rgba(0, 0, 0, 0.06),
0 2px 6px rgba(0, 0, 0, 0.04);
overflow: hidden;
padding: 10px 10px 12px;
}
.cep-card__num {
position: absolute;
top: 4px;
right: 8px;
font-size: 2rem;
font-weight: 900;
color: #6366f1;
opacity: 0.08;
line-height: 1;
pointer-events: none;
}
.cep-card__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.cep-card__tag {
font-size: 0.5rem;
font-weight: 700;
color: #6366f1;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.cep-card__progress {
font-size: 0.5rem;
font-weight: 700;
color: #6b7280;
}
.cep-card__title {
font-size: 0.85rem;
font-weight: 800;
letter-spacing: -0.02em;
color: #111827;
line-height: 1.1;
margin-bottom: 3px;
}
.cep-card__title .cep-accent {
color: #6366f1;
margin-left: 3px;
}
.cep-card__desc {
font-size: 0.55rem;
color: #6b7280;
line-height: 1.4;
margin-bottom: 8px;
}
/* ── Fields ────────────────────────────── */
.cep-fields {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 10px;
}
.cep-field {
display: flex;
flex-direction: column;
gap: 2px;
}
.cep-field__label {
font-size: 0.5rem;
color: #374151;
font-weight: 600;
}
.cep-input {
height: 18px;
border: 1px solid #e5e7eb;
border-radius: 0.375rem;
background: #f9fafb;
}
/* ── CTA ───────────────────────────────── */
.cep-cta {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 7px 10px;
background: #6366f1;
color: #fff;
border: none;
border-radius: 0.5rem;
font-size: 0.62rem;
font-weight: 700;
cursor: default;
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.28);
}
/* ── Powered ───────────────────────────── */
.cep-powered {
text-align: center;
font-size: 0.5rem;
color: #9ca3af;
margin-top: 4px;
}
.cep-powered strong {
color: #6b7280;
font-weight: 700;
}
</style>
@@ -105,7 +105,24 @@ const props = defineProps({
feriados: { type: Array, default: () => [] },
// Rota para cadastro completo de paciente (abre em nova aba)
newPatientRoute: { type: String, default: '' }
newPatientRoute: { type: String, default: '' },
// ── Locks aditivos (default false — comportamento atual) ──────────
// Usados pelo MelissaPaciente.vue pra abrir esse dialog ja em
// contexto de Sessao + paciente fixo do prontuario. Os 5 callsites
// existentes (TherapistDashboard, PatientsListPage, MelissaAgenda,
// MelissaAgendamentosRecebidos, MelissaLayout) seguem com defaults.
//
// lockType=true: pula o step de escolha de tipo (commitment cards) e
// vai direto pro form. Espera que eventRow ja venha com tipo+commitment
// resolvidos (no MelissaPaciente: tipo='sessao').
//
// lockPatient=true: esconde os botoes "trocar"/"limpar" do paciente
// e mostra o icon de lock (mesma UX do patientLocked computed que
// ja existia pra modo edit, agora cobre tambem cenarios "criar sessao
// pra paciente fixo").
lockType: { type: Boolean, default: false },
lockPatient: { type: Boolean, default: false }
});
const emit = defineEmits(['update:modelValue', 'save', 'delete', 'updateSeriesEvent', 'editSeriesOccurrence', 'updated']);
@@ -176,6 +193,31 @@ const {
resetForm
} = _composer;
// Lock de tipo: quando lockType=true (ex: MelissaPaciente), simula o
// click no card "Sessão" do step 1. Chama selectCommitment com o
// commitment native_key='session' — isso seta form.commitment_id +
// extra_fields + step.value=2. Sem isso, pular pro step 2 sem
// commitment_id deixaria isSessionEvent=false e esconderia jornada,
// billing (particular/convenio/gratuito) e frequencia.
//
// Re-roda quando commitmentOptions popula tarde (caso usual: dialog
// abre antes do tenant load terminar). Idempotente: so chama se ainda
// nao tem commitment_id ou step != 2.
watch(
[() => props.lockType, () => props.modelValue, () => props.commitmentOptions],
([locked, open]) => {
if (!locked || !open) return;
const sessao = commitmentCards.value.find((c) => c.native_key === 'session');
if (!sessao) return;
if (!form.value.commitment_id || form.value.commitment_id !== sessao.id) {
selectCommitment(sessao);
} else if (step.value !== 2) {
step.value = 2;
}
},
{ immediate: true }
);
// ── recorrência: opções estáticas (consts data — não migradas) ──
const freqOpcoes = [
{ value: 'avulsa', label: 'Avulsa' },
@@ -718,13 +760,18 @@ const googleCalendarUrl = computed(() => {
<Dialog v-model:visible="visible" modal :draggable="false" :dismissableMask="true" :style="{ width: '1000px', maxWidth: '96vw' }" :breakpoints="{ '960px': '96vw', '640px': '98vw' }" class="agenda-event-composer" pt:mask:class="backdrop-blur-xs">
<template #header>
<div class="w-full flex items-center justify-between gap-3">
<div class="flex items-center gap-2 min-w-0">
<div class="header-dot shrink-0" :style="selectedCommitment?.bg_color ? { background: `#${selectedCommitment.bg_color}` } : {}" />
<div class="min-w-0">
<div class="font-semibold truncate text-base">{{ headerTitle }}</div>
<div v-if="step === 2" class="text-xs text-color-secondary truncate">{{ previewRange }}</div>
<!-- Slot headerLeft (override de chamadas como MelissaPaciente
que precisam icon+title+subtitle custom). Default: dot
colorido + headerTitle + previewRange. -->
<slot name="headerLeft">
<div class="flex items-center gap-2 min-w-0">
<div class="header-dot shrink-0" :style="selectedCommitment?.bg_color ? { background: `#${selectedCommitment.bg_color}` } : {}" />
<div class="min-w-0">
<div class="font-semibold truncate text-base">{{ headerTitle }}</div>
<div v-if="step === 2" class="text-xs text-color-secondary truncate">{{ previewRange }}</div>
</div>
</div>
</div>
</slot>
<!-- actions moved to footer -->
</div>
@@ -733,9 +780,9 @@ const googleCalendarUrl = computed(() => {
<!-- ConfirmDialog renderizado na página pai para evitar conflito de z-index com o Dialog -->
<!-- -->
<!-- STEP 1 escolha o tipo -->
<!-- STEP 1 escolha o tipo (oculto se lockType) -->
<!-- -->
<div v-if="step === 1" class="p-2">
<div v-if="step === 1 && !lockType" class="p-2">
<div class="mb-4 text-sm text-color-secondary">Selecione o tipo de compromisso para começar.</div>
<Message v-if="isDayBlocked" severity="warn" class="mb-4" :closable="false">
@@ -863,9 +910,9 @@ const googleCalendarUrl = computed(() => {
</div>
</div>
<div class="flex gap-1 shrink-0">
<Button v-if="!patientLocked" icon="pi pi-pencil" severity="secondary" outlined size="small" class="rounded-full h-8 w-8" v-tooltip.top="'Trocar'" @click="openPacientePicker" />
<Button v-if="!patientLocked" icon="pi pi-times" severity="secondary" text size="small" class="rounded-full h-8 w-8" v-tooltip.top="'Limpar'" @click="clearPaciente" />
<span v-if="patientLocked" v-tooltip.top="'Paciente não pode ser alterado após criação'" class="flex items-center gap-1 text-xs text-color-secondary px-2"><i class="pi pi-lock" /></span>
<Button v-if="!patientLocked && !lockPatient" icon="pi pi-pencil" severity="secondary" outlined size="small" class="rounded-full h-8 w-8" v-tooltip.top="'Trocar'" @click="openPacientePicker" />
<Button v-if="!patientLocked && !lockPatient" icon="pi pi-times" severity="secondary" text size="small" class="rounded-full h-8 w-8" v-tooltip.top="'Limpar'" @click="clearPaciente" />
<span v-if="patientLocked || lockPatient" v-tooltip.top="lockPatient ? 'Paciente do prontuário' : 'Paciente não pode ser alterado após criação'" class="flex items-center gap-1 text-xs text-color-secondary px-2"><i class="pi pi-lock" /></span>
</div>
</div>
@@ -0,0 +1,108 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| 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).
|--------------------------------------------------------------------------
*/
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 }));
}
export function usePatientDetail() {
const patient = ref(null);
const groups = ref([]);
const tags = ref([]);
const loading = ref(false);
const loadError = ref('');
async function load(id) {
if (!id) {
patient.value = null;
groups.value = [];
tags.value = [];
return;
}
loading.value = true;
loadError.value = '';
patient.value = null;
groups.value = [];
tags.value = [];
try {
const [p, rel] = await Promise.all([getPatientById(id), getPatientRelations(id)]);
if (!p) throw new Error('Paciente não retornou dados (RLS bloqueando ou ID não existe).');
patient.value = p;
const [g, t] = await Promise.all([
getGroupsByIds(rel.groupIds || []),
getTagsByIds(rel.tagIds || [])
]);
groups.value = g;
tags.value = t;
} catch (e) {
loadError.value = e?.message || 'Falha ao buscar dados.';
} finally {
loading.value = false;
}
}
return {
patient,
groups,
tags,
loading,
loadError,
load
};
}
@@ -0,0 +1,102 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| 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).
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { fmtSize, DOC_TYPE_LABEL } from '@/features/patients/utils/patientFormatters';
export function usePatientDocuments() {
const documents = ref([]);
const loading = ref(false);
const error = ref('');
async function load(patientId) {
if (!patientId) {
documents.value = [];
return;
}
loading.value = true;
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 || [];
} catch (e) {
error.value = e?.message || 'Falha ao carregar documentos.';
documents.value = [];
} finally {
loading.value = false;
}
}
const total = computed(() => documents.value.length);
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) => {
const k = d.tipo_documento || 'outro';
map.set(k, (map.get(k) || 0) + 1);
});
return Object.fromEntries(map);
});
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) {
const t = d.tipo_documento || 'outro';
por[t] = (por[t] || 0) + 1;
}
const entries = Object.entries(por).sort((a, b) => b[1] - a[1]);
if (!entries.length) return null;
const [tipo, count] = entries[0];
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
);
/**
* Tamanho total formatado em string legivel (B/KB/MB/GB).
*/
const sizeTotalFormatted = computed(() => fmtSize(totalBytes.value));
return {
documents,
loading,
error,
load,
total,
totalBytes,
tiposCount,
ultimo,
topType,
pendentes,
sizeTotalFormatted
};
}
@@ -0,0 +1,230 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/patients/composables/usePatientFinancial.js
|
| Lancamentos financeiros (financial_records) do paciente. Filtra type=receita,
| limita 100. Schema: paid_at NULL = pendente, preenchido = pago.
| "Vencido" = paid_at IS NULL AND due_date < hoje.
| Computeds derivados: kpis (em aberto, atrasado, total, ultimo pago).
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
export function usePatientFinancial() {
const records = ref([]);
const loading = ref(false);
const error = ref('');
const busy = ref(false);
let _lastPatientId = null;
async function load(patientId) {
_lastPatientId = patientId || null;
if (!patientId) {
records.value = [];
return;
}
loading.value = true;
error.value = '';
records.value = [];
try {
const { data, error: err } = await supabase
.from('financial_records')
.select('id, type, amount, due_date, paid_at, description, payment_method, category, created_at')
.eq('patient_id', patientId)
.eq('type', 'receita')
.order('created_at', { ascending: false })
.limit(100);
if (err) throw err;
records.value = data || [];
} catch (e) {
error.value = e?.message || 'Falha ao carregar lançamentos.';
records.value = [];
} finally {
loading.value = false;
}
}
const totalRecebido = computed(() =>
records.value
.filter((r) => r.paid_at)
.reduce((acc, r) => acc + Number(r.amount || 0), 0)
);
const totalEmAberto = computed(() =>
records.value
.filter((r) => !r.paid_at)
.reduce((acc, r) => acc + Number(r.amount || 0), 0)
);
const totalAtrasado = computed(() => {
const today = new Date().toISOString().slice(0, 10);
return records.value
.filter((r) => !r.paid_at && r.due_date && r.due_date < today)
.reduce((acc, r) => acc + Number(r.amount || 0), 0);
});
const ultimoPago = computed(() => {
const pagos = records.value.filter((r) => r.paid_at);
if (!pagos.length) return null;
return [...pagos].sort((a, b) => new Date(b.paid_at) - new Date(a.paid_at))[0];
});
/**
* Status financeiro detalhado pra KPI da Visao Geral.
* - emDia: nenhum pendente vencido (paid_at NULL && due_date < hoje)
* - proxVenc: proximo pendente com due_date no futuro
* - totalPendente / totalPago: somatorio
* - vencidos: count de pendentes vencidos
*/
const statusFinanceiro = computed(() => {
const recs = records.value;
if (!recs?.length) {
return { emDia: null, proxVenc: null, totalPendente: 0, totalPago: 0, vencidos: 0 };
}
const now = Date.now();
const pendentes = recs.filter((r) => !r.paid_at);
const pagos = recs.filter((r) => !!r.paid_at);
const vencidos = pendentes.filter(
(r) => r.due_date && new Date(r.due_date + 'T23:59:59').getTime() < now
);
const proxVenc = pendentes
.filter((r) => r.due_date && new Date(r.due_date + 'T00:00:00').getTime() >= now)
.sort((a, b) => new Date(a.due_date) - new Date(b.due_date))[0] || null;
const totalPendente = pendentes.reduce((acc, r) => acc + (Number(r.amount) || 0), 0);
const totalPago = pagos.reduce((acc, r) => acc + (Number(r.amount) || 0), 0);
return {
emDia: vencidos.length === 0,
proxVenc,
totalPendente,
totalPago,
vencidos: vencidos.length
};
});
/**
* Lancamentos ordenados DESC por due_date (fallback created_at).
* Mais recente primeiro pra alimentar a tabela da Tab Financeiro.
*/
const recordsOrdenados = computed(() =>
[...records.value].sort((a, b) => {
const da = a.due_date || a.created_at;
const db = b.due_date || b.created_at;
return new Date(db) - new Date(da);
})
);
/**
* Marca um lancamento como pago (paid_at = now). Auto-reload.
* Retorna {ok, error?}.
*/
async function markPaid(recordId) {
if (!recordId || busy.value) return { ok: false, error: 'busy' };
busy.value = true;
try {
const { error: err } = await supabase
.from('financial_records')
.update({ paid_at: new Date().toISOString() })
.eq('id', recordId);
if (err) throw err;
if (_lastPatientId) await load(_lastPatientId);
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'Erro ao marcar como pago' };
} finally {
busy.value = false;
}
}
/**
* Cria um novo lancamento manual (type=receita) pro paciente.
* Insere com tenant_id + owner_id resolvidos via auth/tenant store.
* Auto-reload ao final pra refletir nos KPIs e tabela.
*
* payload: { description, amount, due_date, payment_method? }
* Retorna {ok, data?, error?}.
*/
async function createRecord(patientId, payload = {}) {
if (!patientId || busy.value) return { ok: false, error: 'busy' };
if (!payload?.amount || Number.isNaN(Number(payload.amount))) {
return { ok: false, error: 'Valor invalido' };
}
busy.value = true;
try {
const { data: userData } = await supabase.auth.getUser();
const ownerId = userData?.user?.id;
// tenant_id: tenta tenantStore lazy import, fallback null (RLS
// via owner_id ainda permite insert).
let tenantId = null;
try {
const { useTenantStore } = await import('@/stores/tenantStore');
tenantId = useTenantStore().activeTenantId || null;
} catch { /* sem tenant store — segue */ }
const row = {
patient_id: patientId,
owner_id: ownerId,
tenant_id: tenantId,
type: 'receita',
amount: Number(payload.amount),
due_date: payload.due_date || null,
description: String(payload.description || '').trim() || null,
payment_method: payload.payment_method || null,
paid_at: null
};
const { data, error: err } = await supabase
.from('financial_records')
.insert([row])
.select()
.single();
if (err) throw err;
if (_lastPatientId) await load(_lastPatientId);
return { ok: true, data };
} catch (e) {
return { ok: false, error: e?.message || 'Erro ao criar lançamento' };
} finally {
busy.value = false;
}
}
/**
* Reverte: remove paid_at (volta pra pendente). Auto-reload.
*/
async function markUnpaid(recordId) {
if (!recordId || busy.value) return { ok: false, error: 'busy' };
busy.value = true;
try {
const { error: err } = await supabase
.from('financial_records')
.update({ paid_at: null })
.eq('id', recordId);
if (err) throw err;
if (_lastPatientId) await load(_lastPatientId);
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'Erro ao reverter pagamento' };
} finally {
busy.value = false;
}
}
return {
records,
loading,
error,
busy,
load,
totalRecebido,
totalEmAberto,
totalAtrasado,
ultimoPago,
statusFinanceiro,
recordsOrdenados,
markPaid,
markUnpaid,
createRecord
};
}
@@ -0,0 +1,76 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/patients/composables/usePatientMessages.js
|
| Mensagens de conversa do paciente. Carrega 200 mais recentes (in+out)
| pra alimentar o card "Ultimas mensagens" (Visao Geral, top 4) e os
| KPIs da aba Conversas. Conversa completa fica no PatientConversationsTab.
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
export function usePatientMessages() {
const messages = ref([]);
const loading = ref(false);
const error = ref('');
async function load(patientId) {
if (!patientId) {
messages.value = [];
return;
}
loading.value = true;
error.value = '';
messages.value = [];
try {
const { data, error: err } = await supabase
.from('conversation_messages')
.select('id, body, direction, created_at, channel, kanban_status')
.eq('patient_id', patientId)
.order('created_at', { ascending: false })
.limit(200);
if (err) throw err;
messages.value = data || [];
} catch (e) {
error.value = e?.message || 'Falha ao carregar mensagens.';
messages.value = [];
} finally {
loading.value = false;
}
}
const recentes = computed(() => messages.value.slice(0, 4));
const totalIn = computed(() =>
messages.value.filter((m) => m.direction === 'in' || m.direction === 'inbound').length
);
const totalOut = computed(() =>
messages.value.filter((m) => m.direction === 'out' || m.direction === 'outbound').length
);
const ultimaMensagem = computed(() => messages.value[0] || null);
const primeiraMensagem = computed(() => messages.value[messages.value.length - 1] || null);
/**
* Canais unicos usados nas mensagens (whatsapp, sms, email).
*/
const canais = computed(() => {
const set = new Set();
for (const m of messages.value) if (m.channel) set.add(m.channel);
return [...set];
});
return {
messages,
loading,
error,
load,
recentes,
totalIn,
totalOut,
ultimaMensagem,
primeiraMensagem,
canais
};
}
@@ -0,0 +1,103 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/patients/composables/usePatientRecurrences.js
|
| Carrega regras de recorrencia (recurrence_rules) filtradas por paciente.
| Usado pela Tab Agenda do MelissaPaciente pra mostrar "este paciente tem
| sessao toda segunda 14h" e dar acoes inline (cancelar/reativar).
|
| Mutations espelham o pattern de MelissaRecorrencias.vue.
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
export function usePatientRecurrences() {
const rules = ref([]);
const loading = ref(false);
const error = ref('');
const busy = ref(false);
let _lastPatientId = null;
async function load(patientId) {
_lastPatientId = patientId || null;
if (!patientId) {
rules.value = [];
return;
}
loading.value = true;
error.value = '';
rules.value = [];
try {
const { data, error: err } = await supabase
.from('recurrence_rules')
.select('*')
.eq('patient_id', patientId)
.order('start_date', { ascending: false });
if (err) throw err;
rules.value = data || [];
} catch (e) {
error.value = e?.message || 'Falha ao carregar recorrencias.';
rules.value = [];
} finally {
loading.value = false;
}
}
async function cancel(ruleId) {
if (!ruleId || busy.value) return { ok: false, error: 'busy' };
busy.value = true;
try {
const { error: err } = await supabase
.from('recurrence_rules')
.update({ status: 'cancelado', updated_at: new Date().toISOString() })
.eq('id', ruleId);
if (err) throw err;
if (_lastPatientId) await load(_lastPatientId);
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'Erro ao cancelar' };
} finally {
busy.value = false;
}
}
async function reactivate(ruleId) {
if (!ruleId || busy.value) return { ok: false, error: 'busy' };
busy.value = true;
try {
const { error: err } = await supabase
.from('recurrence_rules')
.update({ status: 'ativo', updated_at: new Date().toISOString() })
.eq('id', ruleId);
if (err) throw err;
if (_lastPatientId) await load(_lastPatientId);
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'Erro ao reativar' };
} finally {
busy.value = false;
}
}
const ativas = computed(() => rules.value.filter((r) => r.status === 'ativo'));
const canceladas = computed(() => rules.value.filter((r) => r.status === 'cancelado'));
const totalAtivas = computed(() => ativas.value.length);
const totalCanceladas = computed(() => canceladas.value.length);
return {
rules,
loading,
error,
busy,
load,
cancel,
reactivate,
ativas,
canceladas,
totalAtivas,
totalCanceladas
};
}
@@ -0,0 +1,184 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/patients/composables/usePatientSessions.js
|
| Carrega sessoes (agenda_eventos) do paciente. Limit 100 mais recentes
| ordenadas desc por inicio_em. Compativel com a logica original do
| PatientProntuario.vue.
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client';
export function usePatientSessions() {
const sessions = ref([]);
const loading = ref(false);
const error = ref('');
const busy = ref(false); // mutations em curso (updateStatus etc)
let _lastPatientId = null;
async function load(patientId) {
_lastPatientId = patientId || null;
if (!patientId) {
sessions.value = [];
return;
}
loading.value = true;
error.value = '';
sessions.value = [];
try {
const { data, error: err } = await supabase
.from('agenda_eventos')
.select('id, inicio_em, fim_em, status, modalidade, tipo, titulo, titulo_custom, observacoes')
.eq('patient_id', patientId)
.order('inicio_em', { ascending: false })
.limit(100);
if (err) throw err;
sessions.value = data || [];
} catch (e) {
error.value = e?.message || 'Falha ao carregar sessões.';
sessions.value = [];
} finally {
loading.value = false;
}
}
// Helpers derivados — proxima sessao agendada e status corrente
const proximaSessao = computed(() => {
const now = Date.now();
return [...sessions.value]
.filter((s) => s.inicio_em && new Date(s.inicio_em).getTime() >= now)
.sort((a, b) => new Date(a.inicio_em) - new Date(b.inicio_em))[0] || null;
});
const ultimaSessao = computed(() => {
const now = Date.now();
return sessions.value
.filter((s) => s.inicio_em && new Date(s.inicio_em).getTime() < now)
.sort((a, b) => new Date(b.inicio_em) - new Date(a.inicio_em))[0] || null;
});
const totalSessoes = computed(() => sessions.value.length);
// Conta status com regex pra cobrir variantes pt-br
// (realizada/realizado/presente; falta/faltou; cancelada/cancelado/remarcada).
const totalRealizadas = computed(() =>
sessions.value.filter((s) => /realiz|present/i.test(String(s.status || ''))).length
);
const totalFaltas = computed(() =>
sessions.value.filter((s) => /falt/i.test(String(s.status || ''))).length
);
const totalCanceladas = computed(() =>
sessions.value.filter((s) => /cancel|remarca/i.test(String(s.status || ''))).length
);
/**
* Top 6 sessoes "atendidas" (qualquer status que indica encontro: realizado,
* faltou, cancelado, remarcado) alimenta a Timeline da Visao Geral.
*/
const ultimasAtendidas = computed(() =>
sessions.value
.filter((s) => /realiz|present|falt|cancel|remarca/i.test(String(s.status || '')))
.slice(0, 6)
);
/**
* Cria uma nova sessao na agenda do paciente.
*
* payload: {
* inicio_em: ISO timestamp,
* fim_em: ISO timestamp,
* tipo: 'sessao' | 'primeira' | 'retorno' | etc,
* modalidade: 'presencial' | 'online',
* titulo?: string,
* titulo_custom?: string,
* observacoes?: string
* }
* Retorna {ok, data?, error?}.
*/
async function createSession(patientId, payload = {}) {
if (!patientId || busy.value) return { ok: false, error: 'busy' };
if (!payload?.inicio_em || !payload?.fim_em) {
return { ok: false, error: 'Inicio/fim obrigatorios' };
}
busy.value = true;
try {
const { data: userData } = await supabase.auth.getUser();
const ownerId = userData?.user?.id;
let tenantId = null;
try {
const { useTenantStore } = await import('@/stores/tenantStore');
tenantId = useTenantStore().activeTenantId || null;
} catch { /* sem tenant store — segue */ }
const row = {
patient_id: patientId,
owner_id: ownerId,
tenant_id: tenantId,
inicio_em: payload.inicio_em,
fim_em: payload.fim_em,
status: 'agendado',
modalidade: payload.modalidade || 'presencial',
tipo: payload.tipo || 'sessao',
titulo: String(payload.titulo || '').trim() || null,
titulo_custom: String(payload.titulo_custom || '').trim() || null,
observacoes: String(payload.observacoes || '').trim() || null
};
const { data, error: err } = await supabase
.from('agenda_eventos')
.insert([row])
.select()
.single();
if (err) throw err;
if (_lastPatientId) await load(_lastPatientId);
return { ok: true, data };
} catch (e) {
return { ok: false, error: e?.message || 'Erro ao agendar sessao' };
} finally {
busy.value = false;
}
}
/**
* Atualiza o status de uma sessao (mutation). Recarrega a lista do paciente
* ao final pra refletir o novo estado nos computeds derivados.
* Retorna {ok: true} ou {ok: false, error: msg}.
*/
async function updateStatus(sessionId, novoStatus) {
if (!sessionId || busy.value) return { ok: false, error: 'busy' };
busy.value = true;
try {
const { error: err } = await supabase
.from('agenda_eventos')
.update({ status: novoStatus })
.eq('id', sessionId);
if (err) throw err;
if (_lastPatientId) await load(_lastPatientId);
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'Erro ao atualizar status' };
} finally {
busy.value = false;
}
}
return {
sessions,
loading,
error,
busy,
load,
updateStatus,
createSession,
proximaSessao,
ultimaSessao,
totalSessoes,
totalRealizadas,
totalFaltas,
totalCanceladas,
ultimasAtendidas
};
}
@@ -0,0 +1,399 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/patients/utils/patientFormatters.js
|
| Helpers de formatacao compartilhaveis entre PatientProntuario.vue (legacy)
| e MelissaPaciente.vue (Melissa nativo). Extraidos do PatientProntuario
| pra eliminar duplicacao quando MelissaPaciente substituir o legacy
| na Fase 8.
|--------------------------------------------------------------------------
*/
/**
* Tenta varios formatos de data: ISO, DD/MM/YYYY, YYYY-MM-DD, etc.
*/
export function parseDateLoose(v) {
if (!v) return null;
if (v instanceof Date) return Number.isNaN(v.getTime()) ? null : v;
const s = String(v).trim();
if (!s) return null;
// DD/MM/YYYY
let m = s.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
if (m) {
const d = new Date(Number(m[3]), Number(m[2]) - 1, Number(m[1]));
return Number.isNaN(d.getTime()) ? null : d;
}
// YYYY-MM-DD
m = s.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (m) {
const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
return Number.isNaN(d.getTime()) ? null : d;
}
const d = new Date(s);
return Number.isNaN(d.getTime()) ? null : d;
}
export function dash(v) {
const s = String(v ?? '').trim();
return s || '—';
}
/**
* Pega a primeira chave nao vazia de um objeto (snake_case ou camelCase).
* Usado pra resolver discrepancias de schema (ex: 'data_nascimento' vs 'birth_date').
*/
export function pickField(obj, keys = []) {
for (const k of keys) {
const v = obj?.[k];
if (v !== null && v !== undefined && String(v).trim()) return v;
}
return null;
}
export function onlyDigits(v) {
return String(v ?? '').replace(/\D/g, '');
}
/**
* Formata CPF: 00000000000 -> 000.000.000-00
*/
export function fmtCPF(v) {
const d = onlyDigits(v);
if (!d) return '—';
if (d.length !== 11) return d;
return `${d.slice(0, 3)}.${d.slice(3, 6)}.${d.slice(6, 9)}-${d.slice(9)}`;
}
/**
* Formata RG (genericamente varia por estado):
* 00.000.000-0 / 0000000000 mantem digitos com pontos a cada 3 a partir da direita.
*/
export function fmtRG(v) {
const s = String(v ?? '').trim();
if (!s) return '—';
return s;
}
/**
* Formata telefone celular pt-br: (XX) 9XXXX-XXXX ou (XX) XXXX-XXXX.
*/
export function fmtPhoneMobile(v) {
const d = onlyDigits(v);
if (!d) return '—';
if (d.length === 11) return `(${d.slice(0, 2)}) ${d.slice(2, 7)}-${d.slice(7, 11)}`;
if (d.length === 10) return `(${d.slice(0, 2)}) ${d.slice(2, 6)}-${d.slice(6, 10)}`;
return d;
}
/**
* Mapeia variantes de genero pra label legivel.
*/
export function fmtGender(v) {
const s = String(v ?? '').trim();
if (!s) return '—';
const x = s.toLowerCase();
if (['m', 'masc', 'masculino', 'male', 'man', 'homem'].includes(x)) return 'Masculino';
if (['f', 'fem', 'feminino', 'female', 'woman', 'mulher'].includes(x)) return 'Feminino';
if (['nb', 'nao-binario', 'não-binário', 'nonbinary', 'non-binary'].includes(x)) return 'Não-binário';
if (['outro', 'other'].includes(x)) return 'Outro';
return s;
}
/**
* Mapeia variantes de estado civil pra label pt-br.
*/
export function fmtMarital(v) {
const s = String(v ?? '').trim();
if (!s) return '—';
const x = s.toLowerCase();
if (['solteiro', 'solteira', 'single'].includes(x)) return 'Solteiro(a)';
if (['casado', 'casada', 'married'].includes(x)) return 'Casado(a)';
if (['divorciado', 'divorciada', 'divorced'].includes(x)) return 'Divorciado(a)';
if (['viuvo', 'viúva', 'viuvo(a)', 'widowed'].includes(x)) return 'Viúvo(a)';
if (['uniao estavel', 'união estável', 'civil union'].includes(x)) return 'União estável';
return s;
}
export function fmtDateBR(v) {
const d = parseDateLoose(v);
if (!d) return v ? dash(v) : '—';
return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
}
/**
* Hora curta HH:MM (24h pt-br).
*/
export function fmtHourShort(iso) {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
/**
* Dia da semana abreviado pt-br (seg/ter/qua...).
*/
export function fmtDayShort(iso) {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleDateString('pt-BR', { weekday: 'short' }).replace('.', '');
}
export function fmtDateTimeBR(iso) {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const dd = String(d.getDate()).padStart(2, '0');
const mm = String(d.getMonth() + 1).padStart(2, '0');
const hh = String(d.getHours()).padStart(2, '0');
const mi = String(d.getMinutes()).padStart(2, '0');
return `${dd}/${mm}/${d.getFullYear()} ${hh}:${mi}`;
}
/**
* Bytes -> string legivel (B/KB/MB/GB).
*/
export function fmtSize(bytes) {
const b = Number(bytes) || 0;
if (b < 1024) return `${b} B`;
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
if (b < 1024 * 1024 * 1024) return `${(b / (1024 * 1024)).toFixed(1)} MB`;
return `${(b / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
/**
* Map de tipos de documento clinico pra label pt-br.
*/
export const DOC_TYPE_LABEL = {
atestado: 'Atestado',
receita: 'Receita',
laudo: 'Laudo',
encaminhamento: 'Encaminhamento',
termo: 'Termo',
termo_assinado: 'Termo assinado',
relatorio: 'Relatório',
declaracao: 'Declaração',
outro: 'Outro'
};
/**
* Map de dia da semana (0=Domingo) -> label pt-br.
*/
export const WEEKDAY_LABEL = ['Domingo', 'Segunda', 'Terça', 'Quarta', 'Quinta', 'Sexta', 'Sábado'];
export const WEEKDAY_LABEL_SHORT = ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb'];
/**
* Label legivel da regra de recorrencia.
* Ex: "Toda segunda às 14:00", "A cada 2 semanas, terça às 09:00",
* "Quartas e sextas às 16:00", "Mensal no dia 15".
*/
export function fmtRecurrenceLabel(rule) {
if (!rule) return '—';
const time = String(rule.start_time || '').slice(0, 5);
const interval = Number(rule.interval) || 1;
if (rule.type === 'weekly' || (rule.type === 'biweekly' && interval === 1)) {
const dow = (rule.weekdays || [])[0];
if (dow == null) return time ? `Semanal às ${time}` : 'Semanal';
const dayLbl = WEEKDAY_LABEL[dow] || '?';
if (rule.type === 'biweekly') {
return time ? `Quinzenal · ${dayLbl} às ${time}` : `Quinzenal · ${dayLbl}`;
}
return time ? `Toda ${dayLbl.toLowerCase()} às ${time}` : `Toda ${dayLbl.toLowerCase()}`;
}
if (rule.type === 'custom_weekdays') {
const dows = (rule.weekdays || []).map((d) => WEEKDAY_LABEL_SHORT[d]).filter(Boolean);
const dayList = dows.length ? dows.join(', ') : '?';
return time ? `${dayList} às ${time}` : dayList;
}
if (rule.type === 'monthly') {
return time ? `Mensal às ${time}` : 'Mensal';
}
if (rule.type === 'yearly') {
return time ? `Anual às ${time}` : 'Anual';
}
return rule.type || 'Recorrência';
}
/**
* Label pro fim da regra: "Sem data de fim", "Até DD/MM/YYYY", "N sessões no total".
*/
export function fmtRecurrenceFim(rule) {
if (!rule) return '';
if (rule.end_date) return `Até ${fmtDateBR(rule.end_date)}`;
if (rule.max_occurrences) {
const n = Number(rule.max_occurrences);
return `${n} ${n === 1 ? 'sessão' : 'sessões'} no total`;
}
return 'Sem data de fim';
}
/**
* Channel label pra conversa: whatsapp -> WhatsApp, sms -> SMS, email -> E-mail.
*/
export function chConvLabel(c) {
const k = String(c || '').toLowerCase();
if (k === 'whatsapp') return 'WhatsApp';
if (k === 'sms') return 'SMS';
if (k === 'email') return 'E-mail';
return c || '';
}
export function fmtCurrency(v) {
if (v === null || v === undefined || v === '') return '—';
return `R$ ${Number(v).toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
export function sessionDuration(inicio, fim) {
if (!inicio || !fim) return null;
const diff = new Date(fim) - new Date(inicio);
if (diff <= 0) return null;
const min = Math.round(diff / 60000);
if (min < 60) return `${min} min`;
const h = Math.floor(min / 60);
const m = min % 60;
return m ? `${h}h ${m}min` : `${h}h`;
}
/**
* Data relativa em pt-BR.
* Retorna: "agora", "ha 5 min", "ha 2 h", "ontem", "ha 3 dias",
* "em 2 dias", "em 3 sem", ou data absoluta para >= 5 semanas.
*/
export function fmtRelative(iso) {
if (!iso) return '—';
const target = new Date(iso).getTime();
if (Number.isNaN(target)) return '—';
const diff = target - Date.now();
const abs = Math.abs(diff);
const past = diff < 0;
const min = Math.round(abs / 60000);
if (min < 1) return 'agora';
if (min < 60) return past ? `${min} min` : `em ${min} min`;
const h = Math.round(min / 60);
if (h < 24) return past ? `${h} h` : `em ${h} h`;
const d = Math.round(h / 24);
if (d === 1) return past ? 'ontem' : 'amanhã';
if (d < 7) return past ? `${d} dias` : `em ${d} dias`;
const w = Math.round(d / 7);
if (w < 5) return past ? `${w} sem` : `em ${w} sem`;
return fmtDateBR(iso);
}
/**
* Idade em anos com base em data de nascimento.
*/
export function calcAge(v) {
const d = parseDateLoose(v);
if (!d) return null;
const now = new Date();
let age = now.getFullYear() - d.getFullYear();
const m = now.getMonth() - d.getMonth();
if (m < 0 || (m === 0 && now.getDate() < d.getDate())) age--;
return age;
}
/**
* Status visual de uma sessao da agenda.
*/
export const STATUS_LABEL = {
agendado: 'Agendado',
realizado: 'Realizado',
realizada: 'Realizado',
faltou: 'Faltou',
falta: 'Faltou',
cancelado: 'Cancelado',
cancelada: 'Cancelado',
remarcado: 'Remarcado',
bloqueado: 'Bloqueado'
};
/**
* Determina o status financeiro de um lancamento:
* - "pago": paid_at preenchido
* - "vencido": due_date < hoje E paid_at vazio
* - "pendente": demais casos com paid_at vazio
*/
export function recordStatus(r) {
if (r?.paid_at) return 'pago';
if (r?.due_date) {
const ms = new Date(r.due_date + 'T23:59:59').getTime();
if (!Number.isNaN(ms) && ms < Date.now()) return 'vencido';
}
return 'pendente';
}
export const RECORD_STATUS_LABEL = {
pago: 'Pago',
pendente: 'Pendente',
vencido: 'Vencido'
};
/**
* Mapeia variantes de payment_method pra label legivel.
*/
export function fmtPaymentMethod(v) {
const s = String(v ?? '').toLowerCase();
if (!s) return '';
if (s === 'pix') return 'PIX';
if (s === 'cartao' || s === 'cartão' || s === 'credit_card') return 'Cartão';
if (s === 'dinheiro' || s === 'cash') return 'Dinheiro';
if (s === 'boleto') return 'Boleto';
if (s === 'transferencia' || s === 'transfer' || s === 'ted' || s === 'doc') return 'Transferência';
if (s === 'convenio' || s === 'convênio') return 'Convênio';
return v;
}
export const STATUS_SEVERITY = {
agendado: 'info',
realizado: 'success',
realizada: 'success',
faltou: 'danger',
falta: 'danger',
cancelado: 'warn',
cancelada: 'warn',
remarcado: 'secondary',
bloqueado: 'secondary'
};
/**
* Tag styling com contraste auto (texto preto/branco baseado em luminancia).
*/
function normalizeHexColor(c) {
const s = String(c ?? '').trim();
if (!s) return '';
if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(s)) return s;
if (/^([0-9a-f]{3}|[0-9a-f]{6})$/i.test(s)) return `#${s}`;
return s;
}
function hexToRgb(hex) {
const h = String(hex || '').replace('#', '').trim();
if (!/^([0-9a-f]{3}|[0-9a-f]{6})$/i.test(h)) return null;
const full = h.length === 3 ? h.split('').map((ch) => ch + ch).join('') : h;
const r = parseInt(full.slice(0, 2), 16);
const g = parseInt(full.slice(2, 4), 16);
const b = parseInt(full.slice(4, 6), 16);
if ([r, g, b].some((n) => Number.isNaN(n))) return null;
return { r, g, b };
}
function relativeLuminance({ r, g, b }) {
const srgb = [r, g, b].map((v) => v / 255).map((c) => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)));
return 0.2126 * srgb[0] + 0.7152 * srgb[1] + 0.0722 * srgb[2];
}
export function bestTextColor(bg) {
const rgb = hexToRgb(normalizeHexColor(bg));
if (!rgb) return '#0f172a';
return relativeLuminance(rgb) < 0.45 ? '#ffffff' : '#0f172a';
}
export function tagStyle(t) {
const bg = normalizeHexColor(t?.color || t?.cor);
if (!bg) return {};
return { background: bg, color: bestTextColor(bg), borderColor: 'transparent' };
}
+11 -28
View File
@@ -34,7 +34,6 @@ import MelissaAgendaSearchPopover from './MelissaAgendaSearchPopover.vue';
import MelissaAgendaActionsPopover from './MelissaAgendaActionsPopover.vue';
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue';
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue';
import PatientProntuario from '@/features/patients/prontuario/PatientProntuario.vue';
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
import { getSessionCounts } from '@/features/patients/services/patientsRepository';
// `Menu` PrimeVue: NÃO importar explicitamente projeto usa auto-import
@@ -234,13 +233,9 @@ function pacienteRowDblclick(id) {
abrirProntuarioPorId(id);
}
function abrirProntuarioPorId(id) {
const p =
props.pacientes.find((x) => x.id === id) ||
pacientesAside.value.find((x) => x.id === id) ||
null;
if (!p) return;
prontuarioPatient.value = { ...p };
prontuarioOpen.value = true;
if (!id) return;
// Fase 8 wire-up: navega pra MelissaPaciente nativo.
router.push({ path: '/melissa/paciente', query: { id: String(id) } });
}
// Calendar (FullCalendar)
@@ -992,8 +987,6 @@ function onPatientCreated() {
// Pattern espelha PatientsListPage (goEdit/goConversation/openProntuario).
// Aparece no .melissa-dock via Teleport quando há paciente selecionado.
const conversationDrawerStore = useConversationDrawerStore();
const prontuarioOpen = ref(false);
const prontuarioPatient = ref(null);
const sessionCountsMap = ref(new Map()); // id count (cache)
// Cache pra patients carregados sob demanda quando o pacienteSelecionadoId
@@ -1169,9 +1162,8 @@ function abrirWhatsappPaciente() {
}
function abrirProntuarioPaciente() {
const p = pacienteSelecionado.value;
if (!p) return;
prontuarioPatient.value = { ...p };
prontuarioOpen.value = true;
if (!p?.id) return;
abrirProntuarioPorId(p.id);
}
// API pública pra MelissaLayout chamar via ref (botão "Editar paciente"
// do MelissaEventoPanel). Abre o PatientCadastroDialog já no modo edição.
@@ -1211,7 +1203,7 @@ const kebabItems = computed(() => {
return [
{ label: 'Sessões', icon: 'pi pi-history', command: () => { pacienteSelecionadoId.value = p.id; abrirSessoesPaciente(); } },
{ label: 'WhatsApp', icon: 'pi pi-whatsapp', command: () => conversationDrawerStore.openForPatient(String(p.id)) },
{ label: 'Prontuário', icon: 'pi pi-file', command: () => { prontuarioPatient.value = { ...p }; prontuarioOpen.value = true; } },
{ label: 'Prontuário', icon: 'pi pi-file', command: () => abrirProntuarioPorId(p.id) },
{ label: 'Editar', icon: 'pi pi-pencil', command: () => { editPatientId.value = String(p.id); cadastroFullDialog.value = true; } }
];
});
@@ -1220,9 +1212,8 @@ const kebabItems = computed(() => {
// MelissaEventoPanel emite ações que o parent precisa orquestrar com
// a Agenda aqui ficam os métodos invocáveis via ref.
function openProntuario(patient) {
if (!patient) return;
prontuarioPatient.value = { ...patient };
prontuarioOpen.value = true;
if (!patient?.id) return;
abrirProntuarioPorId(patient.id);
}
defineExpose({
refetch: refetchEventosFc,
@@ -1905,17 +1896,9 @@ defineExpose({
<!-- Menu kebab (mobile) abre via toggleKebab a partir de qualquer .ma-pat -->
<Menu ref="kebabMenu" :model="kebabItems" popup append-to="body" />
<!-- Dialog Prontuário reaproveita componente do PatientsListPage.
:key força re-mount quando troca de paciente. Optional chain
em prontuarioPatient pra blindar contra bloco-tree opt do Vue. -->
<PatientProntuario
v-if="prontuarioPatient"
:key="prontuarioPatient?.id || 'none'"
v-model="prontuarioOpen"
:patient="prontuarioPatient"
@close="prontuarioOpen = false"
@edit="(id) => { prontuarioOpen = false; editPatientId = String(id); cadastroFullDialog = true; }"
/>
<!-- Prontuario migrado pra MelissaPaciente nativo (Fase 8 wire-up).
abrirProntuarioPorId(id) navega pra /melissa/paciente?id=X. -->
</section>
</template>
+145
View File
@@ -17,6 +17,7 @@
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue';
import MelissaConfigList from './MelissaConfigList.vue';
import JoditTextEditor from '@/components/ui/JoditTextEditor.vue';
import AgendadorPreview from '@/components/agendador/AgendadorPreview.vue';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
@@ -41,14 +42,27 @@ const AGENDADOR_BUCKET = 'agendador';
// Breakpoints + drawer
const drawerOpen = ref(false);
const isMobile = ref(false);
const isWideDesktop = ref(false); // >= 1340px preview vira painel flutuante fora do fake dialog
let _mqMobile = null;
let _mqWide = null;
function _onMqMobileChange(e) {
isMobile.value = e.matches;
if (!e.matches) drawerOpen.value = false;
}
function _onMqWideChange(e) { isWideDesktop.value = e.matches; }
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
function fecharDrawer() { drawerOpen.value = false; }
// Onde o preview do celular e renderizado:
// - mobile: dentro do main, no topo do conteudo (acima de tudo)
// - mid-desktop (1024-1339): dentro da sidebar inline (apos Status/Resumos)
// - wide-desktop (>=1340): painel flutuante fora do fake dialog
const previewTarget = computed(() => {
if (isMobile.value) return '#mag-main-preview-target';
if (isWideDesktop.value) return '#mag-floating-preview-target';
return '#mag-sidebar-preview-target';
});
// Toggle entre cards (default) e lista de configs (alterna inline na sidebar)
const cfgOpen = ref(false);
function toggleCfg() { cfgOpen.value = !cfgOpen.value; }
@@ -533,6 +547,11 @@ onMounted(async () => {
isMobile.value = _mqMobile.matches;
try { _mqMobile.addEventListener('change', _onMqMobileChange); }
catch { _mqMobile.addListener(_onMqMobileChange); }
_mqWide = window.matchMedia('(min-width: 1340px)');
isWideDesktop.value = _mqWide.matches;
try { _mqWide.addEventListener('change', _onMqWideChange); }
catch { _mqWide.addListener(_onMqWideChange); }
}
await tenantStore.ensureLoaded();
await load();
@@ -543,6 +562,10 @@ onBeforeUnmount(() => {
try { _mqMobile.removeEventListener('change', _onMqMobileChange); }
catch { _mqMobile.removeListener(_onMqMobileChange); }
}
if (_mqWide) {
try { _mqWide.removeEventListener('change', _onMqWideChange); }
catch { _mqWide.removeListener(_onMqWideChange); }
}
clearTimeout(_copyTimer);
});
@@ -574,6 +597,12 @@ const summaryItems = computed(() => [
/>
</Transition>
<!-- Painel flutuante do Preview (wide-desktop >=1340px). Vive FORA do
fake dialog, ancorado a sua right edge + 14px gap. Em mobile o
preview teleporta pro #mag-main-preview-target (topo do main); em
mid-desktop (1024-1339) teleporta pro #mag-sidebar-preview-target. -->
<aside id="mag-floating-preview-target" class="mag-floating-preview" aria-label="Pré-visualização do agendador"></aside>
<section class="mag-page">
<header class="mag-page__head">
<button
@@ -766,11 +795,20 @@ const summaryItems = computed(() => [
</button>
</div>
</div>
<!-- Target do Teleport do Preview pra modo sidebar (mid-desktop 1024-1339).
Em mobile o preview teleporta pro #mag-main-preview-target (topo do main);
em wide-desktop (>=1340px) teleporta pro #mag-floating-preview-target. -->
<div id="mag-sidebar-preview-target" class="mag-sidebar-preview-target" />
</div>
</aside>
</Teleport>
<div class="mag-main">
<!-- Target do Teleport do Preview pra modo mobile (acima de tudo no main).
Em desktop fica oculto via CSS e o preview teleporta pra sidebar/floating. -->
<div id="mag-main-preview-target" class="mag-main-preview-target" />
<!-- Loading -->
<template v-if="loading">
<div class="mag-w" v-for="n in 3" :key="`sk-${n}`">
@@ -1350,6 +1388,24 @@ const summaryItems = computed(() => [
</div>
</div>
</section>
<!-- Card de Pré-visualização (Teleport). O target alterna conforme o
viewport: topo do main (mobile) / sidebar (mid-desktop) / painel
flutuante fora do fake dialog (wide-desktop). -->
<Teleport :to="previewTarget">
<div class="mag-w mag-w--side mag-w--preview">
<div class="mag-w__head">
<div class="mag-w__icon"><i class="pi pi-mobile" /></div>
<div class="mag-w__title">
<div class="mag-w__title-text">Pré-visualização</div>
<div class="mag-w__sub">Como aparece no celular</div>
</div>
</div>
<div class="mag-w__body">
<AgendadorPreview :cfg="cfg" />
</div>
</div>
</Teleport>
</template>
<style scoped>
@@ -1553,6 +1609,15 @@ const summaryItems = computed(() => [
.mag-main ja eh display:flex flex-direction:column gap:12px no base
so adiciona limites de largura aqui. */
@media (min-width: 1024px) {
/* Fake dialog: largura adaptativa, alinhada a esquerda (apos o
config-aside global). Espelha o pattern de MelissaNegocio:
- 10241012px : full-width (right: 6px) overlap minimo
- 10122012px : width = 1000px fixo (right cresce com viewport)
- >= 2012px : width = ~50% do viewport (right: 50%)
Necessario pra ter espaco a direita pro painel flutuante do preview. */
.mag-page {
right: max(6px, min(50%, calc(100% - 1006px)));
}
.mag-main {
max-width: 1100px;
margin: 0 auto;
@@ -2206,4 +2271,84 @@ const summaryItems = computed(() => [
.mag-page__title-icon { display: none; }
.mag-menu-btn--mobile-only { display: inline-flex; }
}
/* Painel flutuante do Preview (wide-desktop >= 1340px)
Vive FORA do fake dialog, ancorado a sua right edge + 14px gap.
Largura fica em 320px pra acomodar o phone-frame (260px + bordas + padding).
Glass igual ao fake dialog: fundo, blur, borda, radius, sombra. */
.mag-floating-preview {
display: none; /* default: oculto. Wide-desktop ativa via @media abaixo. */
position: absolute;
top: 6px;
/* height segue o conteudo (sem bottom). max-height limita ao mesmo
espaco do fake dialog pra forcar scroll se ficar muito alto. */
max-height: calc(100% - var(--m-dock-h, 76px) - 12px);
width: 320px;
z-index: 39; /* abaixo do mag-page (40) — nao concorre por foco */
overflow-y: auto;
overflow-x: hidden;
/* Sem padding aqui: o card .mag-w--preview interno controla o espaco
e seu __head fica flush com o topo, alinhando com o head do fake dialog. */
padding: 0;
background: var(--m-bg-medium);
backdrop-filter: blur(32px) saturate(160%);
-webkit-backdrop-filter: blur(32px) saturate(160%);
border: 1px solid var(--m-border);
border-radius: 18px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
color: var(--m-text);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mag-floating-preview::-webkit-scrollbar { width: 5px; }
.mag-floating-preview::-webkit-scrollbar-thumb { background: var(--m-border-strong); border-radius: 3px; }
/* Placeholders do preview na sidebar/main: nao introduzem wrapper visivel.
Os filhos teleportados se posicionam como flex items diretos do parent
(mag-side scroll ou mag-main), herdando o mesmo gap dos outros cards. */
.mag-sidebar-preview-target,
.mag-main-preview-target { display: contents; }
/* Esconde target da sidebar em mobile (preview vai pro main) e em
wide-desktop (vai pro floating) */
@media (max-width: 1023px) {
.mag-sidebar-preview-target { display: none; }
}
/* Esconde target do main em desktop (>=1024px) preview vai pra
sidebar/floating */
@media (min-width: 1024px) {
.mag-main-preview-target { display: none; }
}
/* Dentro do painel flutuante, o card de preview perde o "card-em-card":
sem fundo/borda/sombra propria (o glass do painel ja faz esse papel). */
.mag-floating-preview > .mag-w--preview {
background: transparent;
border: none;
box-shadow: none;
border-radius: 0;
}
.mag-floating-preview > .mag-w--preview > .mag-w__head {
border-bottom: 1px solid var(--m-border);
padding: 14px 18px;
}
.mag-floating-preview > .mag-w--preview > .mag-w__body {
padding: 14px 18px;
}
/* Wide-desktop: floating ativo, ancorado a right edge do .mag-page + 14px gap.
.mag-page tem `right: max(6px, min(50%, calc(100% - 1006px)))`, entao seu
right edge esta a `100% - max(...)` do parent-left. O preview comeca 14px
apos isso. 1340px e o piso onde page (1006) + gap (14) + preview (320) +
margem (caso) cabem confortavelmente. */
@media (min-width: 1340px) {
.mag-floating-preview {
display: block;
left: calc(100% - max(6px, min(50%, calc(100% - 1006px))) + 14px);
}
/* Placeholder da sidebar some — preview foi pro painel flutuante */
.mag-sidebar-preview-target { display: none; }
}
</style>
@@ -1887,4 +1887,16 @@ onBeforeUnmount(() => {
via tableStyle min-width:640px. Coluna "Ação" frozen à direita
continua fixa enquanto o user scrolla horizontalmente. */
}
/* Fake dialog: largura adaptativa (>=1024px)
Espelha pattern de MelissaAgendador/Negocio fica mesma janela,
drawer a esquerda, sobra espaco a direita pra dock e contexto.
- 10241012px : full-width (right: 6px) overlap minimo
- 10122012px : width = 1000px fixo (right cresce com viewport)
- >= 2012px : width = ~50% do viewport (right: 50%) */
@media (min-width: 1024px) {
.mcr-page {
right: max(6px, min(50%, calc(100% - 1006px)));
}
}
</style>
@@ -1922,4 +1922,13 @@ async function onDelete(c) {
.mc-act-btn--primary span { display: none; }
.mc-act-btn--primary { width: 32px; padding: 0; justify-content: center; }
}
/* Fake dialog: largura adaptativa (>=1024px)
Espelha pattern de MelissaAgendador/Negocio fica mesma janela,
drawer a esquerda, sobra espaco a direita. */
@media (min-width: 1024px) {
.mc-page {
right: max(6px, min(50%, calc(100% - 1006px)));
}
}
</style>
+9
View File
@@ -2406,4 +2406,13 @@ watch(editPatientDialog, (isOpen) => {
.mg-act-btn--primary span { display: none; }
.mg-act-btn--primary { width: 32px; padding: 0; justify-content: center; }
}
/* Fake dialog: largura adaptativa (>=1024px)
Espelha pattern de MelissaAgendador/Negocio fica mesma janela,
drawer a esquerda, sobra espaco a direita. */
@media (min-width: 1024px) {
.mg-page {
right: max(6px, min(50%, calc(100% - 1006px)));
}
}
</style>
+17 -1
View File
@@ -39,6 +39,7 @@ import MelissaTags from './MelissaTags.vue';
import MelissaGrupos from './MelissaGrupos.vue';
import MelissaConfiguracoes from './MelissaConfiguracoes.vue';
import MelissaPerfil from './MelissaPerfil.vue';
import MelissaPaciente from './MelissaPaciente.vue';
import MelissaPlano from './MelissaPlano.vue';
import MelissaNegocio from './MelissaNegocio.vue';
import MelissaAlterarPlano from './MelissaAlterarPlano.vue';
@@ -199,6 +200,11 @@ const SECOES = {
grupos: { label: 'Grupos de pacientes', icon: 'pi pi-th-large', descricao: 'Categorize pacientes por grupos.' },
tags: { label: 'Tags', icon: 'pi pi-tag', descricao: 'Etiquetas livres pra organizar pacientes.' },
medicos: { label: 'Médicos e referências', icon: 'pi pi-user-edit', descricao: 'Profissionais que encaminham ou recebem pacientes.' },
// Pagina nativa do prontuario do paciente (MelissaPaciente) Fase 1 foundation.
// ID do paciente vem via route.query.id (?id=xxx). Substitui gradualmente o
// PatientProntuario.vue legado (3593L Dialog) que continua nos 4 callsites
// ate Fase 8 (wire-up final).
paciente: { label: 'Paciente', icon: 'pi pi-user', descricao: 'Prontuario completo: visao geral, perfil, sessoes, financeiro, documentos, conversas.' },
// Pagina unificada do Layout Melissa (Aparencia + Fundo + Relogio + Cronometro)
aparencia: { label: 'Layout Melissa', icon: 'pi pi-palette', descricao: 'Aparência, plano de fundo, relógio e cronômetro do resumo.' },
// Pagina nativa do perfil (MelissaPerfil) saiu do MelissaConfiguracoes
@@ -258,7 +264,7 @@ const MELISSA_EMBED_KEYS = [];
// conversas etc. + as MELISSA_EMBED_KEYS. Usado no isConfigRoute pra
// evitar colisão (ex: /melissa/agenda MelissaAgenda, não config).
const MELISSA_NON_CONFIG_SLUGS = new Set([
'agenda', 'pacientes', 'compromissos', 'recorrencias', 'conversas',
'agenda', 'pacientes', 'paciente', 'compromissos', 'recorrencias', 'conversas',
'tags', 'grupos', 'cadastros-recebidos', 'medicos', 'agendamentos-recebidos',
'link-externo', 'notificacoes', 'financeiro', 'financeiro-lancamentos',
'documentos', 'documentos-templates', 'relatorios',
@@ -1859,6 +1865,16 @@ function onKeydown(e) {
@close="fecharSecao"
/>
<!-- Pagina nativa do prontuario do paciente (Fase 8 wire-up).
ID vem via route.query.id (?id=xxx). MelissaPaciente cuida
internamente das acoes (close -> /melissa/pacientes; edit ->
/melissa/pacientes?edit=<id>; open-whatsapp -> conversationDrawerStore). -->
<MelissaPaciente
v-if="layoutReady && secaoAberta === 'paciente'"
:patient-id="String(route.query.id || '')"
@close="fecharSecao"
/>
<MelissaPlano
v-if="layoutReady && secaoAberta === 'plano'"
@close="fecharSecao"
+332 -55
View File
@@ -12,9 +12,10 @@
* rotate_patient_invite_token_v2 + copy/openLink). o chrome muda pra
* casar com o blueprint Melissa (1 header , sem hero sticky redundante).
*/
import { ref, computed, onMounted } from 'vue';
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import CadastroExternoPreview from '@/components/cadastro/CadastroExternoPreview.vue';
// Button/InputText/InputGroup/InputGroupAddon/Message: auto via PrimeVueResolver
const emit = defineEmits(['close']);
@@ -25,6 +26,30 @@ const inviteToken = ref('');
const rotating = ref(false);
const loading = ref(false);
// Breakpoints + drawer
const drawerOpen = ref(false);
const isMobile = ref(false);
const isWideDesktop = ref(false); // >= 1340px preview vira painel flutuante fora do fake dialog
let _mqMobile = null;
let _mqWide = null;
function _onMqMobileChange(e) {
isMobile.value = e.matches;
if (!e.matches) drawerOpen.value = false;
}
function _onMqWideChange(e) { isWideDesktop.value = e.matches; }
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
function fecharDrawer() { drawerOpen.value = false; }
// Onde o preview do celular e renderizado:
// - mobile: dentro do main, no topo do conteudo (acima de tudo)
// - mid-desktop (1024-1339): dentro da sidebar inline (apos cards info)
// - wide-desktop (>=1340): painel flutuante fora do fake dialog
const previewTarget = computed(() => {
if (isMobile.value) return '#ml-main-preview-target';
if (isWideDesktop.value) return '#ml-floating-preview-target';
return '#ml-sidebar-preview-target';
});
// Conteúdo estático
const howItWorks = [
{ n: 1, title: 'Você envia o link', desc: 'Por WhatsApp, e-mail ou mensagem direta.' },
@@ -107,14 +132,67 @@ async function copyInviteMessage() {
// Lifecycle
onMounted(async () => {
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); }
_mqWide = window.matchMedia('(min-width: 1340px)');
isWideDesktop.value = _mqWide.matches;
try { _mqWide.addEventListener('change', _onMqWideChange); }
catch { _mqWide.addListener(_onMqWideChange); }
}
await loadOrCreateInvite();
});
onBeforeUnmount(() => {
if (_mqMobile) {
try { _mqMobile.removeEventListener('change', _onMqMobileChange); }
catch { _mqMobile.removeListener(_onMqMobileChange); }
}
if (_mqWide) {
try { _mqWide.removeEventListener('change', _onMqWideChange); }
catch { _mqWide.removeListener(_onMqWideChange); }
}
});
</script>
<template>
<Transition name="ml-drawer-fade">
<div
v-show="isMobile && drawerOpen"
class="ml-mobile-drawer"
:class="{ 'is-open': drawerOpen }"
>
<div id="ml-mobile-drawer-target" class="ml-mobile-drawer__scroll" />
</div>
</Transition>
<Transition name="ml-drawer-fade">
<div
v-show="isMobile && drawerOpen"
class="ml-mobile-drawer__backdrop"
@click="fecharDrawer"
/>
</Transition>
<!-- Painel flutuante do Preview (wide-desktop >=1340px). Vive FORA do
fake dialog, ancorado a sua right edge + 14px gap. Em mobile o
preview teleporta pro #ml-main-preview-target (topo do main); em
mid-desktop (1024-1339) teleporta pro #ml-sidebar-preview-target. -->
<aside id="ml-floating-preview-target" class="ml-floating-preview" aria-label="Pré-visualização do link externo"></aside>
<section class="ml-page">
<!-- Header único (sem hero sticky o chrome Melissa) -->
<header class="ml-page__head">
<button
class="ml-menu-btn ml-menu-btn--mobile-only"
v-tooltip.bottom="'Como funciona & Boas práticas'"
@click="toggleDrawer"
>
<i class="pi pi-bars" />
<span>Menu</span>
</button>
<div class="ml-page__title">
<i class="pi pi-share-alt ml-page__title-icon" />
<span>Link externo de cadastro</span>
@@ -162,8 +240,66 @@ onMounted(async () => {
<!-- Body 2-col -->
<div class="ml-body">
<!-- COL 1: Link + Mensagem -->
<!-- COL 1: Sidebar (Como funciona / Boas práticas) esquerda -->
<Teleport to="#ml-mobile-drawer-target" :disabled="!isMobile">
<aside class="ml-side">
<!-- Como funciona -->
<div class="ml-card">
<div class="ml-card__head ml-card__head--simple">
<div class="ml-info-head">
<span class="ml-info-head__icon ml-info-head__icon--primary">
<i class="pi pi-list-check" />
</span>
<div>
<div class="ml-info-head__title">Como funciona</div>
<div class="ml-info-head__sub">Simples e sem fricção pro paciente</div>
</div>
</div>
</div>
<ol class="ml-steps">
<li v-for="step in howItWorks" :key="step.n" class="ml-steps__item">
<div class="ml-steps__num">{{ step.n }}</div>
<div class="ml-steps__text">
<div class="ml-steps__title">{{ step.title }}</div>
<div class="ml-steps__desc">{{ step.desc }}</div>
</div>
</li>
</ol>
</div>
<!-- Boas práticas -->
<div class="ml-card">
<div class="ml-card__head ml-card__head--simple">
<div class="ml-info-head">
<span class="ml-info-head__icon ml-info-head__icon--success">
<i class="pi pi-shield" />
</span>
<div>
<div class="ml-info-head__title">Boas práticas</div>
<div class="ml-info-head__sub">Segurança e privacidade</div>
</div>
</div>
</div>
<ul class="ml-tips">
<li v-for="tip in goodPractices" :key="tip" class="ml-tips__item">
<i class="pi pi-check ml-tips__check" />
<span>{{ tip }}</span>
</li>
</ul>
</div>
<!-- Target do Teleport do Preview pra modo sidebar (mid-desktop 1024-1339).
Em mobile vai pro #ml-main-preview-target; em wide-desktop pro floating. -->
<div id="ml-sidebar-preview-target" class="ml-sidebar-preview-target" />
</aside>
</Teleport>
<!-- COL 2: Main (Link + Mensagem) -->
<div class="ml-main">
<!-- Target do Teleport do Preview pra modo mobile (acima de tudo no main).
Em desktop fica oculto via CSS e o preview teleporta pra sidebar/floating. -->
<div id="ml-main-preview-target" class="ml-main-preview-target" />
<!-- Card: Seu link público -->
<div class="ml-card">
<div class="ml-card__head">
@@ -268,55 +404,30 @@ onMounted(async () => {
</div>
</div>
<!-- COL 2: Instruções -->
<aside class="ml-side">
<!-- Como funciona -->
<div class="ml-card">
<div class="ml-card__head ml-card__head--simple">
<div class="ml-info-head">
<span class="ml-info-head__icon ml-info-head__icon--primary">
<i class="pi pi-list-check" />
</span>
<div>
<div class="ml-info-head__title">Como funciona</div>
<div class="ml-info-head__sub">Simples e sem fricção pro paciente</div>
</div>
</div>
</div>
<ol class="ml-steps">
<li v-for="step in howItWorks" :key="step.n" class="ml-steps__item">
<div class="ml-steps__num">{{ step.n }}</div>
<div class="ml-steps__text">
<div class="ml-steps__title">{{ step.title }}</div>
<div class="ml-steps__desc">{{ step.desc }}</div>
</div>
</li>
</ol>
</div>
<!-- Boas práticas -->
<div class="ml-card">
<div class="ml-card__head ml-card__head--simple">
<div class="ml-info-head">
<span class="ml-info-head__icon ml-info-head__icon--success">
<i class="pi pi-shield" />
</span>
<div>
<div class="ml-info-head__title">Boas práticas</div>
<div class="ml-info-head__sub">Segurança e privacidade</div>
</div>
</div>
</div>
<ul class="ml-tips">
<li v-for="tip in goodPractices" :key="tip" class="ml-tips__item">
<i class="pi pi-check ml-tips__check" />
<span>{{ tip }}</span>
</li>
</ul>
</div>
</aside>
</div>
</section>
<!-- Card de Pré-visualização (Teleport). O target alterna conforme o
viewport: topo do main (mobile) / sidebar (mid-desktop) / painel
flutuante fora do fake dialog (wide-desktop). -->
<Teleport :to="previewTarget">
<div class="ml-card ml-card--preview">
<div class="ml-card__head ml-card__head--simple">
<div class="ml-info-head">
<span class="ml-info-head__icon ml-info-head__icon--primary">
<i class="pi pi-mobile" />
</span>
<div>
<div class="ml-info-head__title">Pré-visualização</div>
<div class="ml-info-head__sub">Como o paciente o link</div>
</div>
</div>
</div>
<div class="ml-card__body ml-card__body--preview">
<CadastroExternoPreview :token="inviteToken" />
</div>
</div>
</Teleport>
</template>
<style scoped>
@@ -454,6 +565,25 @@ onMounted(async () => {
.ml-act-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.ml-act-btn > i { font-size: 0.78rem; }
/* Botao "Menu" mobile (abre drawer com Como funciona / Boas praticas) */
.ml-menu-btn {
display: none;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
border-radius: 8px;
color: var(--m-text);
cursor: pointer;
font-family: inherit;
font-size: 0.78rem;
font-weight: 600;
transition: background-color 140ms ease;
}
.ml-menu-btn:hover { background: var(--m-bg-soft-hover); }
.ml-menu-btn > i { font-size: 0.78rem; color: var(--p-primary-color); }
/* Subheader (blueprint §9) */
.ml-subheader {
display: flex;
@@ -495,13 +625,22 @@ onMounted(async () => {
gap: 12px;
}
/* ─── Col 2: Aside (instruções) ─── */
/* ─── Col 1: Aside (instruções) — agora à ESQUERDA, drawer no mobile ─── */
.ml-side {
width: 280px;
width: 320px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 12px;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.ml-side::-webkit-scrollbar { width: 5px; }
.ml-side::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
/* Card-base */
@@ -786,20 +925,158 @@ onMounted(async () => {
font-size: 0.78rem;
}
/* Mobile: 2-col vira 1-col, instruções vão pra baixo */
/* Mobile: 2-col vira 1-col, instruções viraram drawer (escondido por default) */
@media (max-width: 1023px) {
.ml-body {
flex-direction: column;
padding: 8px;
}
.ml-side {
width: 100%;
}
/* Em mobile, sidebar inline e escondida (vai pro drawer via Teleport) */
.ml-body > .ml-side { display: none; }
.ml-page__title > span:not(.ml-page__title-icon):not(.ml-page__status) {
font-size: 0.92rem;
}
.ml-page__status { display: none; }
.ml-act-btn--primary span { display: none; }
.ml-act-btn--primary { width: 32px; padding: 0; justify-content: center; }
.ml-menu-btn { display: inline-flex; }
}
/* ═══════ Mobile drawer (esquerda, com Como funciona / Boas práticas) ═══════ */
.ml-mobile-drawer {
position: fixed;
top: 0; left: 0;
height: 100vh;
height: 100dvh;
width: min(380px, 90vw);
z-index: 80;
background: var(--m-bg-medium);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
border-right: 1px solid var(--m-border);
transform: translateX(-100%);
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
color: var(--m-text);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
display: flex;
flex-direction: column;
}
.ml-mobile-drawer.is-open { transform: translateX(0); }
.ml-mobile-drawer__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.ml-mobile-drawer__scroll::-webkit-scrollbar { width: 5px; }
.ml-mobile-drawer__scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
.ml-mobile-drawer__scroll .ml-side {
width: 100%;
overflow: visible;
}
.ml-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;
}
.ml-drawer-fade-enter-active,
.ml-drawer-fade-leave-active { transition: opacity 200ms ease; }
.ml-drawer-fade-enter-from,
.ml-drawer-fade-leave-to { opacity: 0; }
/* Fake dialog: largura adaptativa (>=1024px)
Espelha pattern de MelissaAgendador/Negocio fica mesma janela,
drawer a esquerda, sobra espaco a direita pro painel flutuante do preview. */
@media (min-width: 1024px) {
.ml-page {
right: max(6px, min(50%, calc(100% - 1006px)));
}
}
/* Painel flutuante do Preview (wide-desktop >= 1340px)
Vive FORA do fake dialog, ancorado a sua right edge + 14px gap.
Largura 320px pra acomodar o phone-frame (260px + bordas + padding). */
.ml-floating-preview {
display: none; /* default: oculto. Wide-desktop ativa via @media abaixo. */
position: absolute;
top: 6px;
max-height: calc(100% - var(--m-dock-h, 76px) - 12px);
width: 320px;
z-index: 39;
overflow-y: auto;
overflow-x: hidden;
padding: 0;
background: var(--m-bg-medium);
backdrop-filter: blur(32px) saturate(160%);
-webkit-backdrop-filter: blur(32px) saturate(160%);
border: 1px solid var(--m-border);
border-radius: 18px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
color: var(--m-text);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.ml-floating-preview::-webkit-scrollbar { width: 5px; }
.ml-floating-preview::-webkit-scrollbar-thumb { background: var(--m-border-strong); border-radius: 3px; }
/* Card de preview ganha estilo distinto pra destacar do .ml-card padrao */
.ml-card--preview {
background: var(--m-bg-medium);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.16);
}
.ml-card__body--preview {
padding: 14px;
}
/* Placeholders do preview na sidebar/main: nao introduzem wrapper visivel.
Os filhos teleportados se posicionam como flex items diretos do parent. */
.ml-sidebar-preview-target,
.ml-main-preview-target { display: contents; }
/* Hide rules por breakpoint */
@media (max-width: 1023px) {
.ml-sidebar-preview-target { display: none; }
}
@media (min-width: 1024px) {
.ml-main-preview-target { display: none; }
}
/* Dentro do painel flutuante, o card perde o "card-em-card":
sem fundo/borda/sombra propria (o glass do painel ja faz esse papel). */
.ml-floating-preview > .ml-card--preview {
background: transparent;
border: none;
box-shadow: none;
border-radius: 0;
}
.ml-floating-preview > .ml-card--preview > .ml-card__head {
border-bottom: 1px solid var(--m-border);
padding: 14px 18px;
}
.ml-floating-preview > .ml-card--preview > .ml-card__body--preview {
padding: 14px 18px;
}
/* Wide-desktop: floating ativo, ancorado a right edge da .ml-page + 14px gap */
@media (min-width: 1340px) {
.ml-floating-preview {
display: block;
left: calc(100% - max(6px, min(50%, calc(100% - 1006px))) + 14px);
}
.ml-sidebar-preview-target { display: none; }
}
</style>
+9
View File
@@ -2546,4 +2546,13 @@ watch(editPatientDialog, (isOpen) => {
.mm-act-btn--primary span { display: none; }
.mm-act-btn--primary { width: 32px; padding: 0; justify-content: center; }
}
/* Fake dialog: largura adaptativa (>=1024px)
Espelha pattern de MelissaAgendador/Negocio fica mesma janela,
drawer a esquerda, sobra espaco a direita. */
@media (min-width: 1024px) {
.mm-page {
right: max(6px, min(50%, calc(100% - 1006px)));
}
}
</style>
File diff suppressed because it is too large Load Diff
+19 -17
View File
@@ -18,13 +18,14 @@
* e patient_patient_tag (via patientsRepository.listGroupsByPatient/Tags...)
*
* Integrações:
* - PatientProntuario (overlay dialog) abre via duplo-click no card ou
* - MelissaPaciente (rota /melissa/paciente?id=X) abre via duplo-click ou
* botão "Abrir prontuário" da COL 3
* - PatientCadastroDialog cadastro completo / edição
* - PatientCreatePopover + ComponentCadastroRapido fluxo de novo paciente
* - conversationDrawerStore botão WhatsApp da COL 3
*/
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { useMelissaPacientes } from './composables/useMelissaPacientes';
@@ -40,7 +41,6 @@ import {
softDeletePatient
} from '@/features/patients/services/patientsRepository';
import { usePatientLifecycle } from '@/composables/usePatientLifecycle';
import PatientProntuario from '@/features/patients/prontuario/PatientProntuario.vue';
import PatientCreatePopover from '@/components/ui/PatientCreatePopover.vue';
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue';
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue';
@@ -52,6 +52,8 @@ const emit = defineEmits(['close', 'patient-created', 'goto-agenda', 'goto-grupo
const toast = useToast();
const confirm = useConfirm();
const tenantStore = useTenantStore();
const router = useRouter();
const route = useRoute();
const { reactivatePatient } = usePatientLifecycle();
const conversationDrawerStore = useConversationDrawerStore();
@@ -284,6 +286,13 @@ onMounted(() => {
isCompact.value = _mqCompact.matches;
_mqCompact.addEventListener('change', _onMqCompactChange);
}
// Fase 8 wire-up: se navegou pra ca com ?edit=<id> (vindo do
// MelissaPaciente), abre o cadastro full direto.
if (route.query.edit) {
editPatientId.value = String(route.query.edit);
cadastroFullDialog.value = true;
router.replace({ query: { ...route.query, edit: undefined } });
}
});
onBeforeUnmount(() => {
if (_mqMobile) _mqMobile.removeEventListener('change', _onMqMobileChange);
@@ -534,9 +543,6 @@ const cadastroFullDialog = ref(false);
const quickDialog = ref(false);
const editPatientId = ref(null);
const prontuarioOpen = ref(false);
const prontuarioPatient = ref(null);
function openCreatePopover(e) {
createPopoverRef.value?.toggle(e);
}
@@ -551,10 +557,12 @@ function onPatientCreated() {
refetchTudo();
}
// Abrir prontuario agora navega pra MelissaPaciente nativo (Fase 8 wire-up).
// O Dialog PatientProntuario.vue legacy continua existindo pros 2 callsites
// fora do Melissa (TherapistDashboard e PatientsListPage).
function abrirProntuario(p) {
if (!p) return;
prontuarioPatient.value = { ...p };
prontuarioOpen.value = true;
if (!p?.id) return;
router.push({ path: '/melissa/paciente', query: { id: String(p.id) } });
}
function editarPaciente(p) {
@@ -1349,15 +1357,9 @@ function sessaoStatusColor(s) {
@created="onPatientCreated"
/>
<!-- Dialog Prontuário :key força re-mount quando troca de paciente -->
<PatientProntuario
v-if="prontuarioPatient"
:key="prontuarioPatient?.id || 'none'"
v-model="prontuarioOpen"
:patient="prontuarioPatient"
@close="prontuarioOpen = false"
@edit="(id) => { prontuarioOpen = false; editPatientId = String(id); cadastroFullDialog = true; }"
/>
<!-- Prontuario migrado pra MelissaPaciente nativo (Fase 8 wire-up).
abrirProntuario(p) navega pra /melissa/paciente?id=X via router. -->
<!-- Dialog: Novo grupo (nome + cor) -->
<Dialog
@@ -1445,4 +1445,13 @@ onBeforeUnmount(() => {
.mr-card__btn { font-size: 0.72rem; padding: 5px 9px; }
.mr-stats-row { padding: 0 12px 6px; }
}
/* Fake dialog: largura adaptativa (>=1024px)
Espelha pattern de MelissaAgendador/Negocio fica mesma janela,
drawer a esquerda, sobra espaco a direita. */
@media (min-width: 1024px) {
.mr-page {
right: max(6px, min(50%, calc(100% - 1006px)));
}
}
</style>
+9
View File
@@ -2368,4 +2368,13 @@ watch(editPatientDialog, (isOpen) => {
.mt-act-btn--primary span { display: none; }
.mt-act-btn--primary { width: 32px; padding: 0; justify-content: center; }
}
/* Fake dialog: largura adaptativa (>=1024px)
Espelha pattern de MelissaAgendador/Negocio fica mesma janela,
drawer a esquerda, sobra espaco a direita. */
@media (min-width: 1024px) {
.mt-page {
right: max(6px, min(50%, calc(100% - 1006px)));
}
}
</style>
@@ -590,6 +590,48 @@ function _buildHandlers(deps) {
dialogOpen.value = true;
}
// ── onCreateEventoForPatient — abre o AgendaEventDialog com paciente
// pre-selecionado. Usado pelo MelissaPaciente quando o user clica
// "Agendar" na sidebar Acoes Rapidas. Mesma logica de onCreateEvento
// (defaults razoaveis: hoje proximo slot 15min, duracao default), so
// que injeta paciente_id no dialogEventRow.
function onCreateEventoForPatient(patientId) {
if (!ownerId.value) {
toast.add({
severity: 'warn',
summary: 'Agenda',
detail: 'Aguarde carregar as configurações da agenda.',
life: 3000
});
return;
}
const durMin =
settings.value?.session_duration_min ??
settings.value?.duracao_padrao_minutos ??
50;
const base = new Date();
base.setSeconds(0, 0);
const remainder = base.getMinutes() % 15;
if (remainder !== 0) {
base.setMinutes(base.getMinutes() + (15 - remainder));
}
dialogEventRow.value = {
owner_id: ownerId.value,
terapeuta_id: null,
paciente_id: patientId ? String(patientId) : null,
tipo: EVENTO_TIPO.SESSAO,
status: 'agendado',
titulo: deriveTituloDefaultByTipo(EVENTO_TIPO.SESSAO),
observacoes: null,
visibility_scope: 'public',
determined_commitment_id: null
};
dialogStartISO.value = base.toISOString();
dialogEndISO.value = new Date(base.getTime() + durMin * 60000).toISOString();
dialogOpen.value = true;
}
// ── onSelectTime — click-drag no FC pra criar evento novo ──
// Dinâmica de duração:
// click sem drag → settings.session_duration_min (default 50)
@@ -814,6 +856,7 @@ function _buildHandlers(deps) {
return {
onEditEvento,
onCreateEvento,
onCreateEventoForPatient,
onSelectTime,
persistMoveOrResize,
onEditSeriesOccurrence,