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:
@@ -22,6 +22,7 @@ import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
|||||||
import { useRouter } from 'vue-router';
|
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 { supabase } from '@/lib/supabase/client';
|
||||||
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';
|
||||||
@@ -30,6 +31,7 @@ import { usePatientSessions } from '@/features/patients/composables/usePatientSe
|
|||||||
import { usePatientFinancial } from '@/features/patients/composables/usePatientFinancial';
|
import { usePatientFinancial } from '@/features/patients/composables/usePatientFinancial';
|
||||||
import { usePatientMessages } from '@/features/patients/composables/usePatientMessages';
|
import { usePatientMessages } from '@/features/patients/composables/usePatientMessages';
|
||||||
import { usePatientDocuments } from '@/features/patients/composables/usePatientDocuments';
|
import { usePatientDocuments } from '@/features/patients/composables/usePatientDocuments';
|
||||||
|
import { useRecurrence } from '@/features/agenda/composables/useRecurrence';
|
||||||
import {
|
import {
|
||||||
pickField,
|
pickField,
|
||||||
calcAge,
|
calcAge,
|
||||||
@@ -70,6 +72,7 @@ const sessionsHook = usePatientSessions();
|
|||||||
const financialHook = usePatientFinancial();
|
const financialHook = usePatientFinancial();
|
||||||
const messagesHook = usePatientMessages();
|
const messagesHook = usePatientMessages();
|
||||||
const documentsHook = usePatientDocuments();
|
const documentsHook = usePatientDocuments();
|
||||||
|
const recurrenceHook = useRecurrence();
|
||||||
|
|
||||||
// ── Breakpoints + drawer ───────────────────────────────────
|
// ── Breakpoints + drawer ───────────────────────────────────
|
||||||
const drawerOpen = ref(false);
|
const drawerOpen = ref(false);
|
||||||
@@ -425,7 +428,12 @@ const novaSessaoForm = ref({
|
|||||||
duracao_min: 50,
|
duracao_min: 50,
|
||||||
modalidade: 'presencial',
|
modalidade: 'presencial',
|
||||||
titulo_custom: '',
|
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 = [
|
const SESSAO_TIPOS = [
|
||||||
{ label: 'Sessão', value: 'sessao' },
|
{ label: 'Sessão', value: 'sessao' },
|
||||||
@@ -465,7 +473,11 @@ function goAgendar() {
|
|||||||
duracao_min: 50,
|
duracao_min: 50,
|
||||||
modalidade: 'presencial',
|
modalidade: 'presencial',
|
||||||
titulo_custom: '',
|
titulo_custom: '',
|
||||||
observacoes: ''
|
observacoes: '',
|
||||||
|
repetir: false,
|
||||||
|
fim_tipo: 'open',
|
||||||
|
fim_data: '',
|
||||||
|
fim_count: 12
|
||||||
};
|
};
|
||||||
novaSessaoOpen.value = true;
|
novaSessaoOpen.value = true;
|
||||||
}
|
}
|
||||||
@@ -479,9 +491,6 @@ async function salvarSessao() {
|
|||||||
toast.add({ severity: 'warn', summary: 'Hora obrigatória', life: 2500 });
|
toast.add({ severity: 'warn', summary: 'Hora obrigatória', life: 2500 });
|
||||||
return;
|
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 [y, m, d] = f.data.split('-').map(Number);
|
||||||
const [hh, mm] = f.hora.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);
|
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 });
|
toast.add({ severity: 'warn', summary: 'Data/hora inválida', life: 2500 });
|
||||||
return;
|
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, {
|
const result = await sessionsHook.createSession(props.patientId, {
|
||||||
inicio_em: inicio.toISOString(),
|
inicio_em: inicio.toISOString(),
|
||||||
fim_em: fim.toISOString(),
|
fim_em: fim.toISOString(),
|
||||||
@@ -2197,6 +2259,49 @@ onBeforeUnmount(() => {
|
|||||||
class="w-full"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<button class="mpa-quick-btn" @click="novaSessaoOpen = false">
|
<button class="mpa-quick-btn" @click="novaSessaoOpen = false">
|
||||||
@@ -2209,7 +2314,7 @@ onBeforeUnmount(() => {
|
|||||||
@click="salvarSessao"
|
@click="salvarSessao"
|
||||||
>
|
>
|
||||||
<i :class="sessionsHook.busy.value ? 'pi pi-spin pi-spinner' : 'pi pi-check'" :style="{ color: '#10b981' }" />
|
<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>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -3242,6 +3347,71 @@ onBeforeUnmount(() => {
|
|||||||
opacity: 0.75;
|
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 ═══════ */
|
/* ═══════ Tab Conversas (Fase 7) — CTA pra drawer ═══════ */
|
||||||
.mpa-conv-cta {
|
.mpa-conv-cta {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in New Issue
Block a user