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:
@@ -15,7 +15,7 @@
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<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 { useRouter, useRoute } from 'vue-router';
|
||||
import Select from 'primevue/select';
|
||||
@@ -888,6 +888,47 @@ const anyChildDialogOpen = computed(() =>
|
||||
serviceQuickDlgOpen.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>
|
||||
|
||||
<template>
|
||||
@@ -1002,6 +1043,10 @@ const anyChildDialogOpen = computed(() =>
|
||||
<i class="pi pi-ban mr-1" />
|
||||
<b>Paciente inativo.</b> Remarcação de sessões está bloqueada.
|
||||
</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">
|
||||
<i class="pi pi-ban mr-1" />
|
||||
<b>{{ form.paciente_status === 'Arquivado' ? 'Paciente arquivado.' : 'Paciente inativo.' }}</b>
|
||||
@@ -1126,6 +1171,32 @@ const anyChildDialogOpen = computed(() =>
|
||||
</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 ──────────────────────────────── -->
|
||||
<div class="fields-grid">
|
||||
<!-- Título (apenas para não-sessão) -->
|
||||
@@ -1150,24 +1221,9 @@ const anyChildDialogOpen = computed(() =>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<!-- Campos extras do compromisso -->
|
||||
<template v-if="selectedCommitmentFields.length">
|
||||
<div v-for="f in selectedCommitmentFields" :key="f.key">
|
||||
<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>
|
||||
<!-- Observação nativa removida em 2026-05-11. Agora vem como
|
||||
campo extra do compromisso determinado (key='notes').
|
||||
Migration: 20260511000001_session_default_notes_field.sql. -->
|
||||
</div>
|
||||
|
||||
<!-- ── RECORRÊNCIAS APLICADAS ──────────────────────────── -->
|
||||
@@ -1230,7 +1286,7 @@ const anyChildDialogOpen = computed(() =>
|
||||
<div v-if="isSessionEvent" class="field-card mb-4">
|
||||
<div class="field-card__header">
|
||||
<i class="pi pi-wallet" />
|
||||
<span>Pagamento</span>
|
||||
<span>Sessão / Honorários</span>
|
||||
<div class="ml-auto">
|
||||
<SelectButton
|
||||
v-model="billingType"
|
||||
@@ -1418,13 +1474,9 @@ const anyChildDialogOpen = computed(() =>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Observação -->
|
||||
<div class="mb-3">
|
||||
<FloatLabel variant="on">
|
||||
<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>
|
||||
<!-- Observação nativa removida em 2026-05-11. Agora vem
|
||||
como campo extra do compromisso determinado (key='notes').
|
||||
Migration: 20260511000001_session_default_notes_field.sql. -->
|
||||
|
||||
<!-- ── COBRANÇA DA SESSÃO ──────────────────────────── -->
|
||||
<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). -->
|
||||
<Teleport to="body">
|
||||
<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="flex items-center gap-2 mb-2">
|
||||
@@ -2082,6 +2134,13 @@ const anyChildDialogOpen = computed(() =>
|
||||
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
|
||||
no select sem o gap separado de antes. Os addons herdam altura
|
||||
automaticamente do select via PrimeVue. */
|
||||
@@ -2299,7 +2358,7 @@ const anyChildDialogOpen = computed(() =>
|
||||
.composer-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
gap: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
@@ -2512,7 +2571,7 @@ const anyChildDialogOpen = computed(() =>
|
||||
/* layout single-col agora — sticky removido (nao faz sentido) */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* ── Resumo: mobile inline / desktop flutuante via Teleport ────
|
||||
@@ -2533,13 +2592,12 @@ const anyChildDialogOpen = computed(() =>
|
||||
.agenda-resumo--floating {
|
||||
display: block;
|
||||
position: fixed;
|
||||
/* Ancora no topo (igual ao Dialog do PrimeVue, que com conteudo alto fica
|
||||
~5vh do topo). Sem `top: 50%; translateY(-50%)` pra evitar desalinhar
|
||||
quando o dialog fica alto e ancorado no topo do viewport. */
|
||||
top: 5vh;
|
||||
/* top + max-height sao injetados via :style reativo (resumoStyle no
|
||||
script), sincronizados com o .p-dialog via ResizeObserver. Sem isso,
|
||||
dialog baixo (Bloqueio/Atividade) centra vertical e o resumo ficava
|
||||
preso em 5vh, desalinhado. */
|
||||
left: calc(50% + 314px);
|
||||
width: 280px;
|
||||
max-height: 90vh;
|
||||
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 */
|
||||
background: color-mix(in srgb, var(--surface-card) 88%, transparent);
|
||||
|
||||
@@ -525,7 +525,7 @@ const heroDateText = computed(() => {
|
||||
<!-- ════════════════ STEP 2 — 3 zonas ════════════════ -->
|
||||
<div v-else class="aev2-zones">
|
||||
<!-- 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">
|
||||
<span class="font-semibold">Conflito:</span> {{ form.conflito }}
|
||||
</Message>
|
||||
@@ -535,6 +535,9 @@ const heroDateText = computed(() => {
|
||||
<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.
|
||||
</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">
|
||||
<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>
|
||||
|
||||
@@ -365,6 +365,12 @@ export function useAgendaEventComposer(props, emit, extras = {}) {
|
||||
if (!isEdit.value && !perms.canCreateSession) return false;
|
||||
if (!isEdit.value && recorrenciaType.value !== 'avulsa' && !perms.canCreateRecurrence) 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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user