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>
This commit is contained in:
Leonardo
2026-05-08 19:05:25 -03:00
parent b040e15c9b
commit 88dff50223
2 changed files with 73 additions and 381 deletions
+29 -380
View File
@@ -18,11 +18,11 @@
*
* Prefixo CSS: .mpa-* (Melissa PAciente).
*/
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue';
import { ref, computed, watch, nextTick, inject, onMounted, onBeforeUnmount } from 'vue';
import { useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
import { supabase } from '@/lib/supabase/client';
import { MELISSA_AGENDA_KEY } from './composables/useMelissaAgenda';
import DocumentsListPage from '@/features/documents/DocumentsListPage.vue';
import PatientConversationsTab from '@/features/patients/prontuario/PatientConversationsTab.vue';
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue';
@@ -32,7 +32,6 @@ import { usePatientFinancial } from '@/features/patients/composables/usePatientF
import { usePatientMessages } from '@/features/patients/composables/usePatientMessages';
import { usePatientDocuments } from '@/features/patients/composables/usePatientDocuments';
import { usePatientRecurrences } from '@/features/patients/composables/usePatientRecurrences';
import { useRecurrence } from '@/features/agenda/composables/useRecurrence';
import {
pickField,
calcAge,
@@ -44,7 +43,6 @@ import {
fmtDayShort,
fmtRecurrenceLabel,
fmtRecurrenceFim,
WEEKDAY_LABEL as WEEKDAY_LABEL_BLOCK,
fmtCPF,
fmtRG,
fmtGender,
@@ -70,6 +68,11 @@ const router = useRouter();
const toast = useToast();
const conversationDrawerStore = useConversationDrawerStore();
// Inject do MelissaLayout — da acesso ao AgendaEventDialog global,
// suas mutations (onCreateEventoForPatient) e o flag dialogOpen pra
// detectar fechamento e refetchar sessions/recorrencias.
const melissaAgenda = inject(MELISSA_AGENDA_KEY, null);
// ── Composables de dados ───────────────────────────────────
const detail = usePatientDetail();
const sessionsHook = usePatientSessions();
@@ -77,7 +80,6 @@ const financialHook = usePatientFinancial();
const messagesHook = usePatientMessages();
const documentsHook = usePatientDocuments();
const recorrenciasHook = usePatientRecurrences();
const recurrenceHook = useRecurrence();
// ── Breakpoints + drawer ───────────────────────────────────
const drawerOpen = ref(false);
@@ -456,85 +458,8 @@ function addFinancial() {
novoLancOpen.value = true;
}
// Atalho: navega pra aba Agenda + abre dialog de nova sessao.
// Frequencia espelha AgendaEventDialog: avulsa | semanal | quinzenal |
// diasEspecificos. Avulsa cria 1 row em agenda_eventos; demais criam
// regra em recurrence_rules via useRecurrence.
const novaSessaoOpen = ref(false);
const novaSessaoForm = ref({
tipo: 'sessao',
data: '',
hora: '',
duracao_min: 50,
modalidade: 'presencial',
titulo_custom: '',
observacoes: '',
freq: 'avulsa',
diasSelecionados: [],
qtdMode: '12',
qtdCustom: 12
});
const FREQ_OPCOES = [
{ value: 'avulsa', label: 'Avulsa' },
{ value: 'semanal', label: 'Semanal' },
{ value: 'quinzenal', label: 'Quinzenal' },
{ value: 'diasEspecificos', label: 'Dias específicos' }
];
const DIAS_SEMANA_OPCOES = [
{ value: 1, short: 'Seg' },
{ value: 2, short: 'Ter' },
{ value: 3, short: 'Qua' },
{ value: 4, short: 'Qui' },
{ value: 5, short: 'Sex' },
{ value: 6, short: 'Sáb' },
{ value: 0, short: 'Dom' }
];
const QTD_SESSOES_OPCOES = [
{ value: '4', label: '4 sessões' },
{ value: '8', label: '8 sessões' },
{ value: '12', label: '12 sessões' },
{ value: 'personalizar', label: 'Personalizar' }
];
function toggleDiaSelecionado(d) {
const arr = [...(novaSessaoForm.value.diasSelecionados || [])];
const idx = arr.indexOf(d);
if (idx === -1) arr.push(d);
else arr.splice(idx, 1);
novaSessaoForm.value.diasSelecionados = arr;
}
const qtdSessoesEfetiva = computed(() => {
const f = novaSessaoForm.value;
if (f.qtdMode === 'personalizar') return Number(f.qtdCustom) || null;
return Number(f.qtdMode) || null;
});
// Label dinamico do botao "Salvar"
const novaSessaoCtaLabel = computed(() => {
return novaSessaoForm.value.freq === 'avulsa' ? 'Agendar sessão' : 'Criar recorrência';
});
const SESSAO_TIPOS = [
{ label: 'Sessão', value: 'sessao' },
{ label: 'Primeira consulta', value: 'primeira' },
{ label: 'Retorno', value: 'retorno' },
{ label: 'Avaliação', value: 'avaliacao' },
{ label: 'Devolutiva', value: 'devolutiva' }
];
const SESSAO_DURACOES = [
{ label: '30 min', value: 30 },
{ label: '40 min', value: 40 },
{ label: '45 min', value: 45 },
{ label: '50 min', value: 50 },
{ label: '55 min', value: 55 },
{ label: '1 hora', value: 60 },
{ label: '1h 30', value: 90 },
{ label: '2 horas', value: 120 }
];
const SESSAO_MODALIDADES = [
{ label: 'Presencial', value: 'presencial' },
{ label: 'Online (vídeo)', value: 'online' }
];
// Abre o AgendaEventDialog GLOBAL (mesmo que MelissaAgenda usa) com
// paciente pre-selecionado. O dialog vive no MelissaLayout via provide.
function goAgendar() {
activeTab.value = 'agenda';
if (isMobile.value) drawerOpen.value = false;
@@ -542,132 +467,30 @@ function goAgendar() {
toast.add({ severity: 'warn', summary: 'Paciente sem ID', life: 2500 });
return;
}
// Default: amanha as 09:00, sessao 50min presencial.
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
novaSessaoForm.value = {
tipo: 'sessao',
data: tomorrow.toISOString().slice(0, 10),
hora: '09:00',
duracao_min: 50,
modalidade: 'presencial',
titulo_custom: '',
observacoes: '',
freq: 'avulsa',
diasSelecionados: [],
qtdMode: '12',
qtdCustom: 12
};
novaSessaoOpen.value = true;
}
async function salvarSessao() {
const f = novaSessaoForm.value;
if (!f.data) {
toast.add({ severity: 'warn', summary: 'Data obrigatória', life: 2500 });
return;
}
if (!f.hora) {
toast.add({ severity: 'warn', summary: 'Hora obrigatória', life: 2500 });
return;
}
const [y, m, d] = f.data.split('-').map(Number);
const [hh, mm] = f.hora.split(':').map(Number);
const inicio = new Date(y, (m || 1) - 1, d || 1, hh || 0, mm || 0, 0);
if (Number.isNaN(inicio.getTime())) {
toast.add({ severity: 'warn', summary: 'Data/hora inválida', life: 2500 });
return;
}
// Caminho RECORRENTE: cria regra em recurrence_rules via useRecurrence.
// Ocorrencias sao geradas dinamicamente.
if (f.freq !== 'avulsa') {
// Mapeamento freq -> { type, interval, weekdays }
let type, interval, weekdays;
if (f.freq === 'semanal') {
type = 'weekly';
interval = 1;
weekdays = [inicio.getDay()];
} else if (f.freq === 'quinzenal') {
type = 'biweekly';
interval = 2;
weekdays = [inicio.getDay()];
} else if (f.freq === 'diasEspecificos') {
if (!f.diasSelecionados.length) {
toast.add({ severity: 'warn', summary: 'Selecione ao menos um dia da semana', life: 3000 });
return;
}
type = 'custom_weekdays';
interval = 1;
weekdays = [...f.diasSelecionados].sort();
}
const max = qtdSessoesEfetiva.value;
if (!max || max < 1) {
toast.add({ severity: 'warn', summary: 'Quantidade de sessões inválida', life: 2500 });
return;
}
try {
const { data: userData } = await supabase.auth.getUser();
const ownerId = userData?.user?.id;
const rule = {
patient_id: props.patientId,
owner_id: ownerId,
type,
interval,
weekdays,
start_date: f.data,
end_date: null,
max_occurrences: max,
start_time: f.hora,
duration_min: Number(f.duracao_min) || 50,
modalidade: f.modalidade,
titulo_custom: String(f.titulo_custom || '').trim() || null,
observacoes: String(f.observacoes || '').trim() || null,
status: 'ativo'
};
await recurrenceHook.createRule(rule);
if (!melissaAgenda || typeof melissaAgenda.onCreateEventoForPatient !== 'function') {
toast.add({
severity: 'success',
summary: 'Recorrência criada',
detail: `${max} sessões previstas. Veja em "Recorrências".`,
severity: 'warn',
summary: 'Agenda indisponível',
detail: 'Aguarde a agenda carregar e tente novamente.',
life: 3000
});
novaSessaoOpen.value = false;
return;
}
melissaAgenda.onCreateEventoForPatient(props.patientId);
}
// Watch o dialogOpen do AgendaEventDialog: quando ele fecha (true -> false)
// refetcha sessoes + recorrencias do paciente. Cobre tanto save quanto
// close-sem-salvar (idempotente; load() ja eh barato).
if (melissaAgenda?.dialogOpen) {
watch(melissaAgenda.dialogOpen, async (now, prev) => {
if (prev && !now && props.patientId) {
await Promise.all([
sessionsHook.load(props.patientId),
recorrenciasHook.load(props.patientId)
]);
} catch (e) {
toast.add({
severity: 'error',
summary: 'Falha ao criar recorrência',
detail: e?.message || 'Erro inesperado',
life: 4000
});
}
return;
}
// Caminho SESSAO UNICA: insert direto em agenda_eventos.
const fim = new Date(inicio.getTime() + (Number(f.duracao_min) || 50) * 60 * 1000);
const result = await sessionsHook.createSession(props.patientId, {
inicio_em: inicio.toISOString(),
fim_em: fim.toISOString(),
tipo: f.tipo,
modalidade: f.modalidade,
titulo_custom: f.titulo_custom,
observacoes: f.observacoes
});
if (result.ok) {
toast.add({ severity: 'success', summary: 'Sessão agendada', life: 2200 });
novaSessaoOpen.value = false;
} else {
toast.add({
severity: 'error',
summary: 'Falha ao agendar',
detail: result.error || 'Erro inesperado',
life: 4000
});
}
}
async function salvarLancamento() {
const f = novoLancForm.value;
@@ -2381,185 +2204,11 @@ onBeforeUnmount(() => {
</template>
</Dialog>
<!-- Dialog: nova sessao na agenda. Mesma logica do Lancamento form
simples inline com tipo/data/hora/duracao/modalidade/titulo/obs.
Header custom: icon + titulo + subtitulo com nome do paciente. -->
<Dialog
v-model:visible="novaSessaoOpen"
modal
dismissable-mask
:style="{ width: '460px', maxWidth: '92vw' }"
>
<template #header>
<div class="mpa-dlg-head">
<div class="mpa-dlg-head__icon">
<i class="pi pi-calendar-plus" />
</div>
<div class="mpa-dlg-head__text">
<div class="mpa-dlg-head__title">Nova sessão</div>
<div class="mpa-dlg-head__sub">{{ dash(nomeCompleto) }}</div>
</div>
</div>
</template>
<div class="mpa-novo-lanc">
<div class="mpa-novo-lanc__field">
<label class="mpa-novo-lanc__label">Tipo</label>
<Select
v-model="novaSessaoForm.tipo"
:options="SESSAO_TIPOS"
option-label="label"
option-value="value"
class="w-full"
/>
</div>
<div class="mpa-novo-lanc__row">
<div class="mpa-novo-lanc__field">
<label class="mpa-novo-lanc__label">Data *</label>
<InputText
v-model="novaSessaoForm.data"
type="date"
class="w-full"
/>
</div>
<div class="mpa-novo-lanc__field">
<label class="mpa-novo-lanc__label">Hora *</label>
<InputText
v-model="novaSessaoForm.hora"
type="time"
class="w-full"
/>
</div>
</div>
<div class="mpa-novo-lanc__row">
<div class="mpa-novo-lanc__field">
<label class="mpa-novo-lanc__label">Duração</label>
<Select
v-model="novaSessaoForm.duracao_min"
:options="SESSAO_DURACOES"
option-label="label"
option-value="value"
class="w-full"
/>
</div>
<div class="mpa-novo-lanc__field">
<label class="mpa-novo-lanc__label">Modalidade</label>
<Select
v-model="novaSessaoForm.modalidade"
:options="SESSAO_MODALIDADES"
option-label="label"
option-value="value"
class="w-full"
/>
</div>
</div>
<div class="mpa-novo-lanc__field">
<label class="mpa-novo-lanc__label">Título <span class="mpa-novo-lanc__opt">(opcional)</span></label>
<InputText
v-model="novaSessaoForm.titulo_custom"
placeholder="Ex: Sessão de avaliação inicial"
class="w-full"
/>
</div>
<div class="mpa-novo-lanc__field">
<label class="mpa-novo-lanc__label">Observações <span class="mpa-novo-lanc__opt">(opcional)</span></label>
<Textarea
v-model="novaSessaoForm.observacoes"
rows="2"
auto-resize
placeholder="Anotações que aparecem no card da agenda"
class="w-full"
/>
</div>
<!-- Bloco de frequencia (espelha AgendaEventDialog: chips + qtd) -->
<div class="mpa-recur">
<label class="mpa-novo-lanc__label">Frequência</label>
<div class="mpa-freq-chips">
<button
v-for="opt in FREQ_OPCOES"
:key="opt.value"
type="button"
class="mpa-freq-chip"
:class="{ 'is-active': novaSessaoForm.freq === opt.value }"
@click="novaSessaoForm.freq = opt.value"
>
{{ opt.label }}
</button>
</div>
<!-- Preview semanal/quinzenal -->
<div v-if="novaSessaoForm.freq === 'semanal' || novaSessaoForm.freq === 'quinzenal'" class="mpa-freq-preview">
<i class="pi pi-refresh" />
<span>
{{ novaSessaoForm.freq === 'quinzenal' ? 'A cada 2 semanas, toda' : 'Toda' }}
{{ novaSessaoForm.data ? (WEEKDAY_LABEL_BLOCK[new Date(novaSessaoForm.data + 'T00:00:00').getDay()] || '...').toLowerCase() : '...' }},
às {{ novaSessaoForm.hora || '—' }}
</span>
</div>
<!-- Dias da semana (diasEspecificos) -->
<div v-if="novaSessaoForm.freq === 'diasEspecificos'" class="mpa-freq-dias">
<label class="mpa-novo-lanc__label">Dias da semana</label>
<div class="mpa-dias-grid">
<button
v-for="d in DIAS_SEMANA_OPCOES"
:key="d.value"
type="button"
class="mpa-dia-chip"
:class="{ 'is-active': novaSessaoForm.diasSelecionados.includes(d.value) }"
@click="toggleDiaSelecionado(d.value)"
>
{{ d.short }}
</button>
</div>
</div>
<!-- Quantidade de sessoes (so quando nao avulsa) -->
<div v-if="novaSessaoForm.freq !== 'avulsa'" class="mpa-freq-qtd">
<label class="mpa-novo-lanc__label">Quantidade de sessões</label>
<div class="mpa-freq-chips">
<button
v-for="opt in QTD_SESSOES_OPCOES"
:key="opt.value"
type="button"
class="mpa-freq-chip mpa-freq-chip--sm"
:class="{ 'is-active': novaSessaoForm.qtdMode === opt.value }"
@click="novaSessaoForm.qtdMode = opt.value"
>
{{ opt.label }}
</button>
</div>
<div v-if="novaSessaoForm.qtdMode === 'personalizar'" class="mpa-freq-qtd-custom">
<InputNumber
v-model="novaSessaoForm.qtdCustom"
:min="1"
:max="200"
show-buttons
button-layout="horizontal"
fluid
>
<template #decrementbuttonicon><i class="pi pi-minus" /></template>
<template #incrementbuttonicon><i class="pi pi-plus" /></template>
</InputNumber>
</div>
</div>
</div>
</div>
<template #footer>
<button class="mpa-quick-btn" @click="novaSessaoOpen = false">
<i class="pi pi-times" />
<span>Cancelar</span>
</button>
<button
class="mpa-quick-btn mpa-quick-btn--cta"
:disabled="sessionsHook.busy.value"
@click="salvarSessao"
>
<i :class="sessionsHook.busy.value ? 'pi pi-spin pi-spinner' : 'pi pi-check'" :style="{ color: '#10b981' }" />
<span>{{ novaSessaoCtaLabel }}</span>
</button>
</template>
</Dialog>
<!-- Dialog Nova Sessao removido agora usa o AgendaEventDialog
GLOBAL do MelissaLayout (mesmo que a Agenda usa). MelissaPaciente
dispara via melissaAgenda.onCreateEventoForPatient(patientId).
Watch em melissaAgenda.dialogOpen refetcha sessions+recorrencias
quando o dialog fecha (cobre save e cancel). -->
</template>
<style scoped>
@@ -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,