MelissaPaciente Fase 5: Tab Agenda completa (KPIs + filtros + grupos por mes + acoes)

EXTENSAO src/features/patients/utils/patientFormatters.js: +2 helpers
- fmtHourShort (HH:MM 24h pt-br) — usado na coluna data dos cards
- fmtDayShort (DOW abreviado pt-br sem ponto) — usado na coluna data

EXTENSAO src/features/patients/composables/usePatientSessions.js
- Novo ref `busy` pra disable de buttons durante mutation
- _lastPatientId guardado internamente pra auto-reload
- Nova funcao `updateStatus(sessionId, novoStatus)` que faz
  supabase.from('agenda_eventos').update({status}) + auto-reload da
  lista de sessoes. Retorna {ok, error?}.

MELISSAPACIENTE.VUE — script
- agendaFilter ref ('all' default) + AGENDA_FILTERS array com 6 opcoes
  (Todas, Proximas, Passadas, Realizadas, Faltas, Canceladas)
- agendaSessoesFiltradas computed: filtra por future/past/status (regex)
- agendaAgrupadas computed: agrupa por "Mes de YYYY" DESC
- updateSessionStatus(ev, status, msg): chama sessionsHook.updateStatus +
  toast de sucesso/erro
- Removido `void toast` (toast usado de verdade agora)

MELISSAPACIENTE.VUE — Tab Agenda reescrita (substitui placeholder Fase 1)
- 4 KPI cards no padrao Visao Geral (numerados 01-04):
  Total / Realizadas (% do total) / Faltas (cor adaptativa) / Proxima
- 6 filter chips redondas (cor primary quando active)
- Empty state contextual (sem sessoes vs filtro vazio)
- Grupos por mes com header (label + badge count)
- Cards 3-col: data column (DOW + dia + hora) | main (status tag + chips
  modalidade/duracao + relative + titulo + note 2-line clamp) | actions
  (3 buttons: ok/warn/danger com tooltip + cor adaptativa no hover)
- Mobile: stack date+main em 2 cols; actions full-width abaixo

CSS: ~150L novos. Padrao visual Melissa: data column estilo calendario,
actions hover muda cor por intent (verde realiz / amarelo falta / vermelho
cancel), border-left por status.

ESLint: 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 09:53:59 -03:00
parent 1278e93b01
commit 8a8d2e05bd
4 changed files with 499 additions and 16 deletions
+45
View File
@@ -50,6 +50,51 @@ Touched: none
## [2026-05-08 00:00] session | Melissa cfg-* nativas + temas + cronometro DB
Touched: none
## [2026-05-08 16:30] session | MelissaPaciente Fase 5 — Tab Agenda completa
Touched: none
Detalhes: Tab Agenda com KPIs, filtros, agrupamento por mes e acoes
rapidas (mark realizada/falta/cancelar). Espelha o legacy.
EXTENSAO patientFormatters.js: +2 helpers
- fmtHourShort (HH:MM 24h pt-br) e fmtDayShort (DOW abbreviado pt-br
sem ponto) — usados na coluna data dos cards.
EXTENSAO usePatientSessions.js: mutation + busy flag
- Novo ref `busy` pra disable de buttons durante mutation.
- _lastPatientId guardado pra auto-reload depois de mutation.
- Nova funcao `updateStatus(sessionId, novoStatus)` que faz
supabase.from('agenda_eventos').update({status}) + auto-reload da
lista. Retorna {ok, error?}.
MELISSAPACIENTE.VUE — script
- agendaFilter ref ('all' default) + AGENDA_FILTERS array com 6 opcoes
(Todas, Proximas, Passadas, Realizadas, Faltas, Canceladas).
- agendaSessoesFiltradas computed: filtra sessoes por future/past/status.
- agendaAgrupadas computed: agrupa por "Mes de YYYY" mantendo ordem DESC.
- updateSessionStatus(ev, status, msg) handler que chama
sessionsHook.updateStatus + toast de sucesso/erro.
- Removido `void toast` (toast usado de verdade agora).
MELISSAPACIENTE.VUE — Tab Agenda reescrita (substitui placeholder Fase 1)
- 4 KPI cards no padrao Visao Geral (numerados 01-04):
- 01 Total + cap "sessoes registradas"
- 02 Realizadas + cap "% do total"
- 03 Faltas + cap "+ N cancel." (cor vermelha quando > 0, cinza quando 0)
- 04 Proxima + relative + datetime
- 6 filter chips redondas (estilo Melissa: cor primary quando active).
- Empty state contextual (sem sessoes vs filtro vazio).
- Grupos por mes com header (label + badge count).
- Cards com 3 colunas: data column (DOW + dia + hora curta) | main
(status tag + chips modalidade/duracao + relative + titulo + note) |
actions (3 buttons: ok/warn/danger com tooltip + cor adaptativa hover).
- Mobile: stack date+main em 2 cols; actions full-width abaixo.
CSS: ~150L novos pros componentes (mpa-ag__group/list/item/date/main/
actions). Padrao visual Melissa: data column estilo calendario, actions
hover muda cor por intent (verde realiz / amarelo falta / vermelho cancel).
ESLint: 0 errors da minha mudanca.
## [2026-05-08 15:30] session | MelissaPaciente Fase 4 — Tab Prontuario MVP
Touched: none
Detalhes: O legacy PatientProntuario.vue tem a aba Prontuario como
@@ -16,8 +16,11 @@ export function usePatientSessions() {
const sessions = ref([]);
const loading = ref(false);
const error = ref('');
const busy = ref(false); // mutations em curso (updateStatus etc)
let _lastPatientId = null;
async function load(patientId) {
_lastPatientId = patientId || null;
if (!patientId) {
sessions.value = [];
return;
@@ -81,11 +84,36 @@ export function usePatientSessions() {
.slice(0, 6)
);
/**
* Atualiza o status de uma sessao (mutation). Recarrega a lista do paciente
* ao final pra refletir o novo estado nos computeds derivados.
* Retorna {ok: true} ou {ok: false, error: msg}.
*/
async function updateStatus(sessionId, novoStatus) {
if (!sessionId || busy.value) return { ok: false, error: 'busy' };
busy.value = true;
try {
const { error: err } = await supabase
.from('agenda_eventos')
.update({ status: novoStatus })
.eq('id', sessionId);
if (err) throw err;
if (_lastPatientId) await load(_lastPatientId);
return { ok: true };
} catch (e) {
return { ok: false, error: e?.message || 'Erro ao atualizar status' };
} finally {
busy.value = false;
}
}
return {
sessions,
loading,
error,
busy,
load,
updateStatus,
proximaSessao,
ultimaSessao,
totalSessoes,
@@ -122,6 +122,26 @@ export function fmtDateBR(v) {
return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
}
/**
* Hora curta HH:MM (24h pt-br).
*/
export function fmtHourShort(iso) {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
/**
* Dia da semana abreviado pt-br (seg/ter/qua...).
*/
export function fmtDayShort(iso) {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleDateString('pt-BR', { weekday: 'short' }).replace('.', '');
}
export function fmtDateTimeBR(iso) {
if (!iso) return '—';
const d = new Date(iso);
+404 -14
View File
@@ -33,6 +33,8 @@ import {
fmtDateBR,
fmtDateTimeBR,
fmtCurrency,
fmtHourShort,
fmtDayShort,
fmtCPF,
fmtRG,
fmtGender,
@@ -101,6 +103,17 @@ const PRON_FILTERS = [
{ value: 'cancel', label: 'Cancelamentos', icon: 'pi pi-ban' }
];
// Filtros da aba Agenda (lista cronologica + agrupamento por mes)
const agendaFilter = ref('all'); // all | future | past | realiz | falt | cancel
const AGENDA_FILTERS = [
{ value: 'all', label: 'Todas', icon: 'pi pi-list' },
{ value: 'future', label: 'Próximas', icon: 'pi pi-calendar-plus' },
{ value: 'past', label: 'Passadas', icon: 'pi pi-history' },
{ value: 'realiz', label: 'Realizadas', icon: 'pi pi-check-circle' },
{ value: 'falt', label: 'Faltas', icon: 'pi pi-user-minus' },
{ value: 'cancel', label: 'Canceladas', icon: 'pi pi-ban' }
];
// Sub-nav da aba Perfil
const PROFILE_SECTIONS = [
{ key: 'pessoais', label: 'Informações Pessoais', icon: 'pi pi-pencil' },
@@ -195,6 +208,56 @@ 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: filtros + agrupamento por mes ──────────────
const agendaSessoesFiltradas = computed(() => {
const list = sessionsHook.sessions.value;
const now = Date.now();
switch (agendaFilter.value) {
case 'future': return list.filter((s) => s.inicio_em && new Date(s.inicio_em).getTime() > now);
case 'past': return list.filter((s) => s.inicio_em && new Date(s.inicio_em).getTime() <= now);
case 'realiz': return list.filter((s) => /realiz|present/i.test(String(s.status || '')));
case 'falt': return list.filter((s) => /falt/i.test(String(s.status || ''))) ;
case 'cancel': return list.filter((s) => /cancel|remarca/i.test(String(s.status || '')));
default: return list;
}
});
// Agrupa por "Mes de YYYY" mantendo ordem DESC (mais recente primeiro)
const agendaAgrupadas = computed(() => {
const groups = [];
let key = null;
for (const ev of agendaSessoesFiltradas.value) {
if (!ev.inicio_em) continue;
const d = new Date(ev.inicio_em);
const k = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
if (k !== key) {
key = k;
groups.push({
key: k,
label: d.toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' })
.replace(/(^|\s)\S/g, (l) => l.toUpperCase()),
items: []
});
}
groups[groups.length - 1].items.push(ev);
}
return groups;
});
// Handler de mutacao de status (Realizada / Falta / Cancelar)
async function updateSessionStatus(ev, novoStatus, msg) {
const result = await sessionsHook.updateStatus(ev.id, novoStatus);
if (result.ok) {
toast.add({ severity: 'success', summary: msg, life: 2200 });
} else {
toast.add({
severity: 'error',
summary: 'Falha ao atualizar',
detail: result.error || 'Erro inesperado',
life: 4000
});
}
}
// ── Tab Prontuario: lista filtrada de sessoes ──────────────
// MVP enquanto anamnese/evolucao_clinica nao existem no schema:
// usa agenda_eventos.observacoes como nota evolutiva.
@@ -267,8 +330,6 @@ onBeforeUnmount(() => {
}
});
// Suprime "unused" do toast (vai ser usado nas Fases 2+)
void toast;
</script>
<template>
@@ -1206,23 +1267,191 @@ void toast;
</section>
</div>
<!-- ABA: Agenda -->
<!-- ABA: Agenda (Fase 5 KPIs + filtros + grupos por mes + acoes) -->
<div v-else-if="activeTab === 'agenda'" class="mpa-tab">
<div class="mpa-w">
<div class="mpa-w__head">
<div class="mpa-w__icon mpa-w__icon--green"><i class="pi pi-calendar" /></div>
<div class="mpa-w__title">
<div class="mpa-w__title-text">Agenda Fase 5</div>
<div class="mpa-w__sub">Sessões agendadas + ações rápidas</div>
<!-- Loading -->
<div v-if="sessionsHook.loading.value" class="mpa-empty">
<i class="pi pi-spin pi-spinner mr-2" /> Carregando
</div>
<template v-else>
<!-- KPIs (4) -->
<div class="mpa-kpis">
<article class="mpa-kpi" style="--c:var(--p-primary-color)">
<span class="mpa-kpi__num">01</span>
<header class="mpa-kpi__head">
<div class="mpa-kpi__icon"><i class="pi pi-calendar" /></div>
<span class="mpa-kpi__tag">Total</span>
</header>
<div class="mpa-kpi__big">{{ sessionsHook.totalSessoes.value }}</div>
<div class="mpa-kpi__cap">sessões registradas</div>
</article>
<article class="mpa-kpi" style="--c:#4ade80">
<span class="mpa-kpi__num">02</span>
<header class="mpa-kpi__head">
<div class="mpa-kpi__icon"><i class="pi pi-check-circle" /></div>
<span class="mpa-kpi__tag">Realizadas</span>
</header>
<div class="mpa-kpi__big">{{ sessionsHook.totalRealizadas.value }}</div>
<div class="mpa-kpi__cap">
<template v-if="sessionsHook.totalSessoes.value">
{{ Math.round((sessionsHook.totalRealizadas.value / sessionsHook.totalSessoes.value) * 100) }}% do total
</template>
<template v-else></template>
</div>
</article>
<article
class="mpa-kpi"
:style="sessionsHook.totalFaltas.value > 0 ? '--c:#f87171' : '--c:#94a3b8'"
>
<span class="mpa-kpi__num">03</span>
<header class="mpa-kpi__head">
<div class="mpa-kpi__icon"><i class="pi pi-user-minus" /></div>
<span class="mpa-kpi__tag">Faltas</span>
</header>
<div class="mpa-kpi__big">{{ sessionsHook.totalFaltas.value }}</div>
<div class="mpa-kpi__cap">
<template v-if="sessionsHook.totalCanceladas.value">
+ {{ sessionsHook.totalCanceladas.value }} cancel.
</template>
<template v-else>nenhuma falta</template>
</div>
</article>
<article class="mpa-kpi" style="--c:#60a5fa">
<span class="mpa-kpi__num">04</span>
<header class="mpa-kpi__head">
<div class="mpa-kpi__icon"><i class="pi pi-calendar-clock" /></div>
<span class="mpa-kpi__tag">Próxima</span>
</header>
<template v-if="sessionsHook.proximaSessao.value">
<div class="mpa-kpi__big mpa-kpi__big--small">
{{ fmtRelative(sessionsHook.proximaSessao.value.inicio_em) }}
</div>
<div class="mpa-kpi__cap">
{{ fmtDateBR(sessionsHook.proximaSessao.value.inicio_em) }}
· {{ fmtHourShort(sessionsHook.proximaSessao.value.inicio_em) }}
</div>
</template>
<template v-else>
<div class="mpa-kpi__big mpa-kpi__big--small"></div>
<div class="mpa-kpi__cap">Sem futura</div>
</template>
</article>
</div>
<!-- Filter chips -->
<div class="mpa-pron-filters" role="tablist">
<button
v-for="f in AGENDA_FILTERS"
:key="f.value"
type="button"
role="tab"
:aria-selected="agendaFilter === f.value"
class="mpa-pron-filter"
:class="{ 'is-active': agendaFilter === f.value }"
@click="agendaFilter = f.value"
>
<i :class="f.icon" />
<span>{{ f.label }}</span>
</button>
</div>
<!-- Empty state contextual -->
<div v-if="!agendaAgrupadas.length" class="mpa-empty mpa-empty--rich">
<div class="mpa-empty__icon"><i class="pi pi-calendar-times" /></div>
<div class="mpa-empty__title">
{{ sessionsHook.sessions.value.length ? 'Nenhuma sessão neste filtro' : 'Sem sessões registradas' }}
</div>
<div class="mpa-empty__sub">
<template v-if="sessionsHook.sessions.value.length">
Tente outro filtro acima ou veja "Todas" pra listar o histórico completo.
</template>
<template v-else>
As sessões agendadas e atendidas com este paciente aparecerão aqui.
</template>
</div>
</div>
<div class="mpa-w__body">
<p class="mpa-placeholder">
Em desenvolvimento <strong>Fase 5</strong>. disponível: <strong>{{ kpiSessoes }}</strong>
sessões totais ({{ kpiRealizadas }} realizadas) via composable.
</p>
<!-- Grupos por mes -->
<section
v-for="g in agendaAgrupadas"
:key="g.key"
class="mpa-panel mpa-ag__group"
>
<header class="mpa-panel__head">
<div class="mpa-panel__title"><i class="pi pi-calendar" /> {{ g.label }}</div>
<span class="mpa-panel__badge">{{ g.items.length }}</span>
</header>
<ul class="mpa-ag__list">
<li
v-for="s in g.items"
:key="s.id"
class="mpa-ag__item"
:data-status="String(s.status || 'agendado').toLowerCase()"
>
<!-- Coluna data -->
<div class="mpa-ag__date">
<span class="mpa-ag__date-dow">{{ fmtDayShort(s.inicio_em) }}</span>
<span class="mpa-ag__date-day">{{ new Date(s.inicio_em).getDate() }}</span>
<span class="mpa-ag__date-time">{{ fmtHourShort(s.inicio_em) }}</span>
</div>
<!-- Coluna main -->
<div class="mpa-ag__main">
<div class="mpa-ag__top">
<Tag
:value="STATUS_LABEL[s.status] || s.status || 'Agendado'"
:severity="STATUS_SEVERITY[s.status] || 'info'"
class="mpa-ag__tag"
/>
<span v-if="s.modalidade" class="mpa-tl__chip">
<i :class="s.modalidade === 'online' ? 'pi pi-video' : 'pi pi-map-marker'" />
{{ s.modalidade === 'online' ? 'Online' : 'Presencial' }}
</span>
<span v-if="sessionDuration(s.inicio_em, s.fim_em)" class="mpa-tl__chip mpa-tl__chip--dim">
<i class="pi pi-clock" />
{{ sessionDuration(s.inicio_em, s.fim_em) }}
</span>
<span class="mpa-ag__rel">{{ fmtRelative(s.inicio_em) }}</span>
</div>
<div v-if="s.titulo_custom || s.titulo" class="mpa-ag__title">
{{ s.titulo_custom || s.titulo }}
</div>
<p v-if="s.observacoes" class="mpa-ag__note">{{ s.observacoes }}</p>
</div>
<!-- Coluna actions -->
<div class="mpa-ag__actions">
<button
type="button"
v-tooltip.left="'Marcar como realizada'"
class="mpa-ag__act mpa-ag__act--ok"
:disabled="sessionsHook.busy.value"
@click="updateSessionStatus(s, 'realizado', 'Sessão marcada como realizada')"
>
<i class="pi pi-check-circle" />
</button>
<button
type="button"
v-tooltip.left="'Marcar como falta'"
class="mpa-ag__act mpa-ag__act--warn"
:disabled="sessionsHook.busy.value"
@click="updateSessionStatus(s, 'faltou', 'Marcada como falta')"
>
<i class="pi pi-user-minus" />
</button>
<button
type="button"
v-tooltip.left="'Cancelar'"
class="mpa-ag__act mpa-ag__act--danger"
:disabled="sessionsHook.busy.value"
@click="updateSessionStatus(s, 'cancelado', 'Sessão cancelada')"
>
<i class="pi pi-ban" />
</button>
</div>
</li>
</ul>
</section>
</template>
</div>
<!-- ABA: Financeiro -->
@@ -2269,6 +2498,167 @@ void toast;
font-size: 0.66rem !important;
}
/* ═══════ Tab Agenda (Fase 5) ═══════ */
.mpa-ag__group + .mpa-ag__group { margin-top: 10px; }
.mpa-ag__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.mpa-ag__item {
display: grid;
grid-template-columns: 64px 1fr auto;
gap: 12px;
align-items: center;
padding: 10px 14px;
border-top: 1px solid var(--m-border);
border-left: 3px solid transparent;
transition: background-color 120ms ease;
}
.mpa-ag__item:first-child { border-top: none; }
.mpa-ag__item:hover { background: var(--m-bg-medium); }
.mpa-ag__item[data-status*="realiz"],
.mpa-ag__item[data-status*="present"] { border-left-color: rgb(34, 197, 94); }
.mpa-ag__item[data-status*="falt"] { border-left-color: rgb(239, 68, 68); }
.mpa-ag__item[data-status*="cancel"],
.mpa-ag__item[data-status*="remarc"] { border-left-color: rgb(245, 158, 11); }
.mpa-ag__item[data-status*="agendado"] { border-left-color: rgb(96, 165, 250); }
/* Coluna data (esquerda) */
.mpa-ag__date {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 6px 8px;
border-radius: 8px;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
min-width: 56px;
}
.mpa-ag__date-dow {
font-size: 0.62rem;
color: var(--m-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
line-height: 1;
}
.mpa-ag__date-day {
font-size: 1.1rem;
font-weight: 800;
color: var(--m-text);
line-height: 1.05;
margin: 2px 0;
letter-spacing: -0.02em;
}
.mpa-ag__date-time {
font-size: 0.66rem;
font-family: 'JetBrains Mono', ui-monospace, monospace;
color: var(--m-text-muted);
font-weight: 600;
line-height: 1;
}
/* Coluna main (centro) */
.mpa-ag__main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.mpa-ag__top {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.mpa-ag__tag { font-size: 0.66rem !important; }
.mpa-ag__rel {
margin-left: auto;
font-size: 0.7rem;
color: var(--m-text-muted);
}
.mpa-ag__title {
font-size: 0.85rem;
font-weight: 600;
color: var(--m-text);
}
.mpa-ag__note {
font-size: 0.74rem;
color: var(--m-text-muted);
line-height: 1.4;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
/* Coluna actions (direita) */
.mpa-ag__actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.mpa-ag__act {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border-radius: 7px;
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
color: var(--m-text-muted);
cursor: pointer;
font-family: inherit;
transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease;
}
.mpa-ag__act > i { font-size: 0.85rem; }
.mpa-ag__act:hover:not(:disabled) {
background: var(--m-bg-soft-hover);
color: var(--m-text);
border-color: var(--m-border-strong);
}
.mpa-ag__act:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.mpa-ag__act--ok:hover:not(:disabled) {
background: rgba(34, 197, 94, 0.12);
color: rgb(34, 197, 94);
border-color: rgba(34, 197, 94, 0.40);
}
.mpa-ag__act--warn:hover:not(:disabled) {
background: rgba(245, 158, 11, 0.12);
color: rgb(245, 158, 11);
border-color: rgba(245, 158, 11, 0.40);
}
.mpa-ag__act--danger:hover:not(:disabled) {
background: rgba(239, 68, 68, 0.12);
color: rgb(239, 68, 68);
border-color: rgba(239, 68, 68, 0.40);
}
/* Mobile: stack date + main em coluna; actions vai pra baixo */
@media (max-width: 600px) {
.mpa-ag__item {
grid-template-columns: 56px 1fr;
grid-template-rows: auto auto;
}
.mpa-ag__actions {
grid-column: 1 / -1;
justify-content: flex-end;
}
.mpa-ag__rel {
flex-basis: 100%;
margin-left: 0;
text-align: left;
}
}
/* ═══════ Tab Prontuario (Fase 4 MVP) ═══════ */
.mpa-pron-hint {
display: flex;