ffcb8b17f9
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>
437 lines
14 KiB
Vue
437 lines
14 KiB
Vue
<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 (só 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 — só 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 — só 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>
|