Files
agenciapsilmno/src/layout/melissa/MelissaEventoPanel.vue
T
Leonardo ffcb8b17f9 Melissa Agenda: paridade com AgendaTerapeuta + responsivo mobile
Composable useMelissaAgenda (~1150 linhas, exclusivo Melissa):
- Orquestra useAgendaEvents + useRecurrence + useDeterminedCommitments
  + useFeriados + useCommitmentServices
- 7 cases de save (avulso, recorrente C, somente_este D, este_e_seguintes E,
  todos F, todos_sem_excecao G + tratamento de exclusion constraint)
- 3 cases de delete (somente_este, este_e_seguintes, todos com encerrar série)
- onCreateEvento (botão Agendar), onSelectTime com cap de 120min,
  persistMoveOrResize com confirm dialog descritivo e bold em datas/horas
- Bloqueio: openBloqueioDialog(mode) com 4 modos

MelissaLayout:
- Provide composable via MELISSA_AGENDA_KEY (inject em MelissaAgenda)
- Renderiza AgendaEventDialog + BloqueioDialog + ConfirmDialog
- Slot #message v-html pra renderizar HTML em messages do confirm
- onEditEvento liga panel ao dialog completo (B3 não-stub)

MelissaAgenda:
- Drop useMelissaEventosRange — eventos vêm do composable injetado
- Drag/resize/select-to-create habilitados quando há composable
- Cluster Paciente + Agendar (50/50 primary)
- Toolbar: timeMode (24/12/Meu) + onlySessions + bloquear-menu (desktop)
- Header: Pacientes (mobile-only, abre drawer) + Configurações + Fechar
- Mobile <lg: aside + widgets viram drawer off-canvas (slide esquerda);
  calendar fullwidth; "Ações" menu mobile concentra timeMode/onlySessions/
  bloquear; backdrop com click-outside

MelissaEventoPanel (B3 estático-revisado):
- Substitui panel inline que crashava em campos inexistentes
- Action bar agrupada (status / paciente / geral)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 18:15:56 -03:00

437 lines
14 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
/*
* MelissaEventoPanel — Painel de detalhes do evento selecionado.
* --------------------------------------------------
* Substitui o panel inline que vivia em MelissaLayout (era prone a crash
* por referenciar campos inexistentes no normalize: .valor, .participantes,
* .supervisorNome, .local).
*
* Renderiza apenas campos REAIS do useMelissaEventos.normalizeEvent:
* tipo, status, modalidade, descricao, pacienteNome, patient_id,
* color, label, startH, endH, inicio_em, fim_em
*
* Actions emitidas (parent decide o que fazer):
* - close
* - concluir / faltou / cancelar (mudança de status)
* - remarcar / edit (abre dialog de edição — TODO no parent)
* - abrir-prontuario / whatsapp / historico (paciente actions)
*/
import { computed } from 'vue';
const props = defineProps({
evento: { type: Object, required: true },
busy: { type: Boolean, default: false } // bloqueia botões enquanto UPDATE roda
});
const emit = defineEmits([
'close',
'concluir',
'faltou',
'cancelar',
'remarcar',
'edit',
'abrir-prontuario',
'whatsapp',
'historico'
]);
const ev = computed(() => props.evento || {});
const tipoLabel = computed(() => {
const t = String(ev.value.tipo || '').toLowerCase();
if (t === 'sessao') return 'Sessão';
if (t === 'supervisao' || t === 'supervisão') return 'Supervisão';
if (t === 'reuniao' || t === 'reunião') return 'Reunião';
if (t === 'bloqueio') return 'Bloqueio';
return t || 'Evento';
});
const statusLabel = computed(() => {
const s = String(ev.value.status || '').toLowerCase();
if (!s || s === 'agendado') return 'Agendado';
if (s === 'realizado' || s === 'realizada') return 'Realizada';
if (s === 'faltou') return 'Faltou';
if (s === 'cancelado' || s === 'cancelada') return 'Cancelada';
if (s === 'remarcar') return 'A remarcar';
return ev.value.status;
});
const statusSlug = computed(() => {
const s = String(ev.value.status || '').toLowerCase();
if (s === 'realizada') return 'realizado';
if (s === 'cancelada') return 'cancelado';
return s || 'agendado';
});
// Sessão com paciente vinculado mostra o grupo de actions de paciente
const isSessaoComPaciente = computed(
() => ev.value.tipo === 'sessao' && (ev.value.patient_id || ev.value.pacienteNome)
);
// Status finais não permitem mudar pra outro status (UI mais clara)
const statusEhFinal = computed(() => /realizad|faltou|cancelad/i.test(ev.value.status || ''));
function fmtHora(decimal) {
if (decimal === null || decimal === undefined || Number.isNaN(decimal)) return '—';
const h = Math.floor(decimal);
const m = Math.round((decimal - h) * 60);
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}
function duracaoMin() {
const s = ev.value.startH;
const e = ev.value.endH;
if (typeof s !== 'number' || typeof e !== 'number') return null;
return Math.max(0, Math.round((e - s) * 60));
}
function modalidadeIcon(mod) {
const m = String(mod || '').toLowerCase();
if (m === 'online') return 'pi pi-video';
return 'pi pi-map-marker';
}
</script>
<template>
<div class="evento-layer" @click.self="emit('close')">
<div class="evento-panel">
<!-- Header -->
<div class="evento-head">
<div class="evento-head__main">
<div class="evento-pill" :style="{ backgroundColor: ev.color }" />
<div class="min-w-0">
<div class="evento-tipo">{{ tipoLabel }}</div>
<div class="evento-titulo">
{{ isSessaoComPaciente ? ev.pacienteNome : (ev.label || ev.titulo || '—') }}
</div>
</div>
</div>
<button
class="glass-btn evento-close"
v-tooltip.left="'Fechar (Esc)'"
@click="emit('close')"
>
<i class="pi pi-times" />
</button>
</div>
<!-- Conteúdo ( campos reais) -->
<div class="evento-content">
<div class="evento-row">
<i class="pi pi-clock" />
<span>
{{ fmtHora(ev.startH) }} {{ fmtHora(ev.endH) }}
<span v-if="duracaoMin() !== null" class="evento-row__sub">· {{ duracaoMin() }}min</span>
</span>
</div>
<div v-if="ev.modalidade" class="evento-row">
<i :class="modalidadeIcon(ev.modalidade)" />
<span class="capitalize">{{ ev.modalidade }}</span>
</div>
<div class="evento-row">
<i class="pi pi-info-circle" />
<span class="evento-status" :class="`is-${statusSlug}`">{{ statusLabel }}</span>
</div>
<div v-if="ev.descricao" class="evento-desc">
{{ ev.descricao }}
</div>
</div>
<!-- Action bar agrupada por contexto -->
<footer class="evento-actions">
<!-- Grupo Status pra sessão e quando ainda não é status final -->
<div v-if="isSessaoComPaciente && !statusEhFinal" class="evento-actions__group">
<button
class="evento-act evento-act--ok"
:disabled="busy"
v-tooltip.top="'Marcar como realizada'"
@click="emit('concluir')"
>
<i class="pi pi-check-circle" />
</button>
<button
class="evento-act evento-act--warn"
:disabled="busy"
v-tooltip.top="'Marcar como falta'"
@click="emit('faltou')"
>
<i class="pi pi-user-minus" />
</button>
<button
class="evento-act"
:disabled="busy"
v-tooltip.top="'Remarcar'"
@click="emit('remarcar')"
>
<i class="pi pi-calendar-clock" />
</button>
<button
class="evento-act evento-act--danger"
:disabled="busy"
v-tooltip.top="'Cancelar'"
@click="emit('cancelar')"
>
<i class="pi pi-ban" />
</button>
</div>
<!-- Grupo Paciente pra sessão com paciente vinculado -->
<div v-if="isSessaoComPaciente" class="evento-actions__group">
<button
class="evento-act"
:disabled="busy"
v-tooltip.top="'Abrir prontuário'"
@click="emit('abrir-prontuario')"
>
<i class="pi pi-file" />
</button>
<button
class="evento-act"
:disabled="busy"
v-tooltip.top="'Conversar (WhatsApp)'"
@click="emit('whatsapp')"
>
<i class="pi pi-whatsapp" />
</button>
<button
class="evento-act"
:disabled="busy"
v-tooltip.top="'Histórico de sessões'"
@click="emit('historico')"
>
<i class="pi pi-history" />
</button>
</div>
<!-- Grupo Geral Editar sempre disponível -->
<div class="evento-actions__group">
<button
class="evento-act"
:disabled="busy"
v-tooltip.top="'Editar evento'"
@click="emit('edit')"
>
<i class="pi pi-pencil" />
</button>
</div>
</footer>
</div>
</div>
</template>
<style scoped>
/* Camada full-screen com backdrop blur — mantém pattern .evento-layer
que vivia inline no MelissaLayout (assim o lift transition no parent
continua funcionando sem alteração). */
.evento-layer {
position: fixed;
inset: 0;
z-index: 60;
display: grid;
place-items: center;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(20px) saturate(150%);
-webkit-backdrop-filter: blur(20px) saturate(150%);
padding: 20px;
}
.evento-panel {
width: 100%;
max-width: 480px;
background: var(--m-bg-medium, rgba(20, 20, 20, 0.85));
backdrop-filter: blur(28px) saturate(170%);
-webkit-backdrop-filter: blur(28px) saturate(170%);
border: 1px solid var(--m-border, rgba(255, 255, 255, 0.12));
border-radius: 18px;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45);
color: var(--m-text);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
padding: 22px 22px 18px;
display: flex;
flex-direction: column;
gap: 18px;
}
/* ─── Header ────────────────────────────────────── */
.evento-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.evento-head__main {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.evento-pill {
width: 4px;
height: 38px;
border-radius: 2px;
flex-shrink: 0;
}
.evento-tipo {
color: var(--m-text-muted);
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.2em;
font-weight: 600;
}
.evento-titulo {
color: var(--m-text);
font-size: 1.1rem;
font-weight: 500;
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 340px;
}
.evento-close {
width: 34px;
height: 34px;
display: grid;
place-items: center;
flex-shrink: 0;
background: var(--m-bg-soft, rgba(255, 255, 255, 0.08));
border: 1px solid var(--m-border, rgba(255, 255, 255, 0.12));
color: var(--m-text);
border-radius: 10px;
cursor: pointer;
transition: background-color 140ms ease;
font-size: 0.85rem;
}
.evento-close:hover { background: var(--m-bg-soft-hover, rgba(255, 255, 255, 0.16)); }
/* ─── Conteúdo ──────────────────────────────────── */
.evento-content {
display: flex;
flex-direction: column;
gap: 12px;
}
.evento-row {
display: flex;
align-items: center;
gap: 10px;
color: var(--m-text);
font-size: 0.88rem;
}
.evento-row > i {
color: var(--m-text-muted);
font-size: 0.95rem;
width: 18px;
text-align: center;
}
.evento-row__sub {
color: var(--m-text-muted);
margin-left: 4px;
font-size: 0.82rem;
}
.evento-status {
padding: 2px 10px;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 600;
border: 1px solid var(--m-border);
background: var(--m-bg-soft);
}
.evento-status.is-realizado {
color: rgb(16, 185, 129);
border-color: rgba(16, 185, 129, 0.35);
background: rgba(16, 185, 129, 0.12);
}
.evento-status.is-faltou {
color: rgb(239, 68, 68);
border-color: rgba(239, 68, 68, 0.35);
background: rgba(239, 68, 68, 0.12);
}
.evento-status.is-cancelado {
color: rgb(148, 163, 184);
border-color: rgba(148, 163, 184, 0.35);
background: rgba(148, 163, 184, 0.12);
}
.evento-status.is-remarcar {
color: rgb(245, 158, 11);
border-color: rgba(245, 158, 11, 0.35);
background: rgba(245, 158, 11, 0.12);
}
.evento-desc {
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
border-radius: 10px;
padding: 10px 12px;
color: var(--m-text);
font-size: 0.85rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
max-height: 140px;
overflow-y: auto;
}
/* ─── Action bar ────────────────────────────────── */
.evento-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding-top: 14px;
border-top: 1px solid var(--m-border);
justify-content: space-between;
}
.evento-actions__group {
display: flex;
gap: 6px;
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
border-radius: 12px;
padding: 4px;
}
.evento-act {
width: 38px;
height: 38px;
display: grid;
place-items: center;
background: transparent;
border: none;
color: var(--m-text);
border-radius: 9px;
cursor: pointer;
font-size: 1rem;
font-family: inherit;
transition: background-color 140ms ease, color 140ms ease, transform 140ms ease;
}
.evento-act:hover:not(:disabled) {
background: var(--m-bg-soft-hover);
transform: translateY(-1px);
}
.evento-act:focus-visible {
outline: 2px solid var(--m-accent);
outline-offset: 2px;
}
.evento-act:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.evento-act--ok:hover:not(:disabled) {
color: rgb(16, 185, 129);
background: rgba(16, 185, 129, 0.15);
}
.evento-act--warn:hover:not(:disabled) {
color: rgb(245, 158, 11);
background: rgba(245, 158, 11, 0.15);
}
.evento-act--danger:hover:not(:disabled) {
color: rgb(239, 68, 68);
background: rgba(239, 68, 68, 0.15);
}
/* Light mode — overlay menos escuro */
html:not(.app-dark) .evento-layer {
background: rgba(0, 0, 0, 0.32);
}
</style>