carousel, agenda arquivados, agenda cor, agenda arquivados, grupos pacientes, pacientes arquivados - desativados, sessoes verificadas, ajuste notificações, Prontuario, Agenda Animation, Menu Profile, bagdes Profile, Offline
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
<!-- src/components/AppOfflineOverlay.vue -->
|
||||
<!-- Detecta offline via eventos nativos do browser + polling de fetch -->
|
||||
<script setup>
|
||||
import { ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
const isOnline = ref(true) // começa como true; detecta em onMounted
|
||||
const wasOffline = ref(false)
|
||||
const showReconnected = ref(false)
|
||||
|
||||
let pollTimer = null
|
||||
let reconnectedTimer = null
|
||||
|
||||
// ── Detecção real: tenta buscar um recurso minúsculo ──────────
|
||||
async function checkConnectivity () {
|
||||
try {
|
||||
// favicon do próprio app (cache busted) — não depende de rede externa
|
||||
await fetch('/favicon.ico?_t=' + Date.now(), {
|
||||
method: 'HEAD',
|
||||
cache: 'no-store',
|
||||
signal: AbortSignal.timeout(4000)
|
||||
})
|
||||
setOnline()
|
||||
} catch {
|
||||
setOffline()
|
||||
}
|
||||
}
|
||||
|
||||
function setOnline () {
|
||||
if (!isOnline.value && wasOffline.value) {
|
||||
// acabou de reconectar
|
||||
showReconnected.value = true
|
||||
if (reconnectedTimer) clearTimeout(reconnectedTimer)
|
||||
reconnectedTimer = setTimeout(() => { showReconnected.value = false }, 4000)
|
||||
}
|
||||
isOnline.value = true
|
||||
}
|
||||
|
||||
function setOffline () {
|
||||
if (isOnline.value) wasOffline.value = true
|
||||
showReconnected.value = false
|
||||
if (reconnectedTimer) clearTimeout(reconnectedTimer)
|
||||
isOnline.value = false
|
||||
}
|
||||
|
||||
// ── Eventos nativos do browser ────────────────────────────────
|
||||
function onBrowserOffline () { setOffline() }
|
||||
function onBrowserOnline () { checkConnectivity() } // confirma antes de marcar online
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('offline', onBrowserOffline)
|
||||
window.addEventListener('online', onBrowserOnline)
|
||||
|
||||
// Polling a cada 10 s — captura quedas que não disparam evento
|
||||
pollTimer = setInterval(checkConnectivity, 10_000)
|
||||
|
||||
// Verifica estado atual ao montar (útil se já começou offline)
|
||||
checkConnectivity()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('offline', onBrowserOffline)
|
||||
window.removeEventListener('online', onBrowserOnline)
|
||||
clearInterval(pollTimer)
|
||||
clearTimeout(reconnectedTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<!-- ── Overlay: sem internet ───────────────────────────── -->
|
||||
<Transition name="offline-fade">
|
||||
<div
|
||||
v-if="!isOnline"
|
||||
class="offline-overlay"
|
||||
role="alertdialog"
|
||||
aria-live="assertive"
|
||||
aria-label="Sem conexão com a internet"
|
||||
>
|
||||
<div class="offline-backdrop" />
|
||||
|
||||
<div class="offline-card">
|
||||
<div class="offline-icon-wrap">
|
||||
<span class="offline-icon-ring" />
|
||||
<i class="pi pi-wifi offline-icon" />
|
||||
</div>
|
||||
|
||||
<h2 class="offline-title">Sem conexão</h2>
|
||||
<p class="offline-desc">
|
||||
Verifique sua internet.<br>Tentando reconectar automaticamente…
|
||||
</p>
|
||||
|
||||
<div class="offline-pulse-bar">
|
||||
<span class="offline-pulse-dot" />
|
||||
<span class="offline-pulse-dot" style="animation-delay:.2s" />
|
||||
<span class="offline-pulse-dot" style="animation-delay:.4s" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- ── Toast: reconectou ──────────────────────────────── -->
|
||||
<Transition name="reconnect-toast">
|
||||
<div v-if="showReconnected" class="reconnect-toast" role="status">
|
||||
<i class="pi pi-check-circle" />
|
||||
<span>Conexão restabelecida</span>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── Overlay ─────────────────────────────────────────────── */
|
||||
.offline-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.offline-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
/* ── Card ────────────────────────────────────────────────── */
|
||||
.offline-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: var(--surface-card, #fff);
|
||||
border: 1px solid var(--surface-border, #e0e0e0);
|
||||
border-radius: 20px;
|
||||
padding: 40px 48px;
|
||||
max-width: 380px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
box-shadow: 0 24px 60px rgba(0, 0, 0, .25);
|
||||
}
|
||||
|
||||
/* ── Ícone ───────────────────────────────────────────────── */
|
||||
.offline-icon-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.offline-icon-ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--primary-color, #6366f1);
|
||||
opacity: 0.25;
|
||||
animation: ring-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.offline-icon {
|
||||
font-size: 2.2rem;
|
||||
color: var(--primary-color, #6366f1);
|
||||
opacity: 0.85;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@keyframes ring-pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 0.25; }
|
||||
50% { transform: scale(1.18); opacity: 0.10; }
|
||||
}
|
||||
|
||||
/* ── Texto ───────────────────────────────────────────────── */
|
||||
.offline-title {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-color, #1a1a2e);
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.offline-desc {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-color-secondary, #666);
|
||||
margin: 0 0 28px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ── Dots de pulso ───────────────────────────────────────── */
|
||||
.offline-pulse-bar {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.offline-pulse-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary-color, #6366f1);
|
||||
opacity: 0.7;
|
||||
animation: dot-bounce 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes dot-bounce {
|
||||
0%, 80%, 100% { transform: scale(0.7); opacity: 0.4; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
/* ── Transição do overlay ────────────────────────────────── */
|
||||
.offline-fade-enter-active { transition: opacity 0.3s ease; }
|
||||
.offline-fade-leave-active { transition: opacity 0.4s ease; }
|
||||
.offline-fade-enter-from,
|
||||
.offline-fade-leave-to { opacity: 0; }
|
||||
|
||||
/* ── Toast de reconexão ──────────────────────────────────── */
|
||||
.reconnect-toast {
|
||||
position: fixed;
|
||||
bottom: 28px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background: #16a34a;
|
||||
color: #fff;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
padding: 10px 20px;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 4px 20px rgba(22, 163, 74, .4);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reconnect-toast .pi { font-size: 1rem; }
|
||||
|
||||
.reconnect-toast-enter-active { transition: opacity 0.3s ease, transform 0.3s ease; }
|
||||
.reconnect-toast-leave-active { transition: opacity 0.4s ease, transform 0.4s ease; }
|
||||
.reconnect-toast-enter-from { opacity: 0; transform: translateX(-50%) translateY(12px); }
|
||||
.reconnect-toast-leave-to { opacity: 0; transform: translateX(-50%) translateY(12px); }
|
||||
</style>
|
||||
@@ -98,7 +98,7 @@
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-surface-500">
|
||||
Dica: “Gerar usuário” preenche automaticamente com dados fictícios.
|
||||
Dica: "Gerar usuário" preenche automaticamente com dados fictícios.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -136,7 +136,7 @@ import { supabase } from '@/lib/supabase/client'
|
||||
const { canSee } = useRoleGuard()
|
||||
|
||||
/**
|
||||
* Lista “curada” de pensadores influentes na psicanálise e seu entorno.
|
||||
* Lista "curada" de pensadores influentes na psicanálise e seu entorno.
|
||||
* Usada para geração rápida de dados fictícios.
|
||||
*/
|
||||
const PSICANALISE_PENSADORES = Object.freeze([
|
||||
|
||||
@@ -134,7 +134,7 @@ onMounted(load)
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- Resumo tipo “cards” -->
|
||||
<!-- Resumo tipo "cards" -->
|
||||
<div class="grid grid-cols-12 gap-3 mb-4">
|
||||
<div class="col-span-12 md:col-span-4 p-3 rounded-xl border border-[var(--surface-border)]">
|
||||
<div class="text-600 text-sm">Tipo de slots</div>
|
||||
@@ -158,7 +158,7 @@ onMounted(load)
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-900 font-medium">Jornada do dia</div>
|
||||
<div class="text-600 text-sm">
|
||||
(Isso vem das suas “janelas semanais”)
|
||||
(Isso vem das suas "janelas semanais")
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ function normalizeIntervals(list) {
|
||||
return merged
|
||||
}
|
||||
|
||||
// retorna “sobras” de [s,e] depois de remover intervalos ocupados
|
||||
// retorna "sobras" de [s,e] depois de remover intervalos ocupados
|
||||
function subtractIntervals(s, e, occupiedMerged) {
|
||||
let segments = [{ s, e }]
|
||||
for (const occ of occupiedMerged) {
|
||||
@@ -127,7 +127,7 @@ function addPauseSmart({ label, inicio, fim }) {
|
||||
fim: minToHHMM(seg.e)
|
||||
}))
|
||||
|
||||
// se houve “recorte”, avisa
|
||||
// se houve "recorte", avisa
|
||||
if (segments.length !== 1 || (segments[0].s !== s || segments[0].e !== e)) {
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
<div class="text-gray-900 mb-2 text-3xl font-semibold">Joséphine Miller</div>
|
||||
<span class="text-gray-600 text-2xl">Peak Interactive</span>
|
||||
<p class="text-gray-900 sm:line-height-2 md:line-height-4 text-2xl mt-6" style="max-width: 800px">
|
||||
“Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.”
|
||||
"Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
|
||||
</p>
|
||||
<img src="/demo/images/landing/peak-logo.svg" class="mt-6" alt="Company logo" />
|
||||
</div>
|
||||
|
||||
@@ -103,7 +103,7 @@ function goToHistory () {
|
||||
<!-- Empty state -->
|
||||
<div v-else class="notification-drawer__empty">
|
||||
<i class="pi pi-bell-slash notification-drawer__empty-icon" />
|
||||
<p class="notification-drawer__empty-text">Tudo em dia</p>
|
||||
<p class="notification-drawer__empty-text">Tudo em dia por aqui 🎉</p>
|
||||
<p class="notification-drawer__empty-sub">Nenhuma notificação{{ filter === 'unread' ? ' não lida' : '' }}.</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,7 +113,7 @@ function goToHistory () {
|
||||
<div class="notification-drawer__footer">
|
||||
<button class="notification-drawer__history-link" @click="goToHistory">
|
||||
<i class="pi pi-history" />
|
||||
Ver histórico completo
|
||||
Ver histórico completo →
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -4,41 +4,38 @@ import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { ptBR } from 'date-fns/locale'
|
||||
import { useNotificationStore } from '@/stores/notificationStore'
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
item: { type: Object, required: true }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['read', 'archive'])
|
||||
|
||||
const router = useRouter()
|
||||
const store = useNotificationStore()
|
||||
|
||||
const typeIconMap = {
|
||||
new_scheduling: { icon: 'pi-inbox', color: 'text-red-500' },
|
||||
new_patient: { icon: 'pi-user-plus', color: 'text-sky-500' },
|
||||
recurrence_alert: { icon: 'pi-refresh', color: 'text-amber-500' },
|
||||
session_status: { icon: 'pi-calendar-times', color: 'text-orange-500' }
|
||||
const typeMap = {
|
||||
new_scheduling: { icon: 'pi-inbox', border: 'border-red-500', },
|
||||
new_patient: { icon: 'pi-user-plus', border: 'border-sky-500', },
|
||||
recurrence_alert: { icon: 'pi-refresh', border: 'border-amber-500', },
|
||||
session_status: { icon: 'pi-calendar-times', border: 'border-orange-500', },
|
||||
}
|
||||
|
||||
const typeIcon = computed(() => typeIconMap[props.item.type] || { icon: 'pi-bell', color: 'text-gray-400' })
|
||||
|
||||
const meta = computed(() => typeMap[props.item.type] || { icon: 'pi-bell', border: 'border-gray-300' })
|
||||
const isUnread = computed(() => !props.item.read_at)
|
||||
|
||||
const timeAgo = computed(() =>
|
||||
formatDistanceToNow(new Date(props.item.created_at), { addSuffix: true, locale: ptBR })
|
||||
)
|
||||
|
||||
const avatarInitials = computed(() =>
|
||||
props.item.payload?.avatar_initials || '??'
|
||||
)
|
||||
const initials = computed(() => props.item.payload?.avatar_initials || '?')
|
||||
|
||||
function handleRowClick () {
|
||||
const deeplink = props.item.payload?.deeplink
|
||||
if (deeplink) {
|
||||
router.push(deeplink)
|
||||
store.drawerOpen = false
|
||||
emit('read', props.item.id)
|
||||
}
|
||||
}
|
||||
@@ -56,45 +53,42 @@ function handleArchive (e) {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="notification-item"
|
||||
:class="{ 'notification-item--unread': isUnread }"
|
||||
@click="handleRowClick"
|
||||
class="notif-item"
|
||||
:class="[meta.border, isUnread ? 'notif-item--unread' : '']"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="handleRowClick"
|
||||
@keydown.enter="handleRowClick"
|
||||
>
|
||||
<!-- Dot indicador -->
|
||||
<span v-if="isUnread" class="notification-item__dot" aria-hidden="true" />
|
||||
|
||||
<!-- Ícone de tipo -->
|
||||
<span class="notification-item__type-icon" aria-hidden="true">
|
||||
<i :class="['pi', typeIcon.icon, typeIcon.color]" />
|
||||
</span>
|
||||
<!-- Ícone do tipo -->
|
||||
<div class="notif-item__icon" aria-hidden="true">
|
||||
<i :class="['pi', meta.icon]" />
|
||||
</div>
|
||||
|
||||
<!-- Avatar -->
|
||||
<span class="notification-item__avatar" aria-hidden="true">
|
||||
{{ avatarInitials }}
|
||||
</span>
|
||||
<div class="notif-item__avatar" aria-hidden="true">
|
||||
{{ initials }}
|
||||
</div>
|
||||
|
||||
<!-- Conteúdo -->
|
||||
<div class="notification-item__content">
|
||||
<p class="notification-item__title">{{ item.payload?.title }}</p>
|
||||
<p class="notification-item__detail">{{ item.payload?.detail }}</p>
|
||||
<p class="notification-item__time">{{ timeAgo }}</p>
|
||||
<div class="notif-item__body">
|
||||
<p class="notif-item__title">{{ item.payload?.title }}</p>
|
||||
<p class="notif-item__detail">{{ item.payload?.detail }}</p>
|
||||
<p class="notif-item__time">{{ timeAgo }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Ações -->
|
||||
<div class="notification-item__actions" @click.stop>
|
||||
<div class="notif-item__actions" @click.stop>
|
||||
<button
|
||||
v-if="isUnread"
|
||||
class="notification-item__action-btn"
|
||||
class="notif-item__btn"
|
||||
title="Marcar como lida"
|
||||
@click="handleMarkRead"
|
||||
>
|
||||
<i class="pi pi-check" />
|
||||
</button>
|
||||
<button
|
||||
class="notification-item__action-btn"
|
||||
class="notif-item__btn"
|
||||
title="Arquivar"
|
||||
@click="handleArchive"
|
||||
>
|
||||
@@ -105,101 +99,92 @@ function handleArchive (e) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notification-item {
|
||||
position: relative;
|
||||
.notif-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.625rem;
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
border-left-width: 3px;
|
||||
border-left-style: solid;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.notification-item:hover {
|
||||
.notif-item:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
.notification-item--unread {
|
||||
background: color-mix(in srgb, var(--primary-color) 6%, transparent);
|
||||
.notif-item--unread {
|
||||
background: rgba(99, 102, 241, 0.05);
|
||||
}
|
||||
.notification-item--unread:hover {
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
.notif-item--unread:hover {
|
||||
background: rgba(99, 102, 241, 0.09);
|
||||
}
|
||||
|
||||
.notification-item__dot {
|
||||
position: absolute;
|
||||
left: 0.25rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #6366f1;
|
||||
.notif-item__icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notification-item__type-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-top: 0.125rem;
|
||||
flex-shrink: 0;
|
||||
font-size: 0.85rem;
|
||||
padding-top: 0.15rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.notification-item__avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.notif-item__avatar {
|
||||
flex-shrink: 0;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
background: var(--surface-200);
|
||||
color: var(--text-color);
|
||||
font-size: 0.7rem;
|
||||
background: linear-gradient(135deg, #6366f1, #38bdf8);
|
||||
color: #fff;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
letter-spacing: 0.05em;
|
||||
letter-spacing: 0.04em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.notification-item__content {
|
||||
.notif-item__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.notification-item__title {
|
||||
.notif-item__title {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-color);
|
||||
margin: 0 0 0.125rem;
|
||||
margin: 0 0 0.1rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.notification-item__detail {
|
||||
font-size: 0.8rem;
|
||||
.notif-item__detail {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-color-secondary);
|
||||
margin: 0 0 0.125rem;
|
||||
margin: 0 0 0.1rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.notification-item__time {
|
||||
font-size: 0.72rem;
|
||||
.notif-item__time {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.7;
|
||||
margin: 0;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.notification-item__actions {
|
||||
.notif-item__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.notification-item:hover .notification-item__actions {
|
||||
.notif-item:hover .notif-item__actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.notification-item__action-btn {
|
||||
.notif-item__btn {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 50%;
|
||||
@@ -212,7 +197,7 @@ function handleArchive (e) {
|
||||
font-size: 0.75rem;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.notification-item__action-btn:hover {
|
||||
.notif-item__btn:hover {
|
||||
background: var(--surface-border);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
<!-- src/components/patients/PatientActionMenu.vue -->
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { usePatientLifecycle } from '@/composables/usePatientLifecycle'
|
||||
|
||||
const props = defineProps({
|
||||
patient: { type: Object, required: true },
|
||||
hasHistory: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['updated'])
|
||||
|
||||
const toast = useToast()
|
||||
const { deletePatient, deactivatePatient, archivePatient, reactivatePatient } = usePatientLifecycle()
|
||||
|
||||
const loading = ref(false)
|
||||
const menu = ref()
|
||||
|
||||
// ── Dialogs ────────────────────────────────────────────────
|
||||
const deactivateDialogOpen = ref(false)
|
||||
const archiveDialogOpen = ref(false)
|
||||
const hasHistoryDialogOpen = ref(false)
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────
|
||||
function showSuccess (detail) {
|
||||
toast.add({ severity: 'success', summary: 'Ok', detail, life: 2500 })
|
||||
}
|
||||
function showError (detail) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail, life: 4000 })
|
||||
}
|
||||
|
||||
async function runAction (fn, successMsg) {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await fn(props.patient.id)
|
||||
if (!result.ok) { showError(result.message || result.error?.message || 'Falha ao executar ação.'); return }
|
||||
showSuccess(successMsg)
|
||||
emit('updated')
|
||||
} catch (e) {
|
||||
showError(e?.message || 'Falha inesperada.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Desativar — sempre mostra dialog informativo
|
||||
function handleDeactivate () {
|
||||
deactivateDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmDeactivate () {
|
||||
deactivateDialogOpen.value = false
|
||||
await runAction(deactivatePatient, 'Paciente desativado.')
|
||||
}
|
||||
|
||||
// ── Arquivar — sempre mostra dialog informativo
|
||||
function handleArchive () {
|
||||
archiveDialogOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmArchive () {
|
||||
archiveDialogOpen.value = false
|
||||
hasHistoryDialogOpen.value = false
|
||||
await runAction(archivePatient, 'Paciente arquivado.')
|
||||
}
|
||||
|
||||
// ── Excluir — se tem histórico, mostra dialog informativo
|
||||
function handleDelete () {
|
||||
if (props.hasHistory) {
|
||||
hasHistoryDialogOpen.value = true
|
||||
return
|
||||
}
|
||||
doDelete()
|
||||
}
|
||||
|
||||
async function doDelete () {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await deletePatient(props.patient.id)
|
||||
if (!result.ok) { showError(result.message || 'Não foi possível excluir.'); return }
|
||||
showSuccess('Paciente excluído.')
|
||||
emit('updated')
|
||||
} catch (e) {
|
||||
showError(e?.message || 'Falha inesperada.')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Menu de ações ─────────────────────────────────────────
|
||||
function menuItems () {
|
||||
const status = props.patient.status
|
||||
const items = []
|
||||
|
||||
if (status === 'Ativo') {
|
||||
items.push({ label: 'Desativar', icon: 'pi pi-pause', command: () => handleDeactivate() })
|
||||
items.push({ label: 'Arquivar', icon: 'pi pi-archive', command: () => handleArchive() })
|
||||
}
|
||||
|
||||
if (status === 'Inativo') {
|
||||
items.push({ label: 'Reativar', icon: 'pi pi-play', command: () => runAction(reactivatePatient, 'Paciente reativado.') })
|
||||
items.push({ label: 'Arquivar', icon: 'pi pi-archive', command: () => handleArchive() })
|
||||
}
|
||||
|
||||
if (status === 'Arquivado') {
|
||||
items.push({ label: 'Reativar', icon: 'pi pi-play', command: () => runAction(reactivatePatient, 'Paciente reativado.') })
|
||||
}
|
||||
|
||||
if (status === 'Alta' || status === 'Encaminhado') {
|
||||
items.push({ label: 'Arquivar', icon: 'pi pi-archive', command: () => handleArchive() })
|
||||
items.push({ label: 'Reativar', icon: 'pi pi-play', command: () => runAction(reactivatePatient, 'Paciente reativado.') })
|
||||
}
|
||||
|
||||
if (items.length) items.push({ separator: true })
|
||||
items.push({ label: 'Excluir paciente', icon: 'pi pi-trash', class: 'text-red-500', command: () => handleDelete() })
|
||||
|
||||
return items
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex gap-1 justify-end">
|
||||
<Button
|
||||
icon="pi pi-ellipsis-v"
|
||||
severity="secondary"
|
||||
outlined
|
||||
size="small"
|
||||
:loading="loading"
|
||||
v-tooltip.top="'Ações do paciente'"
|
||||
@click="(e) => menu.toggle(e)"
|
||||
/>
|
||||
<Menu ref="menu" :model="menuItems()" popup appendTo="body" />
|
||||
</div>
|
||||
|
||||
<!-- Dialog: Desativar -->
|
||||
<Dialog
|
||||
v-model:visible="deactivateDialogOpen"
|
||||
modal
|
||||
:draggable="false"
|
||||
header="Desativar paciente"
|
||||
:style="{ width: '460px', maxWidth: '95vw' }"
|
||||
>
|
||||
<div class="flex gap-3 items-start py-2">
|
||||
<i class="pi pi-exclamation-triangle text-amber-500 text-2xl mt-0.5 flex-shrink-0" />
|
||||
<div class="text-sm text-[var(--text-color)] leading-relaxed space-y-2">
|
||||
<p class="m-0 font-semibold">
|
||||
Atenção: este paciente pode possuir sessões agendadas e/ou recorrências ativas.
|
||||
</p>
|
||||
<p class="m-0">
|
||||
Ao desativar, todas as sessões e recorrências serão mantidas na agenda — porém novos agendamentos ficarão bloqueados.
|
||||
</p>
|
||||
<p class="m-0 text-[var(--text-color-secondary)]">
|
||||
Recomendamos revisar a agenda e encerrar as recorrências manualmente antes de prosseguir.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<Button label="Cancelar" severity="secondary" outlined @click="deactivateDialogOpen = false" />
|
||||
<Button label="Entendo, desativar mesmo assim!" icon="pi pi-pause" severity="warn" @click="confirmDeactivate" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Dialog: Arquivar -->
|
||||
<Dialog
|
||||
v-model:visible="archiveDialogOpen"
|
||||
modal
|
||||
:draggable="false"
|
||||
header="Arquivar paciente"
|
||||
:style="{ width: '460px', maxWidth: '95vw' }"
|
||||
>
|
||||
<div class="flex gap-3 items-start py-2">
|
||||
<i class="pi pi-archive text-slate-500 text-2xl mt-0.5 flex-shrink-0" />
|
||||
<div class="text-sm text-[var(--text-color)] leading-relaxed space-y-2">
|
||||
<p class="m-0 font-semibold">O que acontece ao arquivar um paciente?</p>
|
||||
<ul class="m-0 pl-4 space-y-1 text-[var(--text-color)]">
|
||||
<li>O paciente sairá da listagem ativa e ficará oculto nas buscas padrão.</li>
|
||||
<li>Todo o histórico clínico, sessões e registros financeiros serão preservados.</li>
|
||||
<li>Novos agendamentos para este paciente ficarão bloqueados.</li>
|
||||
<li>O paciente pode ser <strong>reativado</strong> a qualquer momento pelo menu de ações.</li>
|
||||
</ul>
|
||||
<p class="m-0 text-[var(--text-color-secondary)]">
|
||||
Arquivar é indicado para pacientes que concluíram o acompanhamento ou que estão em pausa prolongada.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<Button label="Cancelar" severity="secondary" outlined @click="archiveDialogOpen = false" />
|
||||
<Button label="Compreendo, arquivar mesmo assim!" icon="pi pi-archive" @click="confirmArchive" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Dialog: Exclusão bloqueada (paciente com histórico) -->
|
||||
<Dialog
|
||||
v-model:visible="hasHistoryDialogOpen"
|
||||
modal
|
||||
:draggable="false"
|
||||
header="Exclusão não permitida"
|
||||
:style="{ width: '420px', maxWidth: '95vw' }"
|
||||
>
|
||||
<div class="flex gap-3 items-start py-2">
|
||||
<i class="pi pi-info-circle text-amber-500 text-2xl mt-0.5 flex-shrink-0" />
|
||||
<p class="text-[var(--text-color)] text-sm leading-relaxed m-0">
|
||||
Este paciente possui histórico clínico e <strong>não pode ser removido permanentemente</strong>.
|
||||
Apenas o arquivamento é permitido para pacientes com registros de atendimento.
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<Button label="Cancelar" severity="secondary" outlined @click="hasHistoryDialogOpen = false" />
|
||||
<Button label="Arquivar paciente" icon="pi pi-archive" @click="confirmArchive" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
Reference in New Issue
Block a user