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>
This commit is contained in:
Leonardo
2026-05-08 11:51:43 -03:00
parent 9e76e4e6ea
commit 42a39ed3ea
+239 -105
View File
@@ -44,6 +44,7 @@ import {
fmtDayShort, fmtDayShort,
fmtRecurrenceLabel, fmtRecurrenceLabel,
fmtRecurrenceFim, fmtRecurrenceFim,
WEEKDAY_LABEL as WEEKDAY_LABEL_BLOCK,
fmtCPF, fmtCPF,
fmtRG, fmtRG,
fmtGender, fmtGender,
@@ -456,6 +457,9 @@ function addFinancial() {
} }
// Atalho: navega pra aba Agenda + abre dialog de nova sessao. // 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 novaSessaoOpen = ref(false);
const novaSessaoForm = ref({ const novaSessaoForm = ref({
tipo: 'sessao', tipo: 'sessao',
@@ -465,11 +469,50 @@ const novaSessaoForm = ref({
modalidade: 'presencial', modalidade: 'presencial',
titulo_custom: '', titulo_custom: '',
observacoes: '', observacoes: '',
// Recorrencia (integra com useRecurrence — schema recurrence_rules) freq: 'avulsa',
repetir: false, diasSelecionados: [],
fim_tipo: 'open', // open | data | count qtdMode: '12',
fim_data: '', qtdCustom: 12
fim_count: 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 = [ const SESSAO_TIPOS = [
{ label: 'Sessão', value: 'sessao' }, { label: 'Sessão', value: 'sessao' },
@@ -510,10 +553,10 @@ function goAgendar() {
modalidade: 'presencial', modalidade: 'presencial',
titulo_custom: '', titulo_custom: '',
observacoes: '', observacoes: '',
repetir: false, freq: 'avulsa',
fim_tipo: 'open', diasSelecionados: [],
fim_data: '', qtdMode: '12',
fim_count: 12 qtdCustom: 12
}; };
novaSessaoOpen.value = true; novaSessaoOpen.value = true;
} }
@@ -536,15 +579,30 @@ async function salvarSessao() {
} }
// Caminho RECORRENTE: cria regra em recurrence_rules via useRecurrence. // Caminho RECORRENTE: cria regra em recurrence_rules via useRecurrence.
// Ocorrencias sao geradas dinamicamente — nao precisa popular agenda_eventos // Ocorrencias sao geradas dinamicamente.
// futuros (sessoes confirmadas/realizadas viram rows reais sob demanda). if (f.freq !== 'avulsa') {
if (f.repetir) { // Mapeamento freq -> { type, interval, weekdays }
if (f.fim_tipo === 'data' && !f.fim_data) { let type, interval, weekdays;
toast.add({ severity: 'warn', summary: 'Informe a data de fim', life: 2500 }); if (f.freq === 'semanal') {
return; 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();
} }
if (f.fim_tipo === 'count' && (!f.fim_count || Number(f.fim_count) < 1)) { const max = qtdSessoesEfetiva.value;
toast.add({ severity: 'warn', summary: 'Informe o número de ocorrências', life: 2500 }); if (!max || max < 1) {
toast.add({ severity: 'warn', summary: 'Quantidade de sessões inválida', life: 2500 });
return; return;
} }
try { try {
@@ -553,12 +611,12 @@ async function salvarSessao() {
const rule = { const rule = {
patient_id: props.patientId, patient_id: props.patientId,
owner_id: ownerId, owner_id: ownerId,
type: 'weekly', type,
interval: 1, interval,
weekdays: [inicio.getDay()], weekdays,
start_date: f.data, start_date: f.data,
end_date: f.fim_tipo === 'data' ? f.fim_data : null, end_date: null,
max_occurrences: f.fim_tipo === 'count' ? Number(f.fim_count) : null, max_occurrences: max,
start_time: f.hora, start_time: f.hora,
duration_min: Number(f.duracao_min) || 50, duration_min: Number(f.duracao_min) || 50,
modalidade: f.modalidade, modalidade: f.modalidade,
@@ -570,12 +628,10 @@ async function salvarSessao() {
toast.add({ toast.add({
severity: 'success', severity: 'success',
summary: 'Recorrência criada', summary: 'Recorrência criada',
detail: 'A série semanal está ativa. Veja em "Recorrências".', detail: `${max} sessões previstas. Veja em "Recorrências".`,
life: 3000 life: 3000
}); });
novaSessaoOpen.value = false; novaSessaoOpen.value = false;
// Recarrega sessoes (caso start_date seja hoje) + recorrencias
// (a regra recem-criada precisa aparecer no bloco da Tab Agenda).
await Promise.all([ await Promise.all([
sessionsHook.load(props.patientId), sessionsHook.load(props.patientId),
recorrenciasHook.load(props.patientId) recorrenciasHook.load(props.patientId)
@@ -2404,46 +2460,77 @@ onBeforeUnmount(() => {
/> />
</div> </div>
<!-- Bloco de recorrencia (cria recurrence_rules em vez de agenda_eventos) --> <!-- Bloco de frequencia (espelha AgendaEventDialog: chips + qtd) -->
<div class="mpa-recur"> <div class="mpa-recur">
<label class="mpa-recur__toggle"> <label class="mpa-novo-lanc__label">Frequência</label>
<Checkbox v-model="novaSessaoForm.repetir" :binary="true" /> <div class="mpa-freq-chips">
<div class="mpa-recur__toggle-text"> <button
<strong>Repetir semanalmente</strong> v-for="opt in FREQ_OPCOES"
<span>Cria uma série recorrente neste mesmo dia da semana e horário</span> :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>
</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>
</div> </div>
@@ -2458,7 +2545,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>{{ novaSessaoForm.repetir ? 'Criar recorrência' : 'Agendar sessão' }}</span> <span>{{ novaSessaoCtaLabel }}</span>
</button> </button>
</template> </template>
</Dialog> </Dialog>
@@ -3491,7 +3578,7 @@ onBeforeUnmount(() => {
opacity: 0.75; opacity: 0.75;
} }
/* Bloco de recorrencia (toggle + opcoes radio) dentro do dialog Nova Sessao */ /* Bloco de frequencia dentro do dialog Nova Sessao (espelha AgendaEventDialog) */
.mpa-recur { .mpa-recur {
margin-top: 4px; margin-top: 4px;
padding: 12px; padding: 12px;
@@ -3500,60 +3587,107 @@ onBeforeUnmount(() => {
border: 1px solid var(--m-border); border: 1px solid var(--m-border);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px;
}
.mpa-recur__toggle {
display: flex;
align-items: flex-start;
gap: 10px; gap: 10px;
cursor: pointer;
} }
.mpa-recur__toggle-text {
/* Chips de frequencia + qtd sessoes */
.mpa-freq-chips {
display: flex; display: flex;
flex-direction: column; flex-wrap: wrap;
gap: 2px; gap: 6px;
flex: 1;
min-width: 0;
} }
.mpa-recur__toggle-text strong { .mpa-freq-chip {
font-size: 0.85rem; padding: 5px 12px;
color: var(--m-text); border-radius: 999px;
font-weight: 700; font-size: 0.78rem;
} font-weight: 600;
.mpa-recur__toggle-text span { border: 1px solid var(--m-border);
font-size: 0.74rem; background: transparent;
color: var(--m-text-muted); 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; cursor: pointer;
font-size: 0.82rem; transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
color: var(--m-text); white-space: nowrap;
font-family: inherit;
} }
.mpa-recur__opt > span { .mpa-freq-chip:hover {
border-color: var(--p-primary-color);
color: var(--p-primary-color);
}
.mpa-freq-chip.is-active {
background: var(--p-primary-color);
border-color: var(--p-primary-color);
color: var(--p-primary-contrast-color, #fff);
}
.mpa-freq-chip--sm {
padding: 4px 10px;
font-size: 0.74rem;
}
/* Preview semanal/quinzenal */
.mpa-freq-preview {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 8px;
background: color-mix(in srgb, var(--p-primary-color) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--p-primary-color) 28%, transparent);
font-size: 0.78rem;
font-weight: 500;
color: var(--m-text);
}
.mpa-freq-preview > i {
color: var(--p-primary-color);
font-size: 0.85rem;
}
/* Dias da semana grid (diasEspecificos) */
.mpa-freq-dias {
display: flex;
flex-direction: column;
gap: 6px; gap: 6px;
}
.mpa-dias-grid {
display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 5px;
} }
.mpa-recur__inline { .mpa-dia-chip {
width: 70px !important; min-width: 44px;
padding: 5px 10px;
border-radius: 999px;
font-size: 0.74rem;
font-weight: 700;
border: 1px solid var(--m-border);
background: transparent;
color: var(--m-text-muted);
cursor: pointer;
text-align: center; text-align: center;
transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
font-family: inherit;
} }
.mpa-recur__inline--date { .mpa-dia-chip:hover {
width: 150px !important; border-color: var(--p-primary-color);
color: var(--p-primary-color);
}
.mpa-dia-chip.is-active {
background: var(--p-primary-color);
border-color: var(--p-primary-color);
color: var(--p-primary-contrast-color, #fff);
}
/* Qtd sessoes */
.mpa-freq-qtd {
display: flex;
flex-direction: column;
gap: 6px;
}
.mpa-freq-qtd-custom {
margin-top: 4px;
padding: 8px;
border-radius: 8px;
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
} }
/* ═══════ Tab Conversas (Fase 7) — CTA pra drawer ═══════ */ /* ═══════ Tab Conversas (Fase 7) — CTA pra drawer ═══════ */