MelissaPaciente: bloco "Recorrencias do paciente" na Tab Agenda

User aprovou a ideia. Adiciona contexto "este paciente tem sessao toda
segunda 14h" direto no prontuario, evitando duplicacao de regras e
deixando claro o estado da serie.

NOVO src/features/patients/composables/usePatientRecurrences.js (~110L)
- load(patientId): SELECT recurrence_rules WHERE patient_id (DESC start_date)
- cancel(ruleId) / reactivate(ruleId): UPDATE status + auto-reload
- Computeds derivados: ativas, canceladas, totalAtivas, totalCanceladas
- busy flag pra disable de buttons

EXTENSAO src/features/patients/utils/patientFormatters.js
- WEEKDAY_LABEL + WEEKDAY_LABEL_SHORT (arrays 0=Domingo..6=Sabado)
- fmtRecurrenceLabel(rule): "Toda segunda às 14:00", "Quinzenal · Terça
  às 09:00", "Qua, Sex às 16:00" (custom_weekdays), "Mensal às 14:00",
  "Anual" — cobre todos os types do useRecurrence.
- fmtRecurrenceFim(rule): "Sem data de fim" / "Até DD/MM/YYYY" /
  "N sessões no total"

MELISSAPACIENTE.VUE
- Composable + handlers (onCancelRecurrence, onReactivateRecurrence) com
  toast feedback.
- recorrenciasShowCanc ref + recorrenciasVisiveis computed (toggle "ver
  canceladas").
- loadAll inclui recorrenciasHook.load.
- salvarSessao no caminho recorrente recarrega sessions+recorrencias em
  Promise.all (regra recem-criada aparece na lista imediatamente).
- 5o KPI na Tab Agenda: "Recorrencias" com count ativas + cap dinamica
  (cor #a855f7 quando > 0, cinza quando 0).
- Bloco <section class="mpa-panel"> entre KPIs e filter chips listando
  rules ativas (default) ou todas (toggle "Ver canceladas" no header,
  so aparece quando ha canceladas):
  - Icon roxo .mpa-recur-item__icon
  - Top: label + Tag status (verde Ativa / amarelo Cancelada)
  - Meta: duracao + modalidade + fim + "desde DATE"
  - Obs (quando preenchido): block textual
  - Actions: pi-ban (cancelar) ou pi-undo (reativar) com tooltip
- border-left adaptativa (#a855f7 ativo / cinza cancelado) + opacity 0.7
  pros cancelados.
- Mobile: stack icon+main em 2-col 2-row; actions full-width abaixo.

CSS: ~120L novos. Padrao Melissa: status pills, icon roxo distintivo
(diferente das sessoes que usam cinza), border-left por status.

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:37:45 -03:00
parent f1d6fbad73
commit 9e76e4e6ea
3 changed files with 426 additions and 3 deletions
+271 -3
View File
@@ -31,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 { usePatientRecurrences } from '@/features/patients/composables/usePatientRecurrences';
import { useRecurrence } from '@/features/agenda/composables/useRecurrence';
import {
pickField,
@@ -41,6 +42,8 @@ import {
fmtCurrency,
fmtHourShort,
fmtDayShort,
fmtRecurrenceLabel,
fmtRecurrenceFim,
fmtCPF,
fmtRG,
fmtGender,
@@ -72,6 +75,7 @@ const sessionsHook = usePatientSessions();
const financialHook = usePatientFinancial();
const messagesHook = usePatientMessages();
const documentsHook = usePatientDocuments();
const recorrenciasHook = usePatientRecurrences();
const recurrenceHook = useRecurrence();
// ── Breakpoints + drawer ───────────────────────────────────
@@ -217,6 +221,38 @@ const groupNames = computed(() => detail.groups.value.map((g) => g?.name).filter
const groupLabel = computed(() => groupNames.value.length ? groupNames.value.join(', ') : '—');
const groupCountLabel = computed(() => groupNames.value.length <= 1 ? 'Grupo' : 'Grupos');
// ── Tab Agenda: bloco recorrencias do paciente ─────────────
const recorrenciasShowCanc = ref(false);
const recorrenciasVisiveis = computed(() =>
recorrenciasShowCanc.value ? recorrenciasHook.rules.value : recorrenciasHook.ativas.value
);
async function onCancelRecurrence(rule) {
const result = await recorrenciasHook.cancel(rule.id);
if (result.ok) {
toast.add({ severity: 'success', summary: 'Recorrência cancelada', life: 2200 });
} else {
toast.add({
severity: 'error',
summary: 'Falha ao cancelar',
detail: result.error || 'Erro inesperado',
life: 4000
});
}
}
async function onReactivateRecurrence(rule) {
const result = await recorrenciasHook.reactivate(rule.id);
if (result.ok) {
toast.add({ severity: 'success', summary: 'Recorrência reativada', life: 2200 });
} else {
toast.add({
severity: 'error',
summary: 'Falha ao reativar',
detail: result.error || 'Erro inesperado',
life: 4000
});
}
}
// ── Tab Agenda: filtros + agrupamento por mes ──────────────
const agendaSessoesFiltradas = computed(() => {
const list = sessionsHook.sessions.value;
@@ -538,8 +574,12 @@ async function salvarSessao() {
life: 3000
});
novaSessaoOpen.value = false;
// Recarrega sessoes do paciente (caso start_date seja hoje).
await sessionsHook.load(props.patientId);
// Recarrega sessoes (caso start_date seja hoje) + recorrencias
// (a regra recem-criada precisa aparecer no bloco da Tab Agenda).
await Promise.all([
sessionsHook.load(props.patientId),
recorrenciasHook.load(props.patientId)
]);
} catch (e) {
toast.add({
severity: 'error',
@@ -618,7 +658,8 @@ async function loadAll(id) {
sessionsHook.load(id),
financialHook.load(id),
messagesHook.load(id),
documentsHook.load(id)
documentsHook.load(id),
recorrenciasHook.load(id)
]);
}
@@ -1654,8 +1695,111 @@ onBeforeUnmount(() => {
<div class="mpa-kpi__cap">Sem futura</div>
</template>
</article>
<article class="mpa-kpi" :style="recorrenciasHook.totalAtivas.value > 0 ? '--c:#a855f7' : '--c:#94a3b8'">
<span class="mpa-kpi__num">05</span>
<header class="mpa-kpi__head">
<div class="mpa-kpi__icon"><i class="pi pi-sync" /></div>
<span class="mpa-kpi__tag">Recorrências</span>
</header>
<div class="mpa-kpi__big">{{ recorrenciasHook.totalAtivas.value }}</div>
<div class="mpa-kpi__cap">
<template v-if="recorrenciasHook.totalAtivas.value === 0">
nenhuma série ativa
</template>
<template v-else>
{{ recorrenciasHook.totalAtivas.value === 1 ? 'série ativa' : 'séries ativas' }}
<span v-if="recorrenciasHook.totalCanceladas.value" class="mpa-kpi__cap-dim">
· {{ recorrenciasHook.totalCanceladas.value }} cancel.
</span>
</template>
</div>
</article>
</div>
<!-- Bloco: recorrencias do paciente (acima dos meses) -->
<section v-if="recorrenciasHook.rules.value.length" class="mpa-panel">
<header class="mpa-panel__head">
<div class="mpa-panel__title"><i class="pi pi-sync" /> Recorrências</div>
<div class="mpa-fin__head-actions">
<span class="mpa-panel__badge">{{ recorrenciasVisiveis.length }}</span>
<label
v-if="recorrenciasHook.totalCanceladas.value > 0"
class="mpa-recur-toggle"
v-tooltip.left="'Mostrar séries canceladas'"
>
<input v-model="recorrenciasShowCanc" type="checkbox" />
<span>Ver canceladas</span>
</label>
</div>
</header>
<ul class="mpa-recur-list">
<li
v-for="rule in recorrenciasVisiveis"
:key="rule.id"
class="mpa-recur-item"
:data-status="rule.status"
>
<div class="mpa-recur-item__icon">
<i class="pi pi-sync" />
</div>
<div class="mpa-recur-item__main">
<div class="mpa-recur-item__top">
<span class="mpa-recur-item__label">{{ fmtRecurrenceLabel(rule) }}</span>
<Tag
v-if="rule.status === 'cancelado'"
value="Cancelada"
severity="warn"
class="mpa-recur-item__tag"
/>
<Tag
v-else
value="Ativa"
severity="success"
class="mpa-recur-item__tag"
/>
</div>
<div class="mpa-recur-item__meta">
<span><i class="pi pi-clock" />{{ rule.duration_min }}min</span>
<span v-if="rule.modalidade">
<i :class="rule.modalidade === 'online' ? 'pi pi-video' : 'pi pi-map-marker'" />
{{ rule.modalidade === 'online' ? 'Online' : 'Presencial' }}
</span>
<span><i class="pi pi-flag-fill" />{{ fmtRecurrenceFim(rule) }}</span>
<span class="mpa-recur-item__since">
desde {{ fmtDateBR(rule.start_date) }}
</span>
</div>
<p v-if="rule.observacoes" class="mpa-recur-item__obs">
{{ rule.observacoes }}
</p>
</div>
<div class="mpa-recur-item__actions">
<button
v-if="rule.status === 'ativo'"
type="button"
v-tooltip.left="'Cancelar série'"
class="mpa-ag__act mpa-ag__act--danger"
:disabled="recorrenciasHook.busy.value"
@click="onCancelRecurrence(rule)"
>
<i class="pi pi-ban" />
</button>
<button
v-else
type="button"
v-tooltip.left="'Reativar série'"
class="mpa-ag__act mpa-ag__act--ok"
:disabled="recorrenciasHook.busy.value"
@click="onReactivateRecurrence(rule)"
>
<i class="pi pi-undo" />
</button>
</div>
</li>
</ul>
</section>
<!-- Filter chips -->
<div class="mpa-pron-filters" role="tablist">
<button
@@ -3646,6 +3790,130 @@ onBeforeUnmount(() => {
}
}
/* ═══════ Bloco recorrencias do paciente (Tab Agenda topo) ═══════ */
.mpa-recur-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.7rem;
font-weight: 600;
color: var(--m-text-muted);
cursor: pointer;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--m-border);
background: var(--m-bg-medium);
transition: background-color 120ms ease;
}
.mpa-recur-toggle:hover { background: var(--m-bg-soft-hover); }
.mpa-recur-toggle > input { accent-color: var(--p-primary-color); }
.mpa-recur-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.mpa-recur-item {
display: grid;
grid-template-columns: 32px 1fr auto;
gap: 12px;
align-items: center;
padding: 10px 14px;
border-top: 1px solid var(--m-border);
border-left: 3px solid #a855f7;
transition: background-color 120ms ease;
}
.mpa-recur-item:first-child { border-top: none; }
.mpa-recur-item:hover { background: var(--m-bg-medium); }
.mpa-recur-item[data-status="cancelado"] {
border-left-color: var(--m-text-muted);
opacity: 0.7;
}
.mpa-recur-item__icon {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: 8px;
background: color-mix(in srgb, #a855f7 16%, transparent);
color: #a855f7;
flex-shrink: 0;
}
.mpa-recur-item__icon > i { font-size: 0.85rem; }
.mpa-recur-item[data-status="cancelado"] .mpa-recur-item__icon {
background: var(--m-bg-medium);
color: var(--m-text-muted);
}
.mpa-recur-item__main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.mpa-recur-item__top {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.mpa-recur-item__label {
font-size: 0.88rem;
font-weight: 700;
color: var(--m-text);
}
.mpa-recur-item__tag { font-size: 0.66rem !important; }
.mpa-recur-item__meta {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
font-size: 0.74rem;
color: var(--m-text-muted);
}
.mpa-recur-item__meta > span {
display: inline-flex;
align-items: center;
gap: 4px;
}
.mpa-recur-item__meta > span > i { font-size: 0.66rem; opacity: 0.7; }
.mpa-recur-item__since { opacity: 0.65; }
.mpa-recur-item__obs {
font-size: 0.74rem;
color: var(--m-text-muted);
line-height: 1.4;
padding: 6px 8px;
border-left: 2px solid var(--m-border);
background: var(--m-bg-medium);
border-radius: 4px;
margin-top: 2px;
white-space: pre-wrap;
}
.mpa-recur-item__actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
/* Mobile: stack icon + main em coluna; actions vai pra baixo */
@media (max-width: 600px) {
.mpa-recur-item {
grid-template-columns: 32px 1fr;
grid-template-rows: auto auto;
}
.mpa-recur-item__actions {
grid-column: 1 / -1;
justify-content: flex-end;
}
}
/* ═══════ Tab Agenda (Fase 5) ═══════ */
.mpa-ag__group + .mpa-ag__group { margin-top: 10px; }
.mpa-ag__list {