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>
This commit is contained in:
Leonardo
2026-05-08 11:32:36 -03:00
parent a8ab13b201
commit f1d6fbad73
+177 -7
View File
@@ -22,6 +22,7 @@ import { ref, computed, watch, nextTick, 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 DocumentsListPage from '@/features/documents/DocumentsListPage.vue';
import PatientConversationsTab from '@/features/patients/prontuario/PatientConversationsTab.vue';
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue';
@@ -30,6 +31,7 @@ import { usePatientSessions } from '@/features/patients/composables/usePatientSe
import { usePatientFinancial } from '@/features/patients/composables/usePatientFinancial';
import { usePatientMessages } from '@/features/patients/composables/usePatientMessages';
import { usePatientDocuments } from '@/features/patients/composables/usePatientDocuments';
import { useRecurrence } from '@/features/agenda/composables/useRecurrence';
import {
pickField,
calcAge,
@@ -70,6 +72,7 @@ const sessionsHook = usePatientSessions();
const financialHook = usePatientFinancial();
const messagesHook = usePatientMessages();
const documentsHook = usePatientDocuments();
const recurrenceHook = useRecurrence();
// ── Breakpoints + drawer ───────────────────────────────────
const drawerOpen = ref(false);
@@ -425,7 +428,12 @@ const novaSessaoForm = ref({
duracao_min: 50,
modalidade: 'presencial',
titulo_custom: '',
observacoes: ''
observacoes: '',
// Recorrencia (integra com useRecurrence — schema recurrence_rules)
repetir: false,
fim_tipo: 'open', // open | data | count
fim_data: '',
fim_count: 12
});
const SESSAO_TIPOS = [
{ label: 'Sessão', value: 'sessao' },
@@ -465,7 +473,11 @@ function goAgendar() {
duracao_min: 50,
modalidade: 'presencial',
titulo_custom: '',
observacoes: ''
observacoes: '',
repetir: false,
fim_tipo: 'open',
fim_data: '',
fim_count: 12
};
novaSessaoOpen.value = true;
}
@@ -479,9 +491,6 @@ async function salvarSessao() {
toast.add({ severity: 'warn', summary: 'Hora obrigatória', life: 2500 });
return;
}
// Monta inicio_em + fim_em a partir de data + hora + duracao_min.
// Local time -> ISO. Sem timezone explicit, usa o do browser (consistente
// com o pattern do AgendaEventDialog e MelissaAgenda).
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);
@@ -489,8 +498,61 @@ async function salvarSessao() {
toast.add({ severity: 'warn', summary: 'Data/hora inválida', life: 2500 });
return;
}
const fim = new Date(inicio.getTime() + (Number(f.duracao_min) || 50) * 60 * 1000);
// Caminho RECORRENTE: cria regra em recurrence_rules via useRecurrence.
// Ocorrencias sao geradas dinamicamente — nao precisa popular agenda_eventos
// futuros (sessoes confirmadas/realizadas viram rows reais sob demanda).
if (f.repetir) {
if (f.fim_tipo === 'data' && !f.fim_data) {
toast.add({ severity: 'warn', summary: 'Informe a data de fim', life: 2500 });
return;
}
if (f.fim_tipo === 'count' && (!f.fim_count || Number(f.fim_count) < 1)) {
toast.add({ severity: 'warn', summary: 'Informe o número de ocorrências', 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: 'weekly',
interval: 1,
weekdays: [inicio.getDay()],
start_date: f.data,
end_date: f.fim_tipo === 'data' ? f.fim_data : null,
max_occurrences: f.fim_tipo === 'count' ? Number(f.fim_count) : null,
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);
toast.add({
severity: 'success',
summary: 'Recorrência criada',
detail: 'A série semanal está ativa. Veja em "Recorrências".',
life: 3000
});
novaSessaoOpen.value = false;
// Recarrega sessoes do paciente (caso start_date seja hoje).
await sessionsHook.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(),
@@ -2197,6 +2259,49 @@ onBeforeUnmount(() => {
class="w-full"
/>
</div>
<!-- Bloco de recorrencia (cria recurrence_rules em vez de agenda_eventos) -->
<div class="mpa-recur">
<label class="mpa-recur__toggle">
<Checkbox v-model="novaSessaoForm.repetir" :binary="true" />
<div class="mpa-recur__toggle-text">
<strong>Repetir semanalmente</strong>
<span>Cria uma série recorrente neste mesmo dia da semana e horário</span>
</div>
</label>
<div v-if="novaSessaoForm.repetir" class="mpa-recur__opts">
<label class="mpa-recur__opt">
<RadioButton v-model="novaSessaoForm.fim_tipo" value="open" />
<span>Sem data de fim continua até cancelar</span>
</label>
<label class="mpa-recur__opt">
<RadioButton v-model="novaSessaoForm.fim_tipo" value="count" />
<span>
Após
<InputText
v-model="novaSessaoForm.fim_count"
type="number"
min="1"
class="mpa-recur__inline"
:disabled="novaSessaoForm.fim_tipo !== 'count'"
/>
sessões
</span>
</label>
<label class="mpa-recur__opt">
<RadioButton v-model="novaSessaoForm.fim_tipo" value="data" />
<span>
Até
<InputText
v-model="novaSessaoForm.fim_data"
type="date"
class="mpa-recur__inline mpa-recur__inline--date"
:disabled="novaSessaoForm.fim_tipo !== 'data'"
/>
</span>
</label>
</div>
</div>
</div>
<template #footer>
<button class="mpa-quick-btn" @click="novaSessaoOpen = false">
@@ -2209,7 +2314,7 @@ onBeforeUnmount(() => {
@click="salvarSessao"
>
<i :class="sessionsHook.busy.value ? 'pi pi-spin pi-spinner' : 'pi pi-check'" :style="{ color: '#10b981' }" />
<span>Agendar sessão</span>
<span>{{ novaSessaoForm.repetir ? 'Criar recorrência' : 'Agendar sessão' }}</span>
</button>
</template>
</Dialog>
@@ -3242,6 +3347,71 @@ onBeforeUnmount(() => {
opacity: 0.75;
}
/* Bloco de recorrencia (toggle + opcoes radio) dentro do dialog Nova Sessao */
.mpa-recur {
margin-top: 4px;
padding: 12px;
border-radius: 10px;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
display: flex;
flex-direction: column;
gap: 12px;
}
.mpa-recur__toggle {
display: flex;
align-items: flex-start;
gap: 10px;
cursor: pointer;
}
.mpa-recur__toggle-text {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.mpa-recur__toggle-text strong {
font-size: 0.85rem;
color: var(--m-text);
font-weight: 700;
}
.mpa-recur__toggle-text span {
font-size: 0.74rem;
color: var(--m-text-muted);
line-height: 1.4;
}
.mpa-recur__opts {
display: flex;
flex-direction: column;
gap: 8px;
padding-left: 4px;
padding-top: 4px;
border-top: 1px dashed var(--m-border);
padding-top: 10px;
}
.mpa-recur__opt {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 0.82rem;
color: var(--m-text);
}
.mpa-recur__opt > span {
display: inline-flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.mpa-recur__inline {
width: 70px !important;
text-align: center;
}
.mpa-recur__inline--date {
width: 150px !important;
}
/* ═══════ Tab Conversas (Fase 7) — CTA pra drawer ═══════ */
.mpa-conv-cta {
display: flex;