agenda: dialog UX (bloqueio paciente arquivado/inativo + resumo sticky + card extras + observacao via commitment)

BLOQUEIO REAL Inativo + AVISO Arquivado futura:
- useAgendaEventComposer.canSave agora bloqueia edicao de sessao futura
  quando !perms.canReschedule. Antes canReschedule era dead code
  (definido em getPatientAgendaPermissions mas nunca consumido). Pra
  Inativo: canReschedule=false => Save desabilitado de verdade (antes
  o aviso "Remarcacao bloqueada" mentia, save acontecia mesmo).
- Aviso novo (severity=info) em AgendaEventDialog + V2 pra Arquivado +
  futura + edit: "Sessao futura editavel; novos agendamentos e
  recorrencias bloqueados". Cobria um gap onde nao havia aviso nenhum
  pra esse cenario.

RESUMO FLUTUANTE acompanha o Dialog:
- ResizeObserver no .p-dialog.agenda-event-composer mede top + altura
  e sincroniza com :style do aside via ref resumoStyle. Antes o aside
  tinha top:5vh fixo — dialog baixo (Bloqueio/Atividade) centrava
  vertical e o resumo ficava preso la em cima desalinhado.

CARD "Campos Extras (compromisso)":
- Bloco de selectedCommitmentFields extraido da fields-grid pra um
  .field-card proprio com header pi-list + titulo + .aed-extras-body
  (padding 0.85rem). Hierarquia visual clara: campos do compromisso
  ficam isolados dos campos do form principal.
- Bind especial pra f.key==='notes': v-model="form.observacoes" em vez
  de form.extra_fields.notes. Mantem compat com a coluna nativa
  agenda_eventos.observacoes (consumida por relatorios/prontuario).
- Textareas hardcoded de Observacao removidas do form (fields-grid +
  side-card direito) — agora vem como campo extra do commitment Sessao,
  via migration 20260511000001 (commit anterior).

OUTROS:
- Card "Pagamento" renomeado pra "Sessao / Honorarios" (cobre os 3
  tipos: gratuito, particular, convenio — terminologia mais alinhada
  ao vocabulario clinico).
- composer-grid e composer-right ganharam gap:0 — os cards filhos
  ja tem mb-4 proprio (Tailwind ~1rem), gap do flex duplicava.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-11 16:57:16 -03:00
parent dba595fd2d
commit 41c44272a3
3 changed files with 103 additions and 36 deletions
@@ -15,7 +15,7 @@
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
--> -->
<script setup> <script setup>
import { computed, ref, watch, nextTick } from 'vue'; import { computed, ref, watch, nextTick, onBeforeUnmount } from 'vue';
import { generateGoogleCalendarLink, formatGCalDate, addMinutesToHHMM } from '@/utils/googleCalendarLink'; import { generateGoogleCalendarLink, formatGCalDate, addMinutesToHHMM } from '@/utils/googleCalendarLink';
import { useRouter, useRoute } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import Select from 'primevue/select'; import Select from 'primevue/select';
@@ -888,6 +888,47 @@ const anyChildDialogOpen = computed(() =>
serviceQuickDlgOpen.value || serviceQuickDlgOpen.value ||
insuranceQuickDlgOpen.value insuranceQuickDlgOpen.value
); );
// ── Sincronização do Resumo flutuante com a posição do Dialog ──
// O Dialog do PrimeVue centra vertical quando o conteudo cabe no viewport
// (commitment "Bloqueio" / "Atividade" sao baixos), e ancora ~5vh do topo
// quando o conteudo é alto (Sessao com paciente+frequencia). O Resumo
// flutuante estava com top:5vh fixo, então em dialog baixo ficava lá em
// cima desalinhado. ResizeObserver mede o .p-dialog e atualiza o style do
// aside pra acompanhar top + altura — funciona em qualquer cenario.
const resumoStyle = ref({ top: '5vh', maxHeight: '90vh' });
let _dialogObserver = null;
function _syncResumoToDialog() {
const dialogEl = document.querySelector('.p-dialog.agenda-event-composer');
if (!dialogEl) return;
const rect = dialogEl.getBoundingClientRect();
resumoStyle.value = {
top: `${Math.round(rect.top)}px`,
maxHeight: `${Math.round(rect.height)}px`
};
}
watch([visible, () => step.value], async ([open, stp]) => {
if (_dialogObserver) {
_dialogObserver.disconnect();
_dialogObserver = null;
}
if (!open || stp !== 2) return;
await nextTick();
const dialogEl = document.querySelector('.p-dialog.agenda-event-composer');
if (!dialogEl || typeof ResizeObserver === 'undefined') return;
_syncResumoToDialog();
_dialogObserver = new ResizeObserver(() => _syncResumoToDialog());
_dialogObserver.observe(dialogEl);
}, { immediate: true });
onBeforeUnmount(() => {
if (_dialogObserver) {
_dialogObserver.disconnect();
_dialogObserver = null;
}
});
</script> </script>
<template> <template>
@@ -1002,6 +1043,10 @@ const anyChildDialogOpen = computed(() =>
<i class="pi pi-ban mr-1" /> <i class="pi pi-ban mr-1" />
<b>Paciente inativo.</b> Remarcação de sessões está bloqueada. <b>Paciente inativo.</b> Remarcação de sessões está bloqueada.
</Message> </Message>
<Message v-if="isEdit && form.paciente_status === 'Arquivado' && isSessionFuture" severity="info" class="mb-3" :closable="false">
<i class="pi pi-info-circle mr-1" />
<b>Paciente arquivado.</b> Sessão futura pode ser editada, mas novos agendamentos e recorrências estão bloqueados.
</Message>
<Message v-if="!isEdit && isSessionEvent && form.paciente_id && !agendaPerms.canCreateSession" severity="error" class="mb-3" :closable="false"> <Message v-if="!isEdit && isSessionEvent && form.paciente_id && !agendaPerms.canCreateSession" severity="error" class="mb-3" :closable="false">
<i class="pi pi-ban mr-1" /> <i class="pi pi-ban mr-1" />
<b>{{ form.paciente_status === 'Arquivado' ? 'Paciente arquivado.' : 'Paciente inativo.' }}</b> <b>{{ form.paciente_status === 'Arquivado' ? 'Paciente arquivado.' : 'Paciente inativo.' }}</b>
@@ -1126,6 +1171,32 @@ const anyChildDialogOpen = computed(() =>
</div> </div>
</div> </div>
<!-- CAMPOS EXTRAS (herdados do compromisso determinado)
'notes' é caso especial: bindamos em form.observacoes pra
manter compat com a coluna nativa agenda_eventos.observacoes
(consumida por relatórios/prontuário). Outros campos vão pra
agenda_eventos.extra_fields (JSONB) via form.extra_fields. -->
<div v-if="selectedCommitmentFields.length" class="field-card mb-4">
<div class="field-card__header">
<i class="pi pi-list" />
<span>Campos Extras (compromisso)</span>
</div>
<div class="field-card__body aed-extras-body">
<div class="fields-grid">
<div v-for="f in selectedCommitmentFields" :key="f.key" :class="{ 'col-span-full': f.field_type === 'textarea' }">
<FloatLabel variant="on">
<template v-if="f.field_type === 'textarea' && f.key === 'notes'">
<Textarea :id="`aed-extra-${f.key}`" v-model="form.observacoes" class="w-full" variant="filled" rows="3" autoResize :disabled="isArchivedPastEdit" />
</template>
<Textarea v-else-if="f.field_type === 'textarea'" :id="`aed-extra-${f.key}`" v-model="form.extra_fields[f.key]" class="w-full" variant="filled" rows="2" autoResize />
<InputText v-else :id="`aed-extra-${f.key}`" v-model="form.extra_fields[f.key]" class="w-full" variant="filled" />
<label :for="`aed-extra-${f.key}`">{{ f.label }}{{ f.required ? ' *' : '' }}</label>
</FloatLabel>
</div>
</div>
</div>
</div>
<!-- DEMAIS CAMPOS --> <!-- DEMAIS CAMPOS -->
<div class="fields-grid"> <div class="fields-grid">
<!-- Título (apenas para não-sessão) --> <!-- Título (apenas para não-sessão) -->
@@ -1150,24 +1221,9 @@ const anyChildDialogOpen = computed(() =>
</FloatLabel> </FloatLabel>
</div> </div>
<!-- Campos extras do compromisso --> <!-- Observação nativa removida em 2026-05-11. Agora vem como
<template v-if="selectedCommitmentFields.length"> campo extra do compromisso determinado (key='notes').
<div v-for="f in selectedCommitmentFields" :key="f.key"> Migration: 20260511000001_session_default_notes_field.sql. -->
<FloatLabel variant="on">
<Textarea v-if="f.field_type === 'textarea'" :id="`aed-extra-${f.key}`" v-model="form.extra_fields[f.key]" class="w-full" variant="filled" rows="2" autoResize />
<InputText v-else :id="`aed-extra-${f.key}`" v-model="form.extra_fields[f.key]" class="w-full" variant="filled" />
<label :for="`aed-extra-${f.key}`">{{ f.label }}{{ f.required ? ' *' : '' }}</label>
</FloatLabel>
</div>
</template>
<!-- Observação (somente quando não é sessão para sessões fica no card direito) -->
<div v-if="!isSessionEvent" class="col-span-full">
<FloatLabel variant="on">
<Textarea id="aed-observacoes" v-model="form.observacoes" class="w-full" variant="filled" rows="3" autoResize />
<label for="aed-observacoes">Observação</label>
</FloatLabel>
</div>
</div> </div>
<!-- RECORRÊNCIAS APLICADAS --> <!-- RECORRÊNCIAS APLICADAS -->
@@ -1230,7 +1286,7 @@ const anyChildDialogOpen = computed(() =>
<div v-if="isSessionEvent" class="field-card mb-4"> <div v-if="isSessionEvent" class="field-card mb-4">
<div class="field-card__header"> <div class="field-card__header">
<i class="pi pi-wallet" /> <i class="pi pi-wallet" />
<span>Pagamento</span> <span>Sessão / Honorários</span>
<div class="ml-auto"> <div class="ml-auto">
<SelectButton <SelectButton
v-model="billingType" v-model="billingType"
@@ -1418,13 +1474,9 @@ const anyChildDialogOpen = computed(() =>
</div> </div>
</div> </div>
<!-- Observação --> <!-- Observação nativa removida em 2026-05-11. Agora vem
<div class="mb-3"> como campo extra do compromisso determinado (key='notes').
<FloatLabel variant="on"> Migration: 20260511000001_session_default_notes_field.sql. -->
<Textarea id="aed-observacoes-side" v-model="form.observacoes" class="w-full" variant="filled" rows="3" autoResize :disabled="isArchivedPastEdit" />
<label for="aed-observacoes-side">Observação</label>
</FloatLabel>
</div>
<!-- COBRANÇA DA SESSÃO --> <!-- COBRANÇA DA SESSÃO -->
<AgendaEventoFinanceiroPanel v-if="isSessionEvent && isEdit && eventRow?.id" :evento="eventRow" class="mb-3" /> <AgendaEventoFinanceiroPanel v-if="isSessionEvent && isEdit && eventRow?.id" :evento="eventRow" class="mb-3" />
@@ -1935,7 +1987,7 @@ const anyChildDialogOpen = computed(() =>
<960px (la o card inline aparece). --> <960px (la o card inline aparece). -->
<Teleport to="body"> <Teleport to="body">
<Transition name="resumo-fade"> <Transition name="resumo-fade">
<aside v-if="visible && step === 2 && !anyChildDialogOpen" class="side-card agenda-resumo agenda-resumo--floating" style="z-index: 100000"> <aside v-if="visible && step === 2 && !anyChildDialogOpen" class="side-card agenda-resumo agenda-resumo--floating" :style="{ ...resumoStyle, zIndex: 100000 }">
<div class="side-card__title">Resumo</div> <div class="side-card__title">Resumo</div>
<div class="flex items-center gap-2 mb-2"> <div class="flex items-center gap-2 mb-2">
@@ -2082,6 +2134,13 @@ const anyChildDialogOpen = computed(() =>
padding: 0.65rem 0.75rem !important; padding: 0.65rem 0.75rem !important;
} }
/* Padding interno do card "Campos Extras (compromisso)" — mesmo
tratamento do aed-pay-body. Sem isso os inputs ficam grudados nas
bordas. */
.aed-extras-body {
padding: 0.85rem 0.85rem 0.65rem !important;
}
/* InputGroup do Particular/Convenio — botoes "+" e "?" grudam /* InputGroup do Particular/Convenio — botoes "+" e "?" grudam
no select sem o gap separado de antes. Os addons herdam altura no select sem o gap separado de antes. Os addons herdam altura
automaticamente do select via PrimeVue. */ automaticamente do select via PrimeVue. */
@@ -2299,7 +2358,7 @@ const anyChildDialogOpen = computed(() =>
.composer-grid { .composer-grid {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 0;
margin-top: 2px; margin-top: 2px;
} }
@@ -2512,7 +2571,7 @@ const anyChildDialogOpen = computed(() =>
/* layout single-col agora — sticky removido (nao faz sentido) */ /* layout single-col agora — sticky removido (nao faz sentido) */
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 0;
} }
/* ── Resumo: mobile inline / desktop flutuante via Teleport ──── /* ── Resumo: mobile inline / desktop flutuante via Teleport ────
@@ -2533,13 +2592,12 @@ const anyChildDialogOpen = computed(() =>
.agenda-resumo--floating { .agenda-resumo--floating {
display: block; display: block;
position: fixed; position: fixed;
/* Ancora no topo (igual ao Dialog do PrimeVue, que com conteudo alto fica /* top + max-height sao injetados via :style reativo (resumoStyle no
~5vh do topo). Sem `top: 50%; translateY(-50%)` pra evitar desalinhar script), sincronizados com o .p-dialog via ResizeObserver. Sem isso,
quando o dialog fica alto e ancorado no topo do viewport. */ dialog baixo (Bloqueio/Atividade) centra vertical e o resumo ficava
top: 5vh; preso em 5vh, desalinhado. */
left: calc(50% + 314px); left: calc(50% + 314px);
width: 280px; width: 280px;
max-height: 90vh;
overflow-y: auto; overflow-y: auto;
z-index: 100000; /* PrimeVue incrementa z-index dinamicamente; valor alto pra garantir que fique acima da mask em qualquer ordem de overlay */ z-index: 100000; /* PrimeVue incrementa z-index dinamicamente; valor alto pra garantir que fique acima da mask em qualquer ordem de overlay */
background: color-mix(in srgb, var(--surface-card) 88%, transparent); background: color-mix(in srgb, var(--surface-card) 88%, transparent);
@@ -525,7 +525,7 @@ const heroDateText = computed(() => {
<!-- STEP 2 3 zonas --> <!-- STEP 2 3 zonas -->
<div v-else class="aev2-zones"> <div v-else class="aev2-zones">
<!-- Avisos topo (mantido do V1) --> <!-- Avisos topo (mantido do V1) -->
<div v-if="form.conflito || isDayBlocked || jornadaDialog || isDiaFolga || isArchivedPastEdit || (isEdit && form.paciente_status === 'Inativo' && isSessionFuture) || solicitacaoPendente" class="aev2-alerts"> <div v-if="form.conflito || isDayBlocked || jornadaDialog || isDiaFolga || isArchivedPastEdit || (isEdit && form.paciente_status === 'Inativo' && isSessionFuture) || (isEdit && form.paciente_status === 'Arquivado' && isSessionFuture) || solicitacaoPendente" class="aev2-alerts">
<Message v-if="form.conflito" severity="warn" :closable="false"> <Message v-if="form.conflito" severity="warn" :closable="false">
<span class="font-semibold">Conflito:</span> {{ form.conflito }} <span class="font-semibold">Conflito:</span> {{ form.conflito }}
</Message> </Message>
@@ -535,6 +535,9 @@ const heroDateText = computed(() => {
<Message v-if="isEdit && form.paciente_status === 'Inativo' && isSessionFuture" severity="warn" :closable="false"> <Message v-if="isEdit && form.paciente_status === 'Inativo' && isSessionFuture" severity="warn" :closable="false">
<i class="pi pi-ban mr-1" /> <b>Paciente inativo.</b> Remarcação bloqueada. <i class="pi pi-ban mr-1" /> <b>Paciente inativo.</b> Remarcação bloqueada.
</Message> </Message>
<Message v-if="isEdit && form.paciente_status === 'Arquivado' && isSessionFuture" severity="info" :closable="false">
<i class="pi pi-info-circle mr-1" /> <b>Paciente arquivado.</b> Sessão futura editável; novos agendamentos e recorrências bloqueados.
</Message>
<Message v-if="solicitacaoPendente && isSessionEvent && !isEdit" severity="info" :closable="false"> <Message v-if="solicitacaoPendente && isSessionEvent && !isEdit" severity="info" :closable="false">
<div class="flex items-center justify-between gap-3 w-full flex-wrap"> <div class="flex items-center justify-between gap-3 w-full flex-wrap">
<span><i class="pi pi-inbox mr-1" /> Solicitação pendente de <b>{{ solicitacaoPendente.paciente_nome }} {{ solicitacaoPendente.paciente_sobrenome }}</b></span> <span><i class="pi pi-inbox mr-1" /> Solicitação pendente de <b>{{ solicitacaoPendente.paciente_nome }} {{ solicitacaoPendente.paciente_sobrenome }}</b></span>
@@ -365,6 +365,12 @@ export function useAgendaEventComposer(props, emit, extras = {}) {
if (!isEdit.value && !perms.canCreateSession) return false; if (!isEdit.value && !perms.canCreateSession) return false;
if (!isEdit.value && recorrenciaType.value !== 'avulsa' && !perms.canCreateRecurrence) return false; if (!isEdit.value && recorrenciaType.value !== 'avulsa' && !perms.canCreateRecurrence) return false;
if (isArchivedPastEdit.value) return false; if (isArchivedPastEdit.value) return false;
// Edição de sessão FUTURA: bloqueia se status não permite remarcação.
// Pra Inativo => canReschedule=false => bloqueia (antes só tinha aviso
// visual "Remarcação bloqueada" que mentia: o save acontecia mesmo
// assim, pq canReschedule era dead code). Pra Arquivado canReschedule
// continua true (futura editável, vide aviso info no template).
if (isEdit.value && isSessionFuture.value && !perms.canReschedule) return false;
} }
return true; return true;
}); });