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>
This commit is contained in:
Leonardo
2026-05-08 19:27:32 -03:00
parent 88dff50223
commit 30d09eb2ac
2 changed files with 154 additions and 33 deletions
@@ -105,7 +105,24 @@ const props = defineProps({
feriados: { type: Array, default: () => [] }, feriados: { type: Array, default: () => [] },
// Rota para cadastro completo de paciente (abre em nova aba) // 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']); const emit = defineEmits(['update:modelValue', 'save', 'delete', 'updateSeriesEvent', 'editSeriesOccurrence', 'updated']);
@@ -176,6 +193,17 @@ const {
resetForm resetForm
} = _composer; } = _composer;
// Lock de tipo: quando lockType=true (callsite no contexto de Sessao do
// paciente, ex: MelissaPaciente), pula o step 1 e vai direto pro form.
// Watch immediate cobre tanto open quanto re-open com novo eventRow.
watch(
() => [props.lockType, props.modelValue],
([locked, open]) => {
if (locked && open) step.value = 2;
},
{ immediate: true }
);
// ── recorrência: opções estáticas (consts data — não migradas) ── // ── recorrência: opções estáticas (consts data — não migradas) ──
const freqOpcoes = [ const freqOpcoes = [
{ value: 'avulsa', label: 'Avulsa' }, { value: 'avulsa', label: 'Avulsa' },
@@ -718,13 +746,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"> <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> <template #header>
<div class="w-full flex items-center justify-between gap-3"> <div class="w-full flex items-center justify-between gap-3">
<div class="flex items-center gap-2 min-w-0"> <!-- Slot headerLeft (override de chamadas como MelissaPaciente
<div class="header-dot shrink-0" :style="selectedCommitment?.bg_color ? { background: `#${selectedCommitment.bg_color}` } : {}" /> que precisam icon+title+subtitle custom). Default: dot
<div class="min-w-0"> colorido + headerTitle + previewRange. -->
<div class="font-semibold truncate text-base">{{ headerTitle }}</div> <slot name="headerLeft">
<div v-if="step === 2" class="text-xs text-color-secondary truncate">{{ previewRange }}</div> <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>
</div> </slot>
<!-- actions moved to footer --> <!-- actions moved to footer -->
</div> </div>
@@ -733,9 +766,9 @@ const googleCalendarUrl = computed(() => {
<!-- ConfirmDialog renderizado na página pai para evitar conflito de z-index com o Dialog --> <!-- 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> <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"> <Message v-if="isDayBlocked" severity="warn" class="mb-4" :closable="false">
@@ -863,9 +896,9 @@ const googleCalendarUrl = computed(() => {
</div> </div>
</div> </div>
<div class="flex gap-1 shrink-0"> <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 && !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" icon="pi pi-times" severity="secondary" text size="small" class="rounded-full h-8 w-8" v-tooltip.top="'Limpar'" @click="clearPaciente" /> <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" 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> <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>
</div> </div>
+109 -21
View File
@@ -23,6 +23,7 @@ import { useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast'; import { useToast } from 'primevue/usetoast';
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore'; import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
import { MELISSA_AGENDA_KEY } from './composables/useMelissaAgenda'; import { MELISSA_AGENDA_KEY } from './composables/useMelissaAgenda';
import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.vue';
import DocumentsListPage from '@/features/documents/DocumentsListPage.vue'; import DocumentsListPage from '@/features/documents/DocumentsListPage.vue';
import PatientConversationsTab from '@/features/patients/prontuario/PatientConversationsTab.vue'; import PatientConversationsTab from '@/features/patients/prontuario/PatientConversationsTab.vue';
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue'; import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue';
@@ -458,8 +459,16 @@ function addFinancial() {
novoLancOpen.value = true; novoLancOpen.value = true;
} }
// Abre o AgendaEventDialog GLOBAL (mesmo que MelissaAgenda usa) com // Abre AgendaEventDialog LOCAL (instancia propria) com paciente fixo
// paciente pre-selecionado. O dialog vive no MelissaLayout via provide. // e tipo='sessao' travado. Dados pesados (commitmentOptions, workRules,
// allEvents, agendaSettings, feriados, ownerId, tenantId) vem via inject
// do MelissaLayout. State do dialog (open/eventRow/start/end) e LOCAL
// pra nao colidir com o dialog global da Agenda.
const sessaoDialogOpen = ref(false);
const sessaoDialogEventRow = ref(null);
const sessaoDialogStartISO = ref(null);
const sessaoDialogEndISO = ref(null);
function goAgendar() { function goAgendar() {
activeTab.value = 'agenda'; activeTab.value = 'agenda';
if (isMobile.value) drawerOpen.value = false; if (isMobile.value) drawerOpen.value = false;
@@ -467,7 +476,7 @@ function goAgendar() {
toast.add({ severity: 'warn', summary: 'Paciente sem ID', life: 2500 }); toast.add({ severity: 'warn', summary: 'Paciente sem ID', life: 2500 });
return; return;
} }
if (!melissaAgenda || typeof melissaAgenda.onCreateEventoForPatient !== 'function') { if (!melissaAgenda?.ownerId?.value) {
toast.add({ toast.add({
severity: 'warn', severity: 'warn',
summary: 'Agenda indisponível', summary: 'Agenda indisponível',
@@ -476,21 +485,58 @@ function goAgendar() {
}); });
return; return;
} }
melissaAgenda.onCreateEventoForPatient(props.patientId); // Defaults razoaveis: proximo slot 15min + duracao padrao da agenda.
const durMin =
melissaAgenda.settings?.value?.session_duration_min ??
melissaAgenda.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));
sessaoDialogEventRow.value = {
owner_id: melissaAgenda.ownerId.value,
terapeuta_id: null,
paciente_id: String(props.patientId),
tipo: 'sessao',
status: 'agendado',
titulo: null,
observacoes: null,
visibility_scope: 'public',
determined_commitment_id: null
};
sessaoDialogStartISO.value = base.toISOString();
sessaoDialogEndISO.value = new Date(base.getTime() + durMin * 60000).toISOString();
sessaoDialogOpen.value = true;
} }
// Watch o dialogOpen do AgendaEventDialog: quando ele fecha (true -> false) // Handlers do dialog local — delegam pros handlers globais do useMelissaAgenda
// refetcha sessoes + recorrencias do paciente. Cobre tanto save quanto // (M.onDialogSave/Delete) que ja sabem mexer com agenda_eventos +
// close-sem-salvar (idempotente; load() ja eh barato). // recurrence_rules + exceptions. Apos fechar, refetcha sessions+recorrencias.
if (melissaAgenda?.dialogOpen) { async function onSessaoDialogSave(payload) {
watch(melissaAgenda.dialogOpen, async (now, prev) => { if (typeof melissaAgenda?.onDialogSave === 'function') {
if (prev && !now && props.patientId) { await melissaAgenda.onDialogSave(payload);
await Promise.all([ }
sessionsHook.load(props.patientId), sessaoDialogOpen.value = false;
recorrenciasHook.load(props.patientId) if (props.patientId) {
]); await Promise.all([
} sessionsHook.load(props.patientId),
}); recorrenciasHook.load(props.patientId)
]);
}
}
async function onSessaoDialogDelete(payload) {
if (typeof melissaAgenda?.onDialogDelete === 'function') {
await melissaAgenda.onDialogDelete(payload);
}
sessaoDialogOpen.value = false;
if (props.patientId) {
await Promise.all([
sessionsHook.load(props.patientId),
recorrenciasHook.load(props.patientId)
]);
}
} }
async function salvarLancamento() { async function salvarLancamento() {
const f = novoLancForm.value; const f = novoLancForm.value;
@@ -2204,11 +2250,53 @@ onBeforeUnmount(() => {
</template> </template>
</Dialog> </Dialog>
<!-- Dialog Nova Sessao removido agora usa o AgendaEventDialog <!-- AgendaEventDialog LOCAL do prontuario.
GLOBAL do MelissaLayout (mesmo que a Agenda usa). MelissaPaciente - Reusa o componente da Agenda (sem duplicar codigo) com 2 props
dispara via melissaAgenda.onCreateEventoForPatient(patientId). novas: lockType (pula step 1) + lockPatient (esconde trocar/limpar).
Watch em melissaAgenda.dialogOpen refetcha sessions+recorrencias - Slot #headerLeft sobrescreve o header padrao com icon + "Nova
quando o dialog fecha (cobre save e cancel). --> sessao" + nome do paciente.
- State LOCAL (sessaoDialog*) nao colide com o dialog global do
MelissaLayout que continua na Agenda.
- Dados pesados (commitmentOptions, workRules, allEvents,
agendaSettings, feriados, ownerId, tenantId) vem via inject do
MelissaLayout evita re-fetch.
- Save/Delete delegam pros handlers globais (M.onDialogSave/Delete)
que ja sabem orquestrar agenda_eventos + recurrence_rules. -->
<AgendaEventDialog
v-if="melissaAgenda"
v-model="sessaoDialogOpen"
:event-row="sessaoDialogEventRow"
:initial-start-i-s-o="sessaoDialogStartISO"
:initial-end-i-s-o="sessaoDialogEndISO"
:owner-id="melissaAgenda.ownerId?.value || ''"
:tenant-id="melissaAgenda.clinicTenantId?.value || ''"
:commitment-options="melissaAgenda.commitmentOptions?.value || []"
:work-rules="melissaAgenda.workRules?.value || []"
:blocked-dates="[]"
:agenda-settings="melissaAgenda.settings?.value || null"
:all-events="melissaAgenda.allEventsForDialog?.value || []"
:pausas-semanais="melissaAgenda.settings?.value?.pausas_semanais || []"
:feriados="melissaAgenda.feriados?.value || []"
new-patient-route="/therapist/patients/cadastro"
:lock-type="true"
:lock-patient="true"
@save="onSessaoDialogSave"
@delete="onSessaoDialogDelete"
@updateSeriesEvent="onSessaoDialogSave"
@editSeriesOccurrence="onSessaoDialogSave"
>
<template #headerLeft>
<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>
</AgendaEventDialog>
</template> </template>
<style scoped> <style scoped>