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>
|
<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;
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user