Layout 100%, Notificações, SetupWizard

This commit is contained in:
Leonardo
2026-03-17 21:08:14 -03:00
parent 84d65e49c0
commit 66f67cd40f
77 changed files with 35823 additions and 15023 deletions

View File

@@ -17,7 +17,8 @@
"Bash(C:/Users/lmnohama/AppData/Local/Programs/Python/Python310/python.exe -c \"\nimport sys\nwith open\\('/d/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/DBS/2026-03-12/schema.sql', 'r', encoding='utf-8'\\) as f:\n lines = f.readlines\\(\\)\nprint\\(f'Total lines: {len\\(lines\\)}'\\)\n\" 2>&1)",
"Bash(C:/Users/lmnohama/AppData/Local/Programs/Python/Python310/python.exe -c \"\nimport sys\nfpath = 'D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/DBS/2026-03-12/schema.sql'\nwith open\\(fpath, 'r', encoding='utf-8'\\) as f:\n lines = f.readlines\\(\\)\nsys.stdout.buffer.write\\(\\('Total lines: ' + str\\(len\\(lines\\)\\) + '\\\\n'\\).encode\\('utf-8'\\)\\)\n\" 2>&1)",
"Bash(find /d/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai -type f \\\\\\( -name \"*convenio*\" -o -name \"*Convenio*\" \\\\\\) 2>/dev/null | head -20)",
"Bash(find:*)"
"Bash(find:*)",
"Bash(ls:*)"
]
}
}

21933
DBS/2026-03-17/schema.sql Normal file

File diff suppressed because it is too large Load Diff

15
package-lock.json generated
View File

@@ -18,6 +18,7 @@
"@primeuix/themes": "^2.0.0",
"@supabase/supabase-js": "^2.95.3",
"chart.js": "3.3.2",
"date-fns": "^4.1.0",
"pinia": "^3.0.4",
"primeicons": "^7.0.0",
"primevue": "^4.5.4",
@@ -2537,6 +2538,15 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
},
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -7035,6 +7045,11 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
},
"date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="
},
"debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",

View File

@@ -23,6 +23,7 @@
"@primeuix/themes": "^2.0.0",
"@supabase/supabase-js": "^2.95.3",
"chart.js": "3.3.2",
"date-fns": "^4.1.0",
"pinia": "^3.0.4",
"primeicons": "^7.0.0",
"primevue": "^4.5.4",

View File

@@ -2,10 +2,11 @@
<!-- Painel de ajuda lateral home com sessão/docs/faq + navegação interna + votação -->
<script setup>
import { ref, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { useAjuda } from '@/composables/useAjuda'
const route = useRoute()
const router = useRouter()
const {
sessionDocs, sessionFaq,
@@ -64,6 +65,50 @@ function fechar () {
faqAbertos.value = {}
closeDrawer()
}
// ── Highlight de elemento na página ──────────────────────────
async function handleDocClick (e) {
const anchor = e.target.closest('a[data-highlight]')
if (!anchor) return
e.preventDefault()
const targetId = anchor.getAttribute('data-highlight')
const targetRoute = anchor.getAttribute('data-route') || null
// Navega para outra rota se necessário (drawer permanece aberto)
if (targetRoute && route.path !== targetRoute) {
await router.push(targetRoute)
// Aguarda a rota e o DOM renderizarem com drawer ainda visível
await new Promise(r => setTimeout(r, 500))
}
// Scroll + highlight
await scrollAndHighlight(targetId)
}
async function scrollAndHighlight (id) {
// Tenta encontrar o elemento (pode ainda não ter montado)
let attempts = 0
let el = null
while (!el && attempts < 10) {
el = document.getElementById(id)
if (!el) await new Promise(r => setTimeout(r, 100))
attempts++
}
if (!el) return
// Scroll suave até o elemento
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
// Aguarda o scroll terminar
await new Promise(r => setTimeout(r, 500))
// Adiciona classe de highlight e remove depois da animação
el.classList.add('notif-card--highlight')
el.addEventListener('animationend', () => {
el.classList.remove('notif-card--highlight')
}, { once: true })
}
</script>
<template>
@@ -106,7 +151,7 @@ function fechar () {
<div class="doc-view">
<!-- Conteúdo -->
<div v-if="docAtual?.conteudo" class="doc-conteudo ql-content" v-html="docAtual.conteudo" />
<div v-if="docAtual?.conteudo" class="doc-conteudo ql-content" v-html="docAtual.conteudo" @click="handleDocClick" />
<!-- Mídias -->
<template v-if="docAtual?.medias?.length">
@@ -135,7 +180,7 @@ function fechar () {
</button>
<Transition name="expand">
<div v-if="faqAbertos[item.id] && item.resposta"
class="faq-resposta ql-content" v-html="item.resposta" />
class="faq-resposta ql-content" v-html="item.resposta" @click="handleDocClick" />
</Transition>
</div>
</div>
@@ -298,7 +343,7 @@ function fechar () {
</button>
<Transition name="expand">
<div v-if="faqAbertos[item.id] && item.resposta"
class="faq-resposta ql-content" v-html="item.resposta" />
class="faq-resposta ql-content" v-html="item.resposta" @click="handleDocClick" />
</Transition>
</div>
</div>
@@ -326,7 +371,7 @@ function fechar () {
</button>
<Transition name="expand">
<div v-if="faqAbertos[item.id] && item.resposta"
class="faq-resposta ql-content" v-html="item.resposta" />
class="faq-resposta ql-content" v-html="item.resposta" @click="handleDocClick" />
</Transition>
</div>
</div>
@@ -705,6 +750,34 @@ function fechar () {
font-weight: 400;
}
/* ── Links de highlight ──────────────────────────────────────── */
.doc-conteudo.ql-content :deep(a[data-highlight]),
.faq-resposta.ql-content :deep(a[data-highlight]) {
display: inline-flex;
align-items: center;
gap: 0.25rem;
color: var(--primary-color);
text-decoration: none;
font-weight: 600;
padding: 0.1rem 0.5rem;
border-radius: 999px;
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--primary-color) 25%, transparent);
transition: background 0.15s;
cursor: pointer;
font-size: 0.82rem;
}
.doc-conteudo.ql-content :deep(a[data-highlight]:hover),
.faq-resposta.ql-content :deep(a[data-highlight]:hover) {
background: color-mix(in srgb, var(--primary-color) 18%, transparent);
}
.doc-conteudo.ql-content :deep(a[data-highlight]::before),
.faq-resposta.ql-content :deep(a[data-highlight]::before) {
content: "↗";
font-size: 0.7rem;
opacity: 0.7;
}
/* ── Expand transition ───────────────────────────────────────── */
.expand-enter-active,
.expand-leave-active {

View File

@@ -2,9 +2,11 @@
<Dialog
v-model:visible="isOpen"
modal
:draggable="false"
:closable="!saving"
:dismissableMask="!saving"
:style="{ width: '34rem', maxWidth: '92vw' }"
pt:mask:class="backdrop-blur-xs"
@hide="onHide"
>
<template #header>

View File

@@ -0,0 +1,249 @@
<!-- src/components/notifications/NotificationDrawer.vue -->
<script setup>
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useNotificationStore } from '@/stores/notificationStore'
import NotificationItem from './NotificationItem.vue'
const store = useNotificationStore()
const router = useRouter()
const filter = ref('unread') // 'unread' | 'all'
const drawerOpen = computed({
get: () => store.drawerOpen,
set: (v) => { store.drawerOpen = v }
})
const displayedItems = computed(() =>
filter.value === 'unread' ? store.unreadItems : store.allItems
)
function handleRead (id) {
store.markRead(id)
// Fecha o drawer e deixa a navegação acontecer
store.drawerOpen = false
}
function handleArchive (id) {
store.archive(id)
}
function goToHistory () {
router.push('/therapist/notificacoes')
store.drawerOpen = false
}
</script>
<template>
<Drawer
v-model:visible="drawerOpen"
position="right"
:style="{ width: '380px' }"
:pt="{ header: { class: 'notification-drawer__header' } }"
>
<!-- Header -->
<template #header>
<div class="notification-drawer__header-content">
<span class="notification-drawer__title">Notificações</span>
<Badge
v-if="store.unreadCount > 0"
:value="store.unreadCount > 99 ? '99+' : store.unreadCount"
severity="danger"
/>
</div>
</template>
<!-- Corpo -->
<div class="notification-drawer__body">
<!-- Ação em lote -->
<div class="notification-drawer__toolbar">
<!-- Filtro tabs -->
<div class="notification-drawer__tabs">
<button
class="notification-drawer__tab"
:class="{ 'notification-drawer__tab--active': filter === 'unread' }"
@click="filter = 'unread'"
>
Não lidas
<span v-if="store.unreadCount > 0" class="notification-drawer__tab-count">
{{ store.unreadCount }}
</span>
</button>
<button
class="notification-drawer__tab"
:class="{ 'notification-drawer__tab--active': filter === 'all' }"
@click="filter = 'all'"
>
Todas
</button>
</div>
<Button
v-if="store.unreadCount > 0"
link
size="small"
label="Marcar todas como lidas"
@click="store.markAllRead()"
class="notification-drawer__mark-all"
/>
</div>
<!-- Lista -->
<div v-if="displayedItems.length > 0" class="notification-drawer__list">
<NotificationItem
v-for="item in displayedItems"
:key="item.id"
:item="item"
@read="handleRead"
@archive="handleArchive"
/>
</div>
<!-- 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-sub">Nenhuma notificação{{ filter === 'unread' ? ' não lida' : '' }}.</p>
</div>
</div>
<!-- Footer -->
<template #footer>
<div class="notification-drawer__footer">
<button class="notification-drawer__history-link" @click="goToHistory">
<i class="pi pi-history" />
Ver histórico completo
</button>
</div>
</template>
</Drawer>
</template>
<style scoped>
.notification-drawer__header-content {
display: flex;
align-items: center;
gap: 0.5rem;
}
.notification-drawer__title {
font-size: 1rem;
font-weight: 600;
color: var(--text-color);
}
.notification-drawer__body {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.notification-drawer__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
border-bottom: 1px solid var(--surface-border);
flex-shrink: 0;
gap: 0.5rem;
flex-wrap: wrap;
}
.notification-drawer__tabs {
display: flex;
gap: 0.25rem;
}
.notification-drawer__tab {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0.75rem;
border-radius: 999px;
border: 1px solid var(--surface-border);
background: transparent;
color: var(--text-color-secondary);
font-size: 0.8rem;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.notification-drawer__tab--active {
background: var(--primary-color);
color: var(--primary-color-text);
border-color: var(--primary-color);
}
.notification-drawer__tab-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.1rem;
height: 1.1rem;
padding: 0 0.25rem;
border-radius: 999px;
background: rgba(255,255,255,0.25);
font-size: 0.7rem;
font-weight: 700;
}
.notification-drawer__mark-all {
white-space: nowrap;
font-size: 0.78rem !important;
padding: 0 !important;
}
.notification-drawer__list {
flex: 1;
overflow-y: auto;
}
.notification-drawer__empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 3rem 1rem;
text-align: center;
}
.notification-drawer__empty-icon {
font-size: 2.5rem;
color: var(--text-color-secondary);
opacity: 0.4;
}
.notification-drawer__empty-text {
font-size: 1rem;
font-weight: 600;
color: var(--text-color);
margin: 0;
}
.notification-drawer__empty-sub {
font-size: 0.82rem;
color: var(--text-color-secondary);
margin: 0;
}
.notification-drawer__footer {
display: flex;
justify-content: center;
padding: 0.75rem 1rem;
border-top: 1px solid var(--surface-border);
}
.notification-drawer__history-link {
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: transparent;
border: none;
color: var(--primary-color);
font-size: 0.85rem;
cursor: pointer;
padding: 0;
transition: opacity 0.15s;
}
.notification-drawer__history-link:hover {
opacity: 0.75;
text-decoration: underline;
}
</style>

View File

@@ -0,0 +1,219 @@
<!-- src/components/notifications/NotificationItem.vue -->
<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { formatDistanceToNow } from 'date-fns'
import { ptBR } from 'date-fns/locale'
const props = defineProps({
item: {
type: Object,
required: true
}
})
const emit = defineEmits(['read', 'archive'])
const router = useRouter()
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 typeIcon = computed(() => typeIconMap[props.item.type] || { icon: 'pi-bell', color: 'text-gray-400' })
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 || '??'
)
function handleRowClick () {
const deeplink = props.item.payload?.deeplink
if (deeplink) {
router.push(deeplink)
emit('read', props.item.id)
}
}
function handleMarkRead (e) {
e.stopPropagation()
emit('read', props.item.id)
}
function handleArchive (e) {
e.stopPropagation()
emit('archive', props.item.id)
}
</script>
<template>
<div
class="notification-item"
:class="{ 'notification-item--unread': isUnread }"
@click="handleRowClick"
role="button"
tabindex="0"
@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>
<!-- Avatar -->
<span class="notification-item__avatar" aria-hidden="true">
{{ avatarInitials }}
</span>
<!-- 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>
<!-- Ações -->
<div class="notification-item__actions" @click.stop>
<button
v-if="isUnread"
class="notification-item__action-btn"
title="Marcar como lida"
@click="handleMarkRead"
>
<i class="pi pi-check" />
</button>
<button
class="notification-item__action-btn"
title="Arquivar"
@click="handleArchive"
>
<i class="pi pi-times" />
</button>
</div>
</div>
</template>
<style scoped>
.notification-item {
position: relative;
display: flex;
align-items: flex-start;
gap: 0.625rem;
padding: 0.75rem 1rem;
cursor: pointer;
border-bottom: 1px solid var(--surface-border);
transition: background 0.15s;
}
.notification-item:hover {
background: var(--surface-hover);
}
.notification-item--unread {
background: color-mix(in srgb, var(--primary-color) 6%, transparent);
}
.notification-item--unread:hover {
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
}
.notification-item__dot {
position: absolute;
left: 0.25rem;
top: 50%;
transform: translateY(-50%);
width: 6px;
height: 6px;
border-radius: 50%;
background: #6366f1;
flex-shrink: 0;
}
.notification-item__type-icon {
display: flex;
align-items: center;
padding-top: 0.125rem;
flex-shrink: 0;
font-size: 0.85rem;
}
.notification-item__avatar {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 50%;
background: var(--surface-200);
color: var(--text-color);
font-size: 0.7rem;
font-weight: 700;
flex-shrink: 0;
letter-spacing: 0.05em;
}
.notification-item__content {
flex: 1;
min-width: 0;
}
.notification-item__title {
font-weight: 600;
font-size: 0.85rem;
color: var(--text-color);
margin: 0 0 0.125rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.notification-item__detail {
font-size: 0.8rem;
color: var(--text-color-secondary);
margin: 0 0 0.125rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.notification-item__time {
font-size: 0.72rem;
color: var(--text-color-secondary);
margin: 0;
opacity: 0.75;
}
.notification-item__actions {
display: flex;
align-items: center;
gap: 0.125rem;
opacity: 0;
transition: opacity 0.15s;
}
.notification-item:hover .notification-item__actions {
opacity: 1;
}
.notification-item__action-btn {
width: 1.75rem;
height: 1.75rem;
border-radius: 50%;
border: none;
background: transparent;
color: var(--text-color-secondary);
display: grid;
place-items: center;
cursor: pointer;
font-size: 0.75rem;
transition: background 0.15s, color 0.15s;
}
.notification-item__action-btn:hover {
background: var(--surface-border);
color: var(--text-color);
}
</style>

View File

@@ -0,0 +1,23 @@
// src/composables/useNotifications.js
import { onMounted, onUnmounted } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { useNotificationStore } from '@/stores/notificationStore'
export function useNotifications () {
const store = useNotificationStore()
onMounted(async () => {
const { data, error } = await supabase.auth.getUser()
if (error || !data?.user?.id) return
const ownerId = data.user.id
await store.load(ownerId)
store.subscribeRealtime(ownerId)
})
onUnmounted(() => {
store.unsubscribe()
})
return store
}

View File

@@ -0,0 +1,90 @@
{
"titulo": "Agenda · Gerenciamento de Compromissos",
"conteudo": "<h2>Como funciona a Agenda</h2>\n<p>A Agenda é o coração operacional do sistema. Ela exibe todos os seus compromissos em três modos de visualização — Dia, Semana e Mês — e se divide em três colunas no desktop: calendário mini à esquerda, grade principal no centro e resumo do dia à direita.</p>\n\n<h3>Barra de controles (topbar)</h3>\n<p>A barra superior fica fixada ao topo da página enquanto você rola. Ela contém todos os controles de navegação e filtros:</p>\n<ul>\n <li><strong>Seta / </strong> — navega para o período anterior ou seguinte</li>\n <li><strong>Data pill</strong> — clique na data exibida no centro para abrir o seletor de mês e ir direto para qualquer período</li>\n <li><strong>Hoje</strong> — retorna imediatamente para o dia atual</li>\n <li><strong>Dia / Semana / Mês</strong> — alterna o modo de visualização da grade principal</li>\n <li><strong>24h / 12h / Meu Horário</strong> — define o intervalo de horas exibido. \"Meu Horário\" usa as regras de jornada configuradas</li>\n <li><strong>Apenas Sessões / Tudo</strong> — oculta ou exibe bloqueios e outros tipos de compromisso</li>\n <li><strong>Buscar</strong> — pesquisa por nome de paciente, título ou observação nos eventos do período ou mês inteiro</li>\n <li><strong>+ (Novo compromisso)</strong> — abre o formulário de criação diretamente</li>\n <li><strong>Bloquear</strong> — cria bloqueios de horário, período, dia inteiro ou por feriados</li>\n <li><strong>↻ Recorrências</strong> — navega para a página de gerenciamento de séries recorrentes</li>\n <li><strong>⚙ Configurações</strong> — acessa as configurações da agenda (jornada, duração de sessão, etc.)</li>\n</ul>\n\n<h3>Grade principal (centro)</h3>\n<p>É onde o FullCalendar exibe os eventos. Você pode:</p>\n<ul>\n <li><strong>Clicar em um horário vazio</strong> para criar um novo compromisso naquele slot</li>\n <li><strong>Arrastar um evento</strong> para mover de horário</li>\n <li><strong>Redimensionar um evento</strong> pelas bordas para alterar a duração</li>\n <li><strong>Clicar em um evento</strong> para abrir o dialog de edição</li>\n</ul>\n\n<div style=\"border:1px solid #e2e8f0;border-radius:6px;overflow:hidden;margin:12px 0;\">\n <div style=\"background:#6366f1;color:#fff;padding:6px 10px;font-size:12px;font-weight:700;\">Sessão — 09:00</div>\n <div style=\"padding:8px 10px;font-size:12px;background:#f8fafc;\">\n <div style=\"font-weight:600;\">Leonardo Alves</div>\n <div style=\"opacity:.6;font-size:11px;\">Presencial · 50min</div>\n </div>\n</div>\n<div style=\"border:1px solid #fecaca;border-radius:6px;overflow:hidden;margin:12px 0;\">\n <div style=\"background:#ef4444;color:#fff;padding:6px 10px;font-size:12px;font-weight:700;opacity:.8;\">Bloqueio — 13:00</div>\n <div style=\"padding:8px 10px;font-size:12px;background:#fff5f5;\">\n <div style=\"font-weight:600;\">Almoço</div>\n <div style=\"opacity:.6;font-size:11px;\">60min</div>\n </div>\n</div>\n\n<h3>Coluna esquerda — Calendário mini</h3>\n<p>Exibe o mês atual com indicadores visuais por dia:</p>\n<ul>\n <li><strong>Fundo verde claro</strong> — dia com jornada de trabalho configurada</li>\n <li><strong>Fundo vermelho suave</strong> — dia de folga (sem jornada)</li>\n <li><strong>Fundo vermelho forte</strong> — dia com bloqueio total</li>\n <li><strong>Pontinho colorido</strong> no canto inferior direito do número — dia tem eventos agendados</li>\n</ul>\n<p>Clique em qualquer dia para navegar diretamente até ele na grade principal.</p>\n\n<h3>Coluna direita — Resumo do dia</h3>\n<p>Exibe um painel com quatro contadores (Total, Agendado, Realizado, Faltou) e a lista de sessões do dia atual ordenadas por horário. Cada sessão tem uma barra colorida lateral indicando o status. Clicar em uma sessão navega até ela na grade.</p>\n\n<h3>Atalhos rápidos (coluna direita)</h3>\n<p>Abaixo das sessões ficam cards de acesso rápido a <strong>Pacientes</strong> (lista, novo cadastro e link de cadastro externo) e <strong>Agendador Online</strong> (solicitações e link público para compartilhar com pacientes).</p>\n\n<h3>Aviso de compromissos fora da jornada</h3>\n<p>Quando há eventos agendados fora do seu horário de trabalho e você está no modo \"Meu Horário\", aparece um alerta amarelo com o botão <strong>Ver 24h</strong> para expandir a grade e visualizar todos os eventos.</p>\n\n<h3>Mobile (telas menores)</h3>\n<p>Em telas menores que 1280px, as colunas laterais somem e aparecem dois elementos:</p>\n<ul>\n <li>Um botão <strong>\"Calendário · Sessões de hoje\"</strong> abre um drawer deslizante com todo o conteúdo das colunas esquerda e direita</li>\n <li>O botão <strong>Ações</strong> no canto da topbar concentra todas as funções de navegação e filtro</li>\n</ul>",
"categoria": "Agenda",
"exibir_no_faq": true,
"tipo_acesso": "usuario",
"pagina_path": "/therapist/agenda",
"ordem": 2,
"ativo": true,
"medias": [
{
"tipo": "imagem",
"url": ""
}
],
"_faq_itens": [
{
"pergunta": "Como crio um novo compromisso?",
"resposta": "Você pode criar de três formas: clique no botão <strong>+</strong> na topbar para abrir o formulário com os dados em branco; clique em um horário vazio na grade do calendário para criar já com o horário pré-preenchido; ou selecione um intervalo arrastando na grade para definir início e fim automaticamente.",
"ordem": 0,
"ativo": true
},
{
"pergunta": "Como movo ou altero a duração de uma sessão?",
"resposta": "Diretamente na grade: <strong>arraste o evento</strong> para mover de horário ou dia. Para alterar a duração, <strong>arraste a borda inferior</strong> do evento. Ao soltar, o sistema salva automaticamente. Você também pode editar clicando no evento e alterando os campos no dialog.",
"ordem": 1,
"ativo": true
},
{
"pergunta": "O que significa o fundo colorido nos dias do mini calendário?",
"resposta": "Verde claro indica dia com jornada de trabalho configurada. Vermelho suave indica folga (sem jornada naquele dia da semana). Vermelho forte indica que o dia está bloqueado por inteiro — sessões não são permitidas. O pontinho no canto inferior direito do número indica que há eventos agendados naquele dia.",
"ordem": 2,
"ativo": true
},
{
"pergunta": "O que é o modo 'Meu Horário' e como configurá-lo?",
"resposta": "O modo <strong>Meu Horário</strong> faz a grade exibir apenas o intervalo de horas da sua jornada de trabalho — por exemplo, de 08h às 18h — ocultando os horários fora do expediente. Para configurar sua jornada, clique no botão <strong>⚙ Configurações</strong> na topbar. Se houver eventos fora do horário configurado, um aviso amarelo aparece com o botão <strong>Ver 24h</strong>.",
"ordem": 3,
"ativo": true
},
{
"pergunta": "Como bloquear um horário ou dia inteiro?",
"resposta": "Clique no botão <strong>Bloquear</strong> (vermelho) na topbar e escolha o tipo: <em>Por Horário</em> bloqueia um intervalo específico; <em>Por Período</em> bloqueia vários dias seguidos; <em>Por Dia</em> bloqueia o dia inteiro impedindo agendamentos; <em>Por Feriados</em> gera bloqueios automáticos nos feriados dos próximos 30 dias que ainda estiverem abertos.",
"ordem": 4,
"ativo": true
},
{
"pergunta": "O que é o sino de feriados e quando ele aparece?",
"resposta": "O sino 🔔 aparece na topbar quando há feriados nos próximos 30 dias em dias de trabalho. Se estiver vermelho com número, indica feriados sem bloqueio — ou seja, pacientes poderiam agendar nesses dias. Clique no sino para ver a lista e bloquear individualmente ou todos de uma vez.",
"ordem": 5,
"ativo": true
},
{
"pergunta": "Como funciona a busca na agenda?",
"resposta": "Digite o nome do paciente, título do compromisso ou uma observação no campo de busca da topbar. A busca pode ser feita no <em>Período atual</em> (eventos visíveis na grade) ou no <em>Mês inteiro</em>. Os resultados aparecem em uma lista acima do calendário. Clique em um resultado para navegar até o evento. A busca ignora maiúsculas e acentos.",
"ordem": 6,
"ativo": true
},
{
"pergunta": "Como gerencio sessões recorrentes?",
"resposta": "Ao criar ou editar um compromisso, você pode ativá-lo como recorrente definindo frequência e número de sessões. Eventos recorrentes aparecem com o símbolo <strong>↻</strong> na lista do dia. Para gerenciar todas as séries ativas — ver quantas sessões foram realizadas, editar ou encerrar uma série — clique no botão <strong>↻ Recorrências</strong> na topbar.",
"ordem": 7,
"ativo": true
},
{
"pergunta": "O que acontece quando aceito uma solicitação pelo Agendador Online?",
"resposta": "Aceitar uma solicitação pelo card de atalho muda o status para <em>autorizado</em>, mas ainda não cria o evento na agenda. Para concluir o agendamento, acesse <strong>Agendador → Solicitações</strong> (<code>/therapist/agendador/solicitacoes</code>), onde você define o horário exato e confirma o agendamento completo.",
"ordem": 8,
"ativo": true
},
{
"pergunta": "Como compartilho o link do agendador com meus pacientes?",
"resposta": "Na coluna direita da agenda, no card <strong>Agendador Online</strong>, há um campo com o link público já gerado e um botão de copiar ao lado. Cole esse link no WhatsApp, e-mail ou onde preferir. O paciente acessa uma página pública onde escolhe o horário disponível e envia a solicitação. Se o link não aparecer, verifique se o agendador está ativado em Configurações.",
"ordem": 9,
"ativo": true
},
{
"pergunta": "Como adiciono um paciente direto pela agenda?",
"resposta": "No card <strong>Pacientes</strong> da coluna direita, clique em <strong>Novo paciente</strong> para abrir o formulário de cadastro manual. Para compartilhar o formulário de auto-cadastro com o próprio paciente, copie o <strong>Link de cadastro externo</strong> exibido no mesmo card e envie para ele.",
"ordem": 10,
"ativo": true
},
{
"pergunta": "No mobile, como acesso o mini calendário e as sessões do dia?",
"resposta": "Em telas menores que 1280px, as colunas laterais ficam ocultas para dar mais espaço ao calendário. Toque no botão <strong>Calendário · Sessões de hoje</strong> que aparece acima da grade — ele abre um drawer deslizante com o mini calendário, a jornada do dia, os feriados próximos, os contadores do dia e a lista de sessões. Tocar em uma sessão na lista fecha o drawer e navega até o evento na grade.",
"ordem": 11,
"ativo": true
}
]
}

File diff suppressed because one or more lines are too long

View File

@@ -8,6 +8,7 @@
:style="{ width: '1000px', maxWidth: '96vw' }"
:breakpoints="{ '960px': '96vw', '640px': '98vw' }"
class="agenda-event-composer"
pt:mask:class="backdrop-blur-xs"
>
<template #header>
<div class="w-full flex items-center justify-between gap-3">
@@ -24,47 +25,7 @@
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<Button
v-if="step === 2 && !isEdit && allowBack"
label="Voltar"
icon="pi pi-arrow-left"
severity="secondary"
outlined
size="small"
class="rounded-full"
@click="goBack"
/>
<Button
v-if="step === 2 && isEdit && hasSerie"
label="Encerrar série"
icon="pi pi-trash"
severity="danger"
outlined
size="small"
class="rounded-full text-xs h-8"
@click="onEncerrarSerie"
/>
<Button
v-if="step === 2 && isEdit && !hasSerie"
icon="pi pi-trash"
severity="danger"
outlined
size="small"
class="rounded-full h-9 w-9"
v-tooltip.bottom="'Remover'"
@click="onDelete"
/>
<Button
v-if="step === 2"
label="Salvar"
icon="pi pi-check"
size="small"
class="rounded-full"
:disabled="!canSave"
@click="onSave"
/>
</div>
<!-- actions moved to footer -->
</div>
</template>
@@ -924,7 +885,7 @@
:style="{ width: '560px', maxWidth: '96vw' }"
:breakpoints="{ '640px': '98vw' }"
>
<div class="flex flex-col gap-4 p-1">
<div class="flex flex-col gap-4">
<!-- Data -->
<div>
<label class="block text-sm font-medium mb-2">Data</label>
@@ -1060,6 +1021,53 @@
</template>
</Dialog>
<!-- Footer -->
<template v-if="step === 2" #footer>
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<Button
v-if="!isEdit && allowBack"
label="Voltar"
icon="pi pi-arrow-left"
severity="secondary"
outlined
size="small"
class="rounded-full"
@click="goBack"
/>
<Button
v-if="isEdit && hasSerie"
label="Encerrar série"
icon="pi pi-trash"
severity="danger"
outlined
size="small"
class="rounded-full text-xs h-8"
@click="onEncerrarSerie"
/>
<Button
v-if="isEdit && !hasSerie"
icon="pi pi-trash"
severity="danger"
outlined
size="small"
class="rounded-full h-9 w-9"
v-tooltip.bottom="'Remover'"
@click="onDelete"
/>
</div>
<Button
label="Salvar"
icon="pi pi-check"
size="small"
class="rounded-full"
:disabled="!canSave"
@click="onSave"
/>
</div>
</template>
<!-- -->
<!-- Cadastro Rápido de Paciente -->
<!-- -->
@@ -2662,7 +2670,7 @@ function statusSeverity (v) {
.commitment-card {
width: 100%; text-align: left;
border: 1px solid var(--surface-border);
border-radius: 1.25rem;
border-radius: 6px;
background: color-mix(in srgb, var(--surface-card), transparent 10%);
transition: box-shadow .12s ease, transform .12s ease, border-color .12s;
overflow: hidden;
@@ -2704,7 +2712,7 @@ function statusSeverity (v) {
/* ── paciente hero ──────────────────────────────── */
.patient-hero {
border: 1.5px solid var(--surface-border);
border-radius: 1.25rem;
border-radius: 6px;
overflow: hidden;
background: color-mix(in srgb, var(--surface-card), transparent 10%);
}
@@ -2736,7 +2744,7 @@ function statusSeverity (v) {
/* Card genérico para seções (data/horário, etc.) */
.field-card {
border-radius: 1rem;
border-radius: 6px;
border: 1px solid var(--surface-border);
background: var(--surface-card);
overflow: hidden;
@@ -2848,7 +2856,7 @@ function statusSeverity (v) {
}
.side-card {
border: 1px solid var(--surface-border);
border-radius: 1.25rem;
border-radius: 6px;
padding: .9rem 1rem;
background: color-mix(in srgb, var(--surface-card), transparent 10%);
}
@@ -2879,7 +2887,7 @@ function statusSeverity (v) {
/* ── serie banner ───────────────────────────────── */
.serie-banner {
border-radius: 1rem;
border-radius: 6px;
padding: .75rem .9rem;
background: color-mix(in srgb, var(--blue-500, #3b82f6) 8%, var(--surface-card));
border: 1px solid color-mix(in srgb, var(--blue-400, #60a5fa) 30%, transparent);
@@ -2922,7 +2930,7 @@ function statusSeverity (v) {
.recorrencia-preview {
display: flex; align-items: center; gap: .5rem;
padding: .5rem .75rem;
border-radius: .75rem;
border-radius: 6px;
background: color-mix(in srgb, var(--p-primary-500) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--p-primary-400) 25%, transparent);
}
@@ -2994,7 +3002,7 @@ function statusSeverity (v) {
align-items: center;
gap: 12px;
padding: 10px 16px;
border-radius: 0.75rem;
border-radius: 6px;
background: color-mix(in srgb, var(--primary-500, #6366f1) 8%, var(--surface-card));
border: 1px solid color-mix(in srgb, var(--primary-400, #818cf8) 25%, transparent);
}
@@ -3125,7 +3133,7 @@ function statusSeverity (v) {
.rec-startdate-row {
display: flex; align-items: center; justify-content: space-between; gap: .5rem;
padding: .45rem .65rem;
border-radius: .75rem;
border-radius: 6px;
background: color-mix(in srgb, var(--surface-ground), transparent 30%);
border: 1px solid var(--surface-border);
}
@@ -3177,7 +3185,7 @@ function statusSeverity (v) {
/* ── personalizar box ───────────────────────────── */
.personalizar-box {
border: 1px solid var(--surface-border);
border-radius: .85rem;
border-radius: 6px;
padding: .75rem;
background: color-mix(in srgb, var(--surface-ground), transparent 40%);
display: flex;
@@ -3190,7 +3198,7 @@ function statusSeverity (v) {
.patient-item {
width: 100%; display: flex; align-items: center; justify-content: space-between;
gap: 1rem; text-align: left; padding: .85rem .95rem;
border: 1px solid var(--surface-border); border-radius: 1.25rem;
border: 1px solid var(--surface-border); border-radius: 6px;
background: color-mix(in srgb, var(--surface-card), transparent 10%);
transition: box-shadow .12s ease, transform .12s ease;
}
@@ -3199,7 +3207,7 @@ function statusSeverity (v) {
/* ── serie panel (Recorrências Aplicadas) ─────────── */
.serie-panel {
border: 1px solid var(--surface-border);
border-radius: 1.1rem;
border-radius: 6px;
overflow: hidden;
}
.serie-panel__header {
@@ -3273,7 +3281,7 @@ function statusSeverity (v) {
flex-direction: column;
gap: .35rem;
border: 1px solid var(--surface-border);
border-radius: .5rem;
border-radius: 6px;
padding: .5rem;
}
.commitment-item-row {

View File

@@ -6,14 +6,15 @@
:closable="!saving"
:dismissableMask="!saving"
class="dc-dialog w-[96vw] max-w-2xl"
:pt="{ content: { class: 'p-0' }, header: { class: 'pb-0' } }"
:pt="{ content: { class: 'p-0' }, header: { class: 'pb-0' }, footer: { class: 'pt-0' } }"
pt:mask:class="backdrop-blur-xs"
>
<template #header>
<div class="flex w-full items-center justify-between gap-3 px-1">
<div class="flex items-center gap-3 min-w-0">
<!-- Dot de cor -->
<span
class="dc-header-dot shrink-0"
class="shrink-0 w-3.5 h-3.5 rounded-full border-2 border-white/30 shadow-[0_0_0_3px_rgba(0,0,0,0.08)] transition-colors duration-200"
:style="{ backgroundColor: previewBgColor }"
/>
<div class="min-w-0">
@@ -37,33 +38,17 @@
v-tooltip.top="'Excluir'"
@click="emitDelete"
/>
<Button
label="Cancelar"
severity="secondary"
outlined
class="rounded-full"
:disabled="saving"
@click="close"
/>
<Button
label="Salvar"
icon="pi pi-check"
class="rounded-full"
:loading="saving"
:disabled="!canSubmit"
@click="submit"
/>
</div>
</div>
</template>
<!-- Banner de preview -->
<div
class="dc-banner"
class="h-[72px] flex items-center justify-center transition-colors duration-[250ms] rounded-[6px]"
:style="{ backgroundColor: previewBgColor }"
>
<span
class="dc-banner__pill"
class="text-base font-bold tracking-[-0.02em] px-[1.1rem] py-[0.35rem] bg-black/15 rounded-full backdrop-blur-sm transition-colors duration-200"
:style="{ color: form.text_color || '#ffffff' }"
>
{{ form.name || 'Nome do compromisso' }}
@@ -71,7 +56,7 @@
</div>
<!-- Corpo -->
<div class="flex flex-col gap-4 p-4">
<div class="flex flex-col gap-4 mt-4">
<!-- Nome + Ativo -->
<div class="flex items-center gap-3">
@@ -91,64 +76,11 @@
<label for="cr-nome">Nome *</label>
</FloatLabel>
</div>
<div class="flex items-center gap-2 shrink-0 pt-1">
<!-- Toggle Ativo -->
<div class="shrink-0 flex items-center gap-2">
<span class="text-sm font-medium">Ativo</span>
<InputSwitch v-model="form.active" :disabled="saving || isActiveLocked" />
</div>
</div>
<!-- Seção Cor -->
<div class="dc-section">
<div class="dc-section__label">Cor</div>
<!-- Paleta predefinida -->
<div class="dc-palette">
<button
v-for="p in presetColors"
:key="p.bg"
class="dc-swatch"
:class="{ 'dc-swatch--active': form.bg_color === p.bg }"
:style="{ backgroundColor: `#${p.bg}` }"
:title="p.name"
:disabled="saving || isEditLocked"
@click="applyPreset(p)"
>
<i v-if="form.bg_color === p.bg" class="pi pi-check dc-swatch__check" />
</button>
<!-- Custom ColorPicker -->
<div class="dc-swatch dc-swatch--custom" title="Cor personalizada">
<ColorPicker
v-model="form.bg_color"
format="hex"
:disabled="saving || isEditLocked"
/>
</div>
</div>
<!-- Texto -->
<div class="flex items-center gap-3 mt-2">
<span class="text-xs font-medium opacity-60 uppercase tracking-wide">Texto</span>
<div class="flex gap-1">
<button
class="dc-text-opt"
:class="{ 'dc-text-opt--active': form.text_color === '#ffffff' }"
:disabled="saving || isEditLocked"
@click="form.text_color = '#ffffff'"
>
<span class="dc-text-opt__dot" style="background:#ffffff; border: 1px solid #ccc;" />
Branco
</button>
<button
class="dc-text-opt"
:class="{ 'dc-text-opt--active': form.text_color === '#000000' }"
:disabled="saving || isEditLocked"
@click="form.text_color = '#000000'"
>
<span class="dc-text-opt__dot" style="background:#000000;" />
Preto
</button>
</div>
<ToggleSwitch v-model="form.active" :disabled="saving || isActiveLocked" />
</div>
</div>
@@ -166,10 +98,83 @@
<label for="cr-descricao">Descrição</label>
</FloatLabel>
<!-- Seção Cor -->
<div class="border border-[var(--surface-border)] rounded-[6px] bg-[var(--surface-card)] p-4">
<div class="text-[1rem] font-bold uppercase tracking-[0.06em] opacity-45 mb-3">Cor</div>
<!-- Paleta predefinida -->
<div class="flex flex-wrap gap-[0.45rem]">
<button
v-for="p in presetColors"
:key="p.bg"
class="w-7 h-7 rounded-full grid place-items-center cursor-pointer relative transition-transform duration-[120ms] ease-in-out hover:scale-[1.18] hover:shadow-[0_3px_10px_rgba(0,0,0,0.2)] disabled:cursor-not-allowed"
:class="form.bg_color === p.bg ? 'shadow-[0_0_0_2px_var(--text-color)] border-2 border-[var(--surface-0,#fff)]' : 'border-0'"
:style="{ backgroundColor: `#${p.bg}` }"
:title="p.name"
:disabled="saving || isEditLocked"
@click="applyPreset(p)"
>
<i v-if="form.bg_color === p.bg" class="pi pi-check !text-[13px] text-white font-black p-1" />
</button>
<!-- Custom ColorPicker -->
<div
class="w-7 h-7 rounded-full grid place-items-center cursor-pointer overflow-hidden relative transition-transform duration-[120ms] ease-in-out hover:scale-[1.18] hover:shadow-[0_3px_10px_rgba(0,0,0,0.2)]"
:class="isCustomColor ? 'shadow-[0_0_0_2px_var(--text-color)]' : ''"
style="background: conic-gradient(red, yellow, lime, cyan, blue, magenta, red);"
title="Cor personalizada"
>
<i
v-if="isCustomColor"
class="pi pi-check !text-[13px] text-white font-black absolute z-10 pointer-events-none drop-shadow-[0_1px_2px_rgba(0,0,0,0.6)]"
/>
<ColorPicker
v-model="form.bg_color"
format="hex"
:disabled="saving || isEditLocked"
class="absolute inset-0 [&_.p-colorpicker-preview]:w-full [&_.p-colorpicker-preview]:h-full [&_.p-colorpicker-preview]:border-0 [&_.p-colorpicker-preview]:rounded-full [&_.p-colorpicker-preview]:opacity-0"
/>
</div>
</div>
<!-- Texto -->
<div class="flex items-center gap-3 mt-2">
<span class="text-xs font-medium opacity-60 uppercase tracking-wide">Texto</span>
<div class="flex gap-1">
<button
class="inline-flex items-center gap-[0.4rem] px-3 py-1 rounded-full border text-sm font-medium cursor-pointer transition-colors duration-[120ms] disabled:cursor-not-allowed"
:class="
form.text_color === '#ffffff'
? 'bg-[var(--surface-section,var(--surface-100))] border-[var(--primary-color)] text-[var(--primary-color)] font-bold'
: 'bg-transparent border-[var(--surface-border)] text-[var(--text-color)] hover:bg-[var(--surface-hover)]'
"
:disabled="saving || isEditLocked"
@click="form.text_color = '#ffffff'"
>
<span class="w-2.5 h-2.5 rounded-full inline-block border border-[#ccc]" style="background:#ffffff;" />
Branco
</button>
<button
class="inline-flex items-center gap-[0.4rem] px-3 py-1 rounded-full border text-sm font-medium cursor-pointer transition-colors duration-[120ms] disabled:cursor-not-allowed"
:class="
form.text_color === '#000000'
? 'bg-[var(--surface-section,var(--surface-100))] border-[var(--primary-color)] text-[var(--primary-color)] font-bold'
: 'bg-transparent border-[var(--surface-border)] text-[var(--text-color)] hover:bg-[var(--surface-hover)]'
"
:disabled="saving || isEditLocked"
@click="form.text_color = '#000000'"
>
<span class="w-2.5 h-2.5 rounded-full inline-block" style="background:#000000;" />
Preto
</button>
</div>
</div>
</div>
<!-- Campos adicionais -->
<div class="dc-section">
<div class="border border-[var(--surface-border)] rounded-[6px] bg-[var(--surface-card)] p-4">
<div class="flex items-center justify-between gap-2 mb-3">
<div class="dc-section__label mb-0">Campos adicionais</div>
<div class="text-[1rem] font-bold uppercase tracking-[0.06em] opacity-45">Campos adicionais</div>
<Button
label="Adicionar campo"
icon="pi pi-plus"
@@ -190,7 +195,7 @@
<div
v-for="(f, idx) in form.fields"
:key="f.key"
class="grid grid-cols-1 gap-2 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-0)] p-3 md:grid-cols-12"
class="grid grid-cols-1 gap-2 rounded-[6px] border border-[var(--surface-border)] bg-[var(--surface-0)] p-3 md:grid-cols-12"
>
<div class="md:col-span-6">
<FloatLabel variant="on">
@@ -233,14 +238,32 @@
@click="removeField(idx)"
/>
</div>
</div>
</div>
</div>
</div>
<div class="md:col-span-12 text-xs opacity-40 font-mono">
key: {{ f.key }}
</div>
</div>
</div>
</div>
<!-- Footer com botões Cancelar / Salvar -->
<template #footer>
<div class="flex items-center justify-end gap-2 pt-2">
<Button
label="Cancelar"
severity="secondary"
outlined
class="rounded-full"
:disabled="saving"
@click="close"
/>
<Button
label="Salvar"
icon="pi pi-check"
class="rounded-full"
:loading="saving"
:disabled="!canSubmit"
@click="submit"
/>
</div>
</template>
</Dialog>
</template>
@@ -249,8 +272,9 @@ import { computed, reactive, watch } from 'vue'
import Textarea from 'primevue/textarea'
import Dropdown from 'primevue/dropdown'
import InputSwitch from 'primevue/inputswitch'
import ColorPicker from 'primevue/colorpicker'
import ToggleSwitch from 'primevue/toggleswitch'
const props = defineProps({
modelValue: { type: Boolean, default: false },
@@ -281,6 +305,16 @@ const presetColors = [
{ bg: '292524', text: '#ffffff', name: 'Escuro' },
]
// bg_colors dos presets (sem #) para comparação
const presetBgValues = presetColors.map(p => p.bg)
// Verdadeiro quando a cor atual não bate com nenhum preset
const isCustomColor = computed(() => {
if (!form.bg_color) return false
const clean = String(form.bg_color).replace('#', '').toLowerCase()
return !presetBgValues.includes(clean)
})
function applyPreset (p) {
if (props.saving) return
form.bg_color = p.bg
@@ -350,9 +384,9 @@ function hydrate () {
}
}
const isActiveLocked = computed(() => !!form.locked) // nativo+locked → sempre ativo, nunca pode desativar
const isEditLocked = computed(() => false) // edição sempre permitida
const isFieldsLocked = computed(() => false) // campos sempre editáveis
const isActiveLocked = computed(() => !!form.locked)
const isEditLocked = computed(() => false)
const isFieldsLocked = computed(() => false)
const canDelete = computed(() => !form.native)
const canSubmit = computed(() => {
@@ -408,13 +442,11 @@ function removeField (idx) {
}
function syncKey (field) {
// se o user renomear, a key acompanha (sem quebrar: simples por enquanto)
const next = makeKey(field.label)
field.key = next
}
function makeKey (label) {
const k = String(label || '')
.trim()
.toLowerCase()
@@ -425,99 +457,3 @@ function makeKey (label) {
return k
}
</script>
<style scoped>
/* ── Header ─────────────────────────────── */
.dc-header-dot {
width: 14px; height: 14px;
border-radius: 50%;
border: 2px solid rgba(255,255,255,0.3);
box-shadow: 0 0 0 3px rgba(0,0,0,0.08);
transition: background-color 0.2s ease;
}
/* ── Banner de preview ───────────────────── */
.dc-banner {
height: 72px;
display: flex; align-items: center; justify-content: center;
transition: background-color 0.25s ease;
}
.dc-banner__pill {
font-size: 1rem; font-weight: 700; letter-spacing: -0.02em;
padding: 0.35rem 1.1rem;
background: rgba(0,0,0,0.15);
border-radius: 999px;
backdrop-filter: blur(4px);
transition: color 0.2s ease;
}
/* ── Section ─────────────────────────────── */
.dc-section {
border: 1px solid var(--surface-border);
border-radius: 1.25rem;
background: var(--surface-card);
padding: 1rem;
}
.dc-section__label {
font-size: 0.7rem; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.06em;
opacity: 0.45; margin-bottom: 0.75rem;
}
/* ── Paleta ──────────────────────────────── */
.dc-palette {
display: flex; flex-wrap: wrap; gap: 0.45rem;
}
.dc-swatch {
width: 28px; height: 28px;
border-radius: 50%;
border: 2px solid transparent;
display: grid; place-items: center;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease, border-color 0.12s ease;
position: relative;
}
.dc-swatch:hover:not(:disabled) {
transform: scale(1.18);
box-shadow: 0 3px 10px rgba(0,0,0,0.2);
}
.dc-swatch--active {
border-color: var(--surface-0, #fff);
box-shadow: 0 0 0 2px var(--text-color);
}
.dc-swatch__check {
font-size: 0.6rem; color: #fff; font-weight: 900;
}
.dc-swatch--custom {
background: conic-gradient(red, yellow, lime, cyan, blue, magenta, red);
overflow: hidden;
}
.dc-swatch--custom :deep(.p-colorpicker-preview) {
width: 100%; height: 100%;
border: none; border-radius: 50%;
opacity: 0;
}
/* ── Texto toggle ────────────────────────── */
.dc-text-opt {
display: inline-flex; align-items: center; gap: 0.4rem;
padding: 0.25rem 0.75rem;
border-radius: 999px;
border: 1px solid var(--surface-border);
font-size: 0.8rem; font-weight: 500;
cursor: pointer;
color: var(--text-color);
background: transparent;
transition: background 0.12s, border-color 0.12s;
}
.dc-text-opt:hover:not(:disabled) { background: var(--surface-hover); }
.dc-text-opt--active {
background: var(--surface-section, var(--surface-100));
border-color: var(--primary-color);
color: var(--primary-color);
font-weight: 700;
}
.dc-text-opt__dot {
width: 10px; height: 10px; border-radius: 50%; display: inline-block;
}
</style>

View File

@@ -3,141 +3,102 @@
<Toast />
<ConfirmDialog />
<!-- Sentinel para detecção de sticky -->
<!-- Sentinel -->
<div ref="headerSentinelRef" class="ag-sentinel" />
<!-- Hero Header sticky -->
<div ref="headerEl" class="ag-hero mx-3 md:mx-5 mb-3" :class="{ 'ag-hero--stuck': headerStuck }">
<!-- Blobs decorativos -->
<div class="ag-hero__blobs" aria-hidden="true">
<div class="ag-hero__blob ag-hero__blob--1" />
<div class="ag-hero__blob ag-hero__blob--2" />
<div class="ag-hero__blob ag-hero__blob--3" />
<!-- Topbar compacta sticky -->
<div ref="headerEl" class="ag-topbar mx-3 md:mx-4 mb-3" :class="{ 'ag-topbar--stuck': headerStuck }">
<div class="ag-topbar__blobs" aria-hidden="true">
<div class="ag-topbar__blob ag-topbar__blob--1" />
<div class="ag-topbar__blob ag-topbar__blob--2" />
</div>
<div class="ag-topbar__inner">
<!-- Linha 1: brand + controles -->
<div class="ag-hero__row1">
<!-- Brand -->
<div class="ag-hero__brand">
<div class="ag-hero__icon">
<i class="pi pi-calendar text-lg" />
</div>
<div class="min-w-0">
<div class="ag-hero__title">Agenda</div>
<div class="ag-hero__sub">{{ subtitleText }}</div>
<div class="ag-topbar__brand">
<div class="ag-topbar__icon"><i class="pi pi-calendar text-base" /></div>
<div class="min-w-0 hidden xl:block">
<div class="ag-topbar__title">Agenda · Clínica</div>
<div class="ag-topbar__sub">{{ subtitleText }}</div>
</div>
</div>
<!-- Controles desktop (1200px) -->
<div class="ag-hero__desktop-controls">
<!-- Navegação (sempre visível) -->
<div class="flex items-center gap-1">
<Button label="Hoje" severity="secondary" outlined size="small" class="rounded-full" @click="goToday" />
<!-- Navegação -->
<div class="ag-topbar__nav">
<Button label="Hoje" severity="secondary" outlined size="small" class="rounded-full hidden lg:flex" @click="goToday" />
<Button icon="pi pi-chevron-left" severity="secondary" outlined class="h-8 w-8 rounded-full" @click="goPrev" />
<span class="ag-topbar__date-pill" @click="toggleMonthPicker">
<i class="pi pi-calendar text-xs opacity-60" />
{{ subtitleText }}
</span>
<Button icon="pi pi-chevron-right" severity="secondary" outlined class="h-8 w-8 rounded-full" @click="goNext" />
<Button :label="visibleTitle" icon="pi pi-calendar" severity="secondary" outlined size="small" class="rounded-full" @click="toggleMonthPicker" />
</div>
<!-- Busca (oculta quando colado) -->
<div v-if="!headerStuck" class="w-[260px]">
<!-- Filtros (desktop) -->
<div class="ag-topbar__filters hidden xl:flex items-center gap-1.5">
<SelectButton v-model="calendarView" :options="viewOptions" optionLabel="label" optionValue="value" :allowEmpty="false" size="small" />
<SelectButton v-model="timeMode" :options="timeModeOptions" optionLabel="label" optionValue="value" :allowEmpty="false" size="small" />
<SelectButton v-model="onlySessions" :options="onlySessionsOptions" optionLabel="label" optionValue="value" :allowEmpty="false" size="small" />
<SelectButton v-model="mosaicMode" :options="mosaicModeOptions" optionLabel="label" optionValue="value" :allowEmpty="false" size="small" />
</div>
<!-- Ações -->
<div class="ag-topbar__actions">
<!-- Busca desktop -->
<div class="hidden xl:block w-44">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-search" />
<InputText id="agendaSearch" v-model="search" class="w-full" autocomplete="off" @keyup.enter="openSearchModal" />
</IconField>
<label for="agendaSearch">Buscar paciente...</label>
<label for="agendaSearch">Buscar...</label>
</FloatLabel>
</div>
<!-- Ações rápidas -->
<div class="flex items-center gap-1">
<!-- Badge: feriados próximos sem bloqueio -->
<!-- Sino feriados -->
<div v-if="feriadosTodosProximos.length" class="relative">
<Button
icon="pi pi-bell"
:severity="feriadosSemBloqueio.length ? 'danger' : 'secondary'"
outlined
class="h-9 w-9 rounded-full"
v-tooltip.bottom="feriadosSemBloqueio.length
? `${feriadosSemBloqueio.length} feriado(s) sem bloqueio nos próximos 30 dias`
: `${feriadosTodosProximos.length} feriado(s) gerenciados`"
@click="feriadosAlertaOpen = true"
/>
<Button icon="pi pi-bell" :severity="feriadosSemBloqueio.length ? 'danger' : 'secondary'" outlined class="h-9 w-9 rounded-full" @click="feriadosAlertaOpen = true" />
<span v-if="feriadosSemBloqueio.length" class="ag-badge">{{ feriadosSemBloqueio.length }}</span>
</div>
<Button v-if="!headerStuck" label="Bloquear" icon="pi pi-lock" size="small" class="rounded-full" severity="danger" outlined @click="(e) => blockMenuRef.toggle(e)" />
<Menu ref="blockMenuRef" :model="blockMenuItems" :popup="true" />
<Button icon="pi pi-plus" class="h-9 w-9 rounded-full" title="Novo compromisso" @click="onCreateFromButton" />
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Recarregar" @click="refetch" />
<Button icon="pi pi-sync" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Recorrências" @click="goRecorrencias" />
<Button icon="pi pi-cog" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Configurações" @click="goSettings" />
</div>
</div>
<!-- Menu mobile (<1200px) -->
<div class="ag-hero__mobile-controls">
<!-- Sino: feriados próximos -->
<div v-if="feriadosTodosProximos.length" class="relative">
<Button
icon="pi pi-bell"
:severity="feriadosSemBloqueio.length ? 'danger' : 'secondary'"
outlined
class="h-9 w-9 rounded-full"
@click="feriadosAlertaOpen = true"
/>
<span v-if="feriadosSemBloqueio.length" class="ag-badge">{{ feriadosSemBloqueio.length }}</span>
</div>
<Button icon="pi pi-plus" class="h-9 w-9 rounded-full" title="Novo compromisso" @click="onCreateFromButton" />
<!-- Mobile -->
<div class="flex xl:hidden items-center gap-1">
<Button icon="pi pi-search" severity="secondary" outlined class="h-9 w-9 rounded-full" @click="searchModalOpen = true" />
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => headerMenuRef.toggle(e)" />
<Menu ref="headerMenuRef" :model="headerMenuItems" :popup="true" />
</div>
</div>
<!-- Divisor -->
<Divider class="ag-hero__divider my-2" />
<!-- Linha 2: filtros -->
<div class="ag-hero__row2">
<div class="flex flex-wrap items-center gap-2">
<div class="flex items-center gap-2">
<span class="text-sm opacity-60">Exibir:</span>
<SelectButton v-model="onlySessions" :options="onlySessionsOptions" optionLabel="label" optionValue="value" :allowEmpty="false" />
<!-- Desktop: extras -->
<div class="hidden xl:flex items-center gap-1">
<Button label="Bloquear" icon="pi pi-lock" size="small" class="rounded-full" severity="danger" outlined @click="(e) => blockMenuRef.toggle(e)" />
<Menu ref="blockMenuRef" :model="blockMenuItems" :popup="true" />
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" @click="refetch" />
<Button icon="pi pi-sync" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Recorrências" @click="goRecorrencias" />
<Button icon="pi pi-cog" severity="secondary" outlined class="h-9 w-9 rounded-full" @click="goSettings" />
</div>
<SelectButton v-model="calendarView" :options="viewOptions" optionLabel="label" optionValue="value" :allowEmpty="false" />
<SelectButton v-model="timeMode" :options="timeModeOptions" optionLabel="label" optionValue="value" :allowEmpty="false" />
</div>
<div v-if="searchTrim" class="flex items-center gap-2">
<Tag :value="`Busca: ${searchTrim}`" severity="secondary" />
<Button label="Limpar" icon="pi pi-times" text severity="secondary" size="small" @click="clearSearch" />
<Button v-if="searchResults.length" :label="`Ver resultados (${searchResults.length})`" icon="pi pi-list" severity="secondary" outlined size="small" class="rounded-full" @click="openSearchModal" />
</div>
</div>
</div>
<!-- Aviso: eventos fora da jornada de trabalho -->
<div
v-if="hasEventsOutsideWorkHours"
class="mx-3 md:mx-5 mb-3 rounded-2xl p-3"
style="background: color-mix(in srgb, var(--yellow-400, #facc15) 10%, var(--surface-card)); border: 1px solid color-mix(in srgb, var(--yellow-400, #facc15) 35%, transparent);"
>
<div class="flex items-start gap-3">
<i class="pi pi-exclamation-triangle shrink-0 mt-0.5" style="color: var(--yellow-600, #ca8a04);" />
<div class="min-w-0 flex-1">
<div class="font-semibold text-sm">Existem compromissos fora da jornada de trabalho</div>
<div class="text-xs opacity-70 mt-0.5">A exibição foi ajustada para <b>24h</b> automaticamente. Caso queira visualizar com horário reduzido (e aceite não ver alguns compromissos), escolha abaixo:</div>
<div class="flex gap-2 mt-2 flex-wrap">
<!-- Aviso: fora da jornada -->
<div v-if="hasEventsOutsideWorkHours" class="mx-3 md:mx-4 mb-3 rounded-[6px] p-3" style="background:color-mix(in srgb,var(--yellow-400,#facc15) 10%,var(--surface-card));border:1px solid color-mix(in srgb,var(--yellow-400,#facc15) 35%,transparent);">
<div class="flex items-center gap-3">
<i class="pi pi-exclamation-triangle shrink-0" style="color:var(--yellow-600,#ca8a04);" />
<div class="font-semibold text-sm flex-1">Compromissos fora da jornada</div>
<div class="flex gap-1 shrink-0">
<Button label="Meu Horário" size="small" severity="secondary" outlined class="rounded-full" @click="timeMode = 'my'" />
<Button label="12h" size="small" severity="secondary" outlined class="rounded-full" @click="timeMode = '12'" />
</div>
</div>
</div>
</div>
<!-- Layout: 2 colunas -->
<div class="flex flex-col lg:flex-row gap-4 px-3 md:px-5 pb-5">
<!-- Coluna maior -->
<div class="w-full lg:flex-1 lg:order-2 min-w-0">
<div class="overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] shadow-sm">
<!-- Layout 2 colunas: calendário + sidebar -->
<div class="flex flex-col xl:flex-row gap-3 px-3 md:px-4 pb-5 items-start">
<!-- Col centro: calendário mosaic -->
<div class="w-full xl:flex-1 min-w-0">
<div class="ag-cal-wrap">
<div class="p-2">
<AgendaClinicMosaic
ref="calendarRef"
@@ -169,12 +130,9 @@
</div>
<!-- Sidebar -->
<div class="w-full lg:basis-[24%] lg:max-w-[24%] lg:order-1">
<!-- Resultados (DESKTOP) -->
<div
v-if="searchTrim"
class="hidden sm:block mb-3 rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3 shadow-sm"
>
<div class="hidden xl:flex flex-col gap-3 w-full xl:w-[280px] shrink-0">
<!-- Resultados -->
<div v-if="searchTrim" class="ag-card">
<div class="mb-2 flex items-center justify-between gap-2">
<div class="min-w-0">
<div class="font-semibold truncate">Resultados</div>
@@ -220,21 +178,20 @@
</div>
<!-- Mini calendário -->
<div class="mb-3 rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3 shadow-sm">
<div class="mb-2 flex items-center justify-between">
<span class="font-semibold">Calendário</span>
<div class="flex items-center gap-1">
<Button label="Hoje" severity="secondary" text class="h-9 rounded-full" @click="miniGoToday" />
<Button icon="pi pi-chevron-left" severity="secondary" text class="h-9 w-9 rounded-full" @click="miniPrevMonth" />
<Button icon="pi pi-chevron-right" severity="secondary" text class="h-9 w-9 rounded-full" @click="miniNextMonth" />
<div class="ag-card">
<div class="ag-card__head mb-1">
<span class="ag-card__title"><i class="pi pi-calendar" />{{ visibleTitle }}</span>
<div class="flex items-center gap-0.5">
<Button icon="pi pi-home" severity="secondary" text class="h-7 w-7 rounded-full" v-tooltip.top="'Hoje'" @click="miniGoToday" />
<Button icon="pi pi-chevron-left" severity="secondary" text class="h-7 w-7 rounded-full" @click="miniPrevMonth" />
<Button icon="pi pi-chevron-right" severity="secondary" text class="h-7 w-7 rounded-full" @click="miniNextMonth" />
</div>
</div>
<Calendar
v-model="miniDate"
inline
showWeek
class="w-full"
class="ag-mini-cal"
@update:modelValue="onMiniPick"
:pt="{ day: ({ context }) => ({ class: miniDayClass(context.date) }) }"
>
@@ -246,14 +203,13 @@
</div>
<ProximosFeriadosCard
class="mb-3"
:ownerId="clinicOwnerId"
:tenantId="tenantId || ''"
:workRules="workRules"
@bloqueado="refetch"
/>
<div class="rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3 shadow-sm">
<div class="ag-card">
<Button label="Novo Compromisso" icon="pi pi-plus" class="w-full rounded-full" @click="onCreateFromButton" />
</div>
</div>
@@ -2452,76 +2408,92 @@ function goRecorrencias () { router.push({ name: 'admin-agenda-recorrencias' })
:deep(.evt-private) { opacity: 0.9; filter: saturate(0.25); }
:deep(.evt-private.fc-event) { border-style: dashed; }
/* ── Hero Header ─────────────────────────────────── */
/* ── Topbar ─────────────────────────────────────────── */
.ag-sentinel { height: 1px; }
.ag-hero {
.ag-topbar {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border-radius: 6px;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.25rem 1.5rem;
}
.ag-hero--stuck {
margin-left: 0;
margin-right: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
padding: 8px 12px;
}
.ag-topbar--stuck { border-top-left-radius: 0; border-top-right-radius: 0; }
.ag-topbar__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.ag-topbar__blob { position: absolute; border-radius: 50%; filter: blur(60px); }
.ag-topbar__blob--1 { width: 16rem; height: 16rem; top: -4rem; right: -2rem; background: rgba(99,102,241,0.10); }
.ag-topbar__blob--2 { width: 18rem; height: 18rem; top: 0; left: -4rem; background: rgba(52,211,153,0.07); }
/* Blobs */
.ag-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.ag-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
.ag-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(99,102,241,0.12); }
.ag-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(52,211,153,0.09); }
.ag-hero__blob--3 { width: 14rem; height: 14rem; bottom: -2rem; right: 22%; background: rgba(217,70,239,0.08); }
/* Linha 1 */
.ag-hero__row1 {
.ag-topbar__inner {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1rem;
display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap;
}
.ag-hero__brand {
display: flex; align-items: center; gap: 0.75rem;
flex-shrink: 0; min-width: 0;
}
.ag-hero__icon {
.ag-topbar__brand { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
.ag-topbar__icon {
display: grid; place-items: center;
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem;
flex-shrink: 0;
width: 2.25rem; height: 2.25rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
}
.ag-hero__title {
font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em;
color: var(--text-color); white-space: nowrap;
.ag-topbar__title { font-size: 1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
.ag-topbar__sub { font-size: 0.75rem; color: var(--text-color-secondary); }
.ag-topbar__nav { display: flex; align-items: center; gap: 0.25rem; }
.ag-topbar__date-pill {
display: inline-flex; align-items: center; gap: 0.35rem;
padding: 0.25rem 0.75rem; border-radius: 999px;
border: 1px solid var(--surface-border); background: var(--surface-ground);
font-size: 0.8rem; font-weight: 600; color: var(--text-color);
cursor: pointer; white-space: nowrap; transition: border-color 0.15s;
}
.ag-hero__sub {
font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
.ag-topbar__date-pill:hover { border-color: var(--p-primary-400); }
.ag-topbar__filters { flex-shrink: 0; }
.ag-topbar__actions { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; }
/* ── Badge ────────────────────────────────────────────── */
.ag-badge {
position: absolute; top: -4px; right: -4px;
min-width: 16px; height: 16px; border-radius: 999px; padding: 0 4px;
background: var(--red-500, #ef4444); color: #fff;
font-size: 0.65rem; font-weight: 700;
display: flex; align-items: center; justify-content: center; pointer-events: none;
}
.ag-hero__desktop-controls {
flex: 1; display: flex; align-items: center;
justify-content: flex-end; gap: 0.75rem; flex-wrap: wrap;
}
.ag-hero__mobile-controls { display: none; }
/* Linha 2 */
.ag-hero__row2 {
display: flex; flex-wrap: wrap; align-items: center;
justify-content: space-between; gap: 0.75rem;
/* ── Calendar wrap ──────────────────────────────────── */
.ag-cal-wrap {
border: 1px solid var(--surface-border);
border-radius: 6px; background: var(--surface-card); overflow: hidden;
}
/* Mobile < 1200px */
@media (max-width: 1199px) {
.ag-hero__desktop-controls { display: none; }
.ag-hero__mobile-controls { display: flex; margin-left: auto; }
.ag-hero__divider,
.ag-hero__row2 { display: none; }
/* ── Sidebar cards ──────────────────────────────────── */
.ag-card {
border: 1px solid var(--surface-border);
border-radius: 6px; background: var(--surface-card); padding: 0.75rem;
}
.ag-card__head { display: flex; align-items: center; justify-content: space-between; }
.ag-card__title {
display: flex; align-items: center; gap: 0.35rem;
font-size: 0.7rem; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.06em;
color: var(--text-color-secondary); opacity: 0.65;
}
/* ── Mini calendário ────────────────────────────────── */
:deep(.ag-mini-cal .p-datepicker) { width: 100%; border: none; padding: 0; background: transparent; box-shadow: none; }
:deep(.ag-mini-cal .p-datepicker-header) { padding: 0 0 0.5rem; border: none; background: transparent; }
:deep(.ag-mini-cal .p-datepicker-calendar) { width: 100%; font-size: 0.78rem; }
:deep(.ag-mini-cal .p-datepicker-calendar td) { padding: 1px; }
:deep(.ag-mini-cal .p-datepicker-calendar td > span) {
width: 100%; min-width: unset; border-radius: 6px;
position: relative; display: flex; align-items: center; justify-content: center; aspect-ratio: 1;
}
.mini-day-num { display: block; text-align: center; line-height: 1; }
.mini-day-dot {
position: absolute; bottom: 2px; right: 2px;
width: 4px; height: 4px; border-radius: 50%;
background: var(--primary-color, #6366f1);
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -2,75 +2,95 @@
<template>
<Toast />
<!-- Sentinel para detecção de sticky -->
<div ref="headerSentinelRef" class="cmpr-sentinel" />
<!-- Sentinel -->
<div ref="headerSentinelRef" class="h-px" />
<!-- Hero Header sticky -->
<div ref="headerEl" class="cmpr-hero mx-3 md:mx-5 mb-4" :class="{ 'cmpr-hero--stuck': headerStuck }">
<!--
Hero sticky
-->
<div
ref="headerEl"
class="sticky mx-3 md:mx-4 mb-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5 transition-[border-radius] duration-200"
:class="{ 'rounded-tl-none rounded-tr-none': headerStuck }"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<!-- Blobs decorativos -->
<div class="cmpr-hero__blobs" aria-hidden="true">
<div class="cmpr-hero__blob cmpr-hero__blob--1" />
<div class="cmpr-hero__blob cmpr-hero__blob--2" />
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-emerald-400/10" />
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-indigo-500/[0.09]" />
</div>
<!-- Linha 1: brand + controles -->
<div class="cmpr-hero__row1">
<div class="cmpr-hero__brand">
<div class="cmpr-hero__icon">
<i class="pi pi-list text-lg" />
<div class="relative z-[1] flex items-center gap-3">
<!-- Brand -->
<div class="flex items-center gap-2 flex-shrink-0">
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
<i class="pi pi-list text-base" />
</div>
<div class="min-w-0">
<div class="cmpr-hero__title">Compromissos</div>
<div class="cmpr-hero__sub">Configure tipos de compromissos e campos adicionais</div>
<div class="min-w-0 hidden lg:block">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Compromissos</div>
<div class="text-[0.75rem] text-[var(--text-color-secondary)]">Configure tipos de compromissos e campos adicionais</div>
</div>
</div>
<!-- Controles desktop (1200px) -->
<div class="hidden xl:flex items-center gap-2 shrink-0">
<!-- Filtros + busca (desktop) -->
<div class="hidden xl:flex items-center gap-2 flex-1 min-w-0 mx-2">
<SelectButton
v-model="typeFilter"
:options="typeOptions"
optionLabel="label"
optionValue="value"
:disabled="loading"
size="small"
/>
<div class="w-56">
<FloatLabel variant="on">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText id="cmprSearch" v-model="filters.global.value" class="w-full" :disabled="loading" />
</IconField>
<label for="cmprSearch">Buscar compromisso...</label>
</FloatLabel>
</div>
</div>
<!-- Ações desktop -->
<div class="hidden xl:flex items-center gap-1 flex-shrink-0">
<Button label="Novo" icon="pi pi-plus" class="rounded-full" :disabled="loading" @click="openCreate()" />
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" title="Recarregar" @click="fetchAll()" />
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" @click="fetchAll()" />
</div>
<!-- Menu mobile (<1200px) -->
<div class="flex xl:hidden items-center shrink-0">
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => mobileMenuRef.toggle(e)" />
<!-- Mobile -->
<div class="flex xl:hidden items-center gap-1 flex-shrink-0 ml-auto">
<Button icon="pi pi-search" severity="secondary" outlined class="h-9 w-9 rounded-full" @click="searchDlgOpen = true" />
<Button icon="pi pi-plus" class="h-9 w-9 rounded-full" @click="openCreate()" />
<Button
label="Ações"
icon="pi pi-ellipsis-v"
severity="secondary"
size="small"
class="rounded-full"
@click="(e) => mobileMenuRef.toggle(e)"
/>
<Menu ref="mobileMenuRef" :model="mobileMenuItems" :popup="true" />
</div>
</div>
<!-- Divisor -->
<Divider class="cmpr-hero__divider my-2" />
<!-- Linha 2: filtros + busca (oculta no mobile) -->
<div class="cmpr-hero__row2">
<SelectButton v-model="typeFilter" :options="typeOptions" optionLabel="label" optionValue="value" :disabled="loading" />
<InputGroup class="w-72">
<InputGroupAddon><i class="pi pi-search" /></InputGroupAddon>
<InputText v-model="filters.global.value" placeholder="Buscar compromisso" :disabled="loading" />
<Button
v-if="filters.global.value"
icon="pi pi-trash"
severity="danger"
title="Limpar busca"
@click="clearSearch"
/>
</InputGroup>
</div>
</div>
<!-- Dialog de busca (mobile) -->
<Dialog v-model:visible="searchDlgOpen" modal :draggable="false" header="Buscar compromisso" class="w-[94vw] max-w-sm">
<!-- Dialog busca mobile -->
<Dialog
v-model:visible="searchDlgOpen"
modal
:draggable="false"
pt:mask:class="backdrop-blur-xs"
header="Buscar compromisso"
class="w-[94vw] max-w-sm"
>
<div class="pt-1">
<InputGroup>
<InputGroupAddon><i class="pi pi-search" /></InputGroupAddon>
<InputText v-model="filters.global.value" placeholder="Nome ou descrição..." autofocus />
<Button
v-if="filters.global.value"
icon="pi pi-trash"
severity="danger"
title="Limpar"
@click="filters.global.value = null"
/>
<Button v-if="filters.global.value" icon="pi pi-times" severity="secondary" @click="filters.global.value = null" />
</InputGroup>
</div>
<template #footer>
@@ -78,113 +98,189 @@
</template>
</Dialog>
<!-- Cards -->
<div class="mb-4 px-3 md:px-5 grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
<Card
<!--
Conteúdo principal
-->
<div class="px-3 md:px-4 pb-5 flex flex-col gap-3">
<!-- Stats row -->
<div class="flex flex-wrap gap-2">
<div
v-for="s in stats"
:key="s.label"
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] min-w-[80px] flex-1"
>
<div
class="text-[1.35rem] font-bold leading-none"
:class="{
'text-green-500': s.cls === 'stat-ok',
'text-red-500': s.cls === 'stat-warn',
'text-[var(--text-color)]': !s.cls,
}"
>{{ s.value }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">{{ s.label }}</div>
</div>
</div>
<!-- Cards grid -->
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-5 2xl:grid-cols-6">
<div
v-for="c in cardsCommitments"
:key="c.id"
class="rounded-3xl border border-[var(--surface-border)] shadow-sm"
class="flex flex-col gap-2.5 p-3.5 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] transition-[border-color,box-shadow] duration-150 hover:border-indigo-400/40 hover:shadow-[0_2px_12px_rgba(0,0,0,0.06)]"
:class="{ 'opacity-60': !c.active }"
>
<template #content>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<!-- Topo: cor + nome + badge nativo -->
<div class="flex items-start gap-2.5">
<!-- Dot de cor -->
<div
class="w-2.5 h-2.5 rounded-full flex-shrink-0 mt-[5px] bg-[var(--surface-border,#e2e8f0)]"
:style="c.bg_color ? { background: `#${c.bg_color}` } : {}"
/>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<!-- Sem cor: exibe nome simples -->
<span v-if="!c.bg_color" class="text-[0.9rem] font-bold text-[var(--text-color)]">{{ c.name }}</span>
<!-- Com cor: substitui nome pela pill colorida -->
<span
v-if="c.bg_color"
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold"
:style="{ backgroundColor: `#${c.bg_color}`, color: c.text_color || '#ffffff' }"
v-else
class="text-[1rem] font-semibold px-2 py-px rounded-full"
:style="{ background: `#${c.bg_color}20`, color: `#${c.bg_color}`, border: `1px solid #${c.bg_color}40` }"
>{{ c.name }}</span>
<span v-else class="truncate text-base font-semibold">{{ c.name }}</span>
<Tag v-if="c.is_native" value="Nativo" severity="info" />
<Tag v-if="c.is_native" value="Nativo" severity="info" class="text-xs" />
</div>
<div class="mt-1 line-clamp-2 text-sm opacity-70">
{{ c.description || '—' }}
<div class="text-[0.75rem] text-[var(--text-color-secondary)] mt-0.5 line-clamp-2">{{ c.description || '—' }}</div>
</div>
<div class="mt-3 text-sm">
<span class="opacity-70">Tempo total:</span>
<span class="ml-2 font-semibold">{{ formatMinutes(getTotalMinutes(c.id)) }}</span>
</div>
</div>
<div class="flex flex-col items-end gap-2">
<div class="flex items-center gap-2">
<span class="text-xs opacity-70">Ativo</span>
<!-- Toggle ativo -->
<div class="flex-shrink-0">
<InputSwitch
v-model="c.active"
:disabled="isActiveLocked(c) || saving"
@change="onToggleActive(c)"
/>
</div>
</div>
<div class="flex items-center gap-1">
<!-- Stats inline -->
<div class="flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-1 text-[0.75rem] text-[var(--text-color-secondary)]">
<i class="pi pi-clock text-xs" />
<span>{{ formatMinutes(getTotalMinutes(c.id)) }}</span>
</div>
<div v-if="c.fields?.length" class="flex items-center gap-1 text-[0.75rem] text-[var(--text-color-secondary)]">
<i class="pi pi-sliders-h text-xs" />
<span>{{ c.fields.length }} campo{{ c.fields.length > 1 ? 's' : '' }}</span>
</div>
<div
class="flex items-center gap-1 text-[0.75rem]"
:class="c.active ? 'text-green-500' : 'text-[var(--text-color-secondary)] opacity-50'"
>
<i class="pi text-xs" :class="c.active ? 'pi-check-circle' : 'pi-pause-circle'" />
<span>{{ c.active ? 'Ativo' : 'Inativo' }}</span>
</div>
</div>
<!-- Ações -->
<div class="flex items-center gap-1.5 pt-2 border-t border-[var(--surface-border,#e2e8f0)]">
<Button
icon="pi pi-pencil"
label="Editar"
size="small"
severity="secondary"
text
rounded
outlined
class="rounded-full flex-1"
:disabled="isEditLocked(c) || saving"
@click="openEdit(c)"
/>
<Button
icon="pi pi-trash"
size="small"
severity="danger"
text
rounded
outlined
class="rounded-full h-8 w-8"
:disabled="isDeleteLocked(c) || saving"
v-tooltip.top="isDeleteLocked(c) ? 'Compromissos nativos não podem ser excluídos' : 'Excluir'"
@click="confirmDelete(c)"
/>
</div>
</div>
<!-- Empty state -->
<div
v-if="!loading && !cardsCommitments.length"
class="flex flex-col items-center justify-center gap-2 p-8 rounded-md border border-dashed border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)] text-center text-[var(--text-color-secondary)] sm:col-span-2 xl:col-span-3"
>
<i class="pi pi-list text-3xl opacity-20" />
<div class="font-semibold">Nenhum compromisso encontrado</div>
<div class="text-sm opacity-60">Tente limpar os filtros ou cadastre um novo</div>
<div class="flex gap-2 mt-2">
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar filtros" size="small" class="rounded-full" @click="clearSearch" />
<Button icon="pi pi-plus" label="Novo compromisso" size="small" class="rounded-full" @click="openCreate()" />
</div>
</div>
</template>
</Card>
</div>
<!-- Tabela -->
<div class="mx-3 md:mx-5 mb-5 rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3 shadow-sm">
<div class="mb-2 flex items-center justify-between gap-3">
<div class="text-base font-semibold">Lista de compromissos</div>
<div class="text-sm opacity-60">
{{ visibleCommitments.length }} itens
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
<!-- Cabeçalho da tabela -->
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
<div class="flex items-center gap-2 min-w-0">
<i class="pi pi-table text-[var(--text-color-secondary)] opacity-60" />
<span class="font-semibold text-sm">Lista completa</span>
</div>
<span class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1.5 rounded-full bg-[var(--primary-color,#6366f1)] text-white text-[1rem] font-bold">
{{ visibleCommitments.length }}
</span>
</div>
<DataTable
:value="visibleCommitments"
dataKey="id"
:loading="loading"
:paginator="true"
:paginator="visibleCommitments.length > 10"
:rows="10"
responsiveLayout="scroll"
class="p-datatable-sm"
class="p-datatable-sm cmpr-datatable"
:rowClass="(r) => isRecent(r) ? 'row-new-highlight' : ''"
:filters="filters"
filterDisplay="menu"
:globalFilterFields="['name','description']"
>
<Column field="name" header="Nome" sortable filter filterPlaceholder="Filtrar nome" style="min-width: 14rem">
<Column field="name" header="Nome" sortable style="min-width: 14rem">
<template #body="{ data }">
<div class="flex items-center gap-2">
<span class="font-semibold">{{ data.name }}</span>
<Tag v-if="data.is_native" value="Nativo" severity="info" />
<div
v-if="data.bg_color"
class="w-3 h-3 rounded-full flex-shrink-0"
:style="{ background: `#${data.bg_color}` }"
/>
<span class="font-semibold text-sm">{{ data.name }}</span>
<Tag v-if="data.is_native" value="Nativo" severity="info" class="text-xs" />
</div>
</template>
</Column>
<Column field="description" header="Descrição" sortable filter filterPlaceholder="Filtrar descrição" style="min-width: 18rem">
<Column field="description" header="Descrição" style="min-width: 16rem">
<template #body="{ data }">
<span class="opacity-80">{{ data.description || '—' }}</span>
<span class="text-sm opacity-75">{{ data.description || '—' }}</span>
</template>
</Column>
<Column header="Tempo total" sortable style="min-width: 10rem">
<Column header="Tempo total" sortable style="min-width: 9rem">
<template #body="{ data }">
{{ formatMinutes(getTotalMinutes(data.id)) }}
<span class="text-sm font-medium">{{ formatMinutes(getTotalMinutes(data.id)) }}</span>
</template>
</Column>
<Column field="active" header="Ativo" style="width: 8rem">
<Column header="Campos" style="width: 7rem">
<template #body="{ data }">
<span class="text-sm opacity-70">{{ data.fields?.length || 0 }}</span>
</template>
</Column>
<Column field="active" header="Ativo" style="width: 7rem">
<template #body="{ data }">
<InputSwitch
v-model="data.active"
@@ -194,7 +290,7 @@
</template>
</Column>
<Column header="Ação" style="width: 10rem">
<Column header="Ação" style="width: 9rem">
<template #body="{ data }">
<div class="flex items-center gap-1">
<Button
@@ -211,6 +307,7 @@
text
rounded
:disabled="isDeleteLocked(data) || saving"
v-tooltip.top="isDeleteLocked(data) ? 'Nativo não excluível' : 'Excluir'"
@click="confirmDelete(data)"
/>
</div>
@@ -218,23 +315,17 @@
</Column>
<template #empty>
<div class="py-10 text-center">
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
<i class="pi pi-search text-xl" />
</div>
<div class="font-semibold">Nenhum compromisso determinístico encontrado</div>
<div class="mt-1 text-sm text-color-secondary">
Tente limpar filtros ou mudar o termo de busca.
</div>
<div class="mt-4 flex justify-center gap-2">
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar filtros" @click="clearSearch" />
<Button icon="pi pi-plus" label="Cadastrar compromisso" @click="openCreate()" />
</div>
<div class="py-8 text-center">
<i class="pi pi-search text-2xl opacity-20 mb-2 block" />
<div class="font-semibold text-sm">Nenhum compromisso encontrado</div>
<div class="text-xs opacity-60 mt-1">Limpe os filtros ou cadastre um novo</div>
</div>
</template>
</DataTable>
</div>
</div>
<!-- Dialog -->
<DeterminedCommitmentDialog
v-model="dlgOpen"
@@ -268,27 +359,13 @@ const headerSentinelRef = ref(null)
const headerStuck = ref(false)
let _observer = null
// ── Mobile menu ───────────────────────────────────────────
// ── Mobile ───────────────────────────────────────────────
const mobileMenuRef = ref(null)
const searchDlgOpen = ref(false)
const mobileMenuItems = computed(() => [
{
label: 'Novo compromisso',
icon: 'pi pi-plus',
command: () => openCreate()
},
{
label: 'Buscar',
icon: 'pi pi-search',
command: () => { searchDlgOpen.value = true }
},
{ separator: true },
{
label: 'Recarregar',
icon: 'pi pi-refresh',
command: () => fetchAll()
}
{ label: 'Novo compromisso', icon: 'pi pi-plus', command: () => openCreate() },
{ label: 'Recarregar', icon: 'pi pi-refresh', command: () => fetchAll() }
])
onMounted(async () => {
@@ -298,7 +375,6 @@ onMounted(async () => {
{ threshold: 0, rootMargin }
)
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value)
await tenantStore.loadSessionAndTenant()
await fetchAll()
})
@@ -314,10 +390,6 @@ const filters = reactive({
description: { value: null, matchMode: 'contains' }
})
/**
* Filtro por tipo (Todos / Nativos / Meus)
* - aplica na tabela (via computed) e nos cards
*/
const typeFilter = ref('all')
const typeOptions = [
{ label: 'Todos', value: 'all' },
@@ -325,39 +397,18 @@ const typeOptions = [
{ label: 'Meus', value: 'custom' }
]
/**
* Modelo de compromisso (tipo determinístico):
* - is_native: template do sistema
* - is_locked: trava comportamento (ex: Sessão)
* - fields: campos adicionais (dinâmicos)
*/
const commitments = ref([])
// Totais reais (minutos) agregados de commitment_time_logs
const totalsByCommitmentId = ref({})
/**
* Lista base para tabela:
* - aplica filtro por tipo (Todos / Nativos / Meus)
* - (global search do DataTable continua via :filters)
*/
const visibleCommitments = computed(() => {
let list = commitments.value
if (typeFilter.value === 'native') list = list.filter(c => !!c.is_native)
if (typeFilter.value === 'custom') list = list.filter(c => !c.is_native)
return list
})
/**
* Lista para cards:
* - aplica o mesmo filtro de tipo
* - + aplica busca global (para cards acompanharem a barra de busca)
*/
const cardsCommitments = computed(() => {
let list = visibleCommitments.value
const q = String(filters.global?.value ?? '').trim().toLowerCase()
if (q) {
list = list.filter(c =>
@@ -365,46 +416,54 @@ const cardsCommitments = computed(() => {
String(c.description || '').toLowerCase().includes(q)
)
}
return list
})
function clearSearch () {
filters.global.value = null
}
// ── Stats ─────────────────────────────────────────────────
const stats = computed(() => {
const all = commitments.value
const ativos = all.filter(c => c.active).length
const inativos = all.filter(c => !c.active).length
const nativos = all.filter(c => c.is_native).length
const meus = all.filter(c => !c.is_native).length
const totalMin = Object.values(totalsByCommitmentId.value).reduce((a, b) => a + b, 0)
return [
{ label: 'Total', value: all.length, cls: '' },
{ label: 'Ativos', value: ativos, cls: ativos > 0 ? 'stat-ok' : '' },
{ label: 'Inativos', value: inativos, cls: inativos > 0 ? 'stat-warn' : '' },
{ label: 'Nativos', value: nativos, cls: '' },
{ label: 'Meus', value: meus, cls: '' },
{ label: 'Tempo total', value: formatMinutes(totalMin), cls: '' },
]
})
function clearSearch () { filters.global.value = null }
const dlgOpen = ref(false)
const dlgMode = ref('create') // 'create' | 'edit'
const dlgMode = ref('create')
const editing = ref(null)
function getTenantId () {
// ✅ sem fallback (evita vazamento clinic↔therapist)
return tenantStore.activeTenantId || null
}
async function fetchAll () {
const tenantId = getTenantId()
if (!tenantId) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Tenant inválido.', life: 3000 })
return
}
loading.value = true
try {
// 1) commitments
const { data: cData, error: cErr } = await supabase
.from('determined_commitments')
.select('id, tenant_id, is_native, native_key, is_locked, active, name, description, bg_color, text_color, created_at, updated_at')
.eq('tenant_id', tenantId)
.order('is_native', { ascending: false })
.order('name', { ascending: true })
.order('created_at', { ascending: false })
if (cErr) throw cErr
const ids = (cData || []).map(x => x.id)
// 2) fields
let fieldsByCommitmentId = {}
if (ids.length > 0) {
const { data: fData, error: fErr } = await supabase
@@ -413,57 +472,41 @@ async function fetchAll () {
.eq('tenant_id', tenantId)
.in('commitment_id', ids)
.order('sort_order', { ascending: true })
if (fErr) throw fErr
fieldsByCommitmentId = (fData || []).reduce((acc, row) => {
const k = row.commitment_id
if (!acc[k]) acc[k] = []
acc[k].push({
id: row.id,
key: row.key,
label: row.label,
type: row.field_type,
required: !!row.required,
sort_order: row.sort_order
if (!acc[row.commitment_id]) acc[row.commitment_id] = []
acc[row.commitment_id].push({
id: row.id, key: row.key, label: row.label,
type: row.field_type, required: !!row.required, sort_order: row.sort_order
})
return acc
}, {})
}
// 3) totals (logs)
const { data: lData, error: lErr } = await supabase
.from('commitment_time_logs')
.select('commitment_id, minutes')
.eq('tenant_id', tenantId)
if (lErr) throw lErr
const totals = {}
for (const row of (lData || [])) {
const cid = row.commitment_id
const m = Number(row.minutes ?? 0) || 0
totals[cid] = (totals[cid] || 0) + m
totals[cid] = (totals[cid] || 0) + (Number(row.minutes ?? 0) || 0)
}
totalsByCommitmentId.value = totals
// 4) merge
commitments.value = (cData || []).map(c => ({
...c,
fields: fieldsByCommitmentId[c.id] || []
}))
} catch (e) {
console.error(e)
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar compromissos.', life: 4500 })
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar.', life: 4500 })
} finally {
loading.value = false
}
}
function getTotalMinutes (commitmentId) {
return Number(totalsByCommitmentId.value?.[commitmentId] ?? 0)
}
function getTotalMinutes (id) { return Number(totalsByCommitmentId.value?.[id] ?? 0) }
function formatMinutes (minutes) {
const m = Math.max(0, Number(minutes) || 0)
@@ -473,35 +516,17 @@ function formatMinutes (minutes) {
return `${h}h ${String(mm).padStart(2, '0')}m`
}
function isActiveLocked (c) {
return !!c.is_locked
}
function isActiveLocked (c) { return !!c.is_locked }
function isDeleteLocked (c) { return !!c.is_native }
function isEditLocked (_c) { return false }
function isDeleteLocked (c) {
return !!c.is_native
}
function isEditLocked (_c) {
return false // edição sempre permitida; só o "ativo" fica travado
}
function openCreate () {
dlgMode.value = 'create'
editing.value = null
dlgOpen.value = true
}
function openEdit (c) {
dlgMode.value = 'edit'
editing.value = JSON.parse(JSON.stringify(c))
dlgOpen.value = true
}
function openCreate () { dlgMode.value = 'create'; editing.value = null; dlgOpen.value = true }
function openEdit (c) { dlgMode.value = 'edit'; editing.value = JSON.parse(JSON.stringify(c)); dlgOpen.value = true }
async function onToggleActive (c) {
if (isActiveLocked(c)) return
const tenantId = getTenantId()
if (!tenantId) return
saving.value = true
try {
const { error } = await supabase
@@ -509,141 +534,83 @@ async function onToggleActive (c) {
.update({ active: !!c.active })
.eq('tenant_id', tenantId)
.eq('id', c.id)
if (error) throw error
toast.add({
severity: 'success',
summary: 'Atualizado',
detail: `${c.name}” agora está ${c.active ? 'ativo' : 'inativo'}.`,
life: 2500
})
toast.add({ severity: 'success', summary: 'Atualizado', detail: `"${c.name}" ${c.active ? 'ativo' : 'inativo'}.`, life: 2500 })
} catch (e) {
console.error(e)
c.active = !c.active
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao atualizar.', life: 4500 })
} finally {
saving.value = false
}
} finally { saving.value = false }
}
async function onSave (payload) {
const tenantId = getTenantId()
if (!tenantId) return
saving.value = true
try {
// pega usuário atual (se quiser auditoria futura)
await supabase.auth.getUser()
if (dlgMode.value === 'create') {
const insertRow = {
tenant_id: tenantId,
is_native: false,
native_key: null,
is_locked: false,
active: !!payload.active,
name: payload.name,
description: payload.description,
bg_color: payload.bg_color || null,
text_color: payload.text_color || null
}
const { data: newC, error: cErr } = await supabase
.from('determined_commitments')
.insert(insertRow)
.insert({
tenant_id: tenantId, is_native: false, native_key: null, is_locked: false,
active: !!payload.active, name: payload.name, description: payload.description,
bg_color: payload.bg_color || null, text_color: payload.text_color || null
})
.select('id, tenant_id, is_native, native_key, is_locked, active, name, description, bg_color, text_color, created_at, updated_at')
.single()
if (cErr) throw cErr
const fields = Array.isArray(payload.fields) ? payload.fields : []
if (fields.length > 0) {
const rows = fields.map((f, idx) => ({
tenant_id: tenantId,
commitment_id: newC.id,
key: f.key,
label: f.label,
field_type: f.type,
required: !!f.required,
const { error: fErr } = await supabase.from('determined_commitment_fields').insert(
fields.map((f, idx) => ({
tenant_id: tenantId, commitment_id: newC.id, key: f.key,
label: f.label, field_type: f.type, required: !!f.required,
sort_order: Number(f.sort_order ?? ((idx + 1) * 10))
}))
const { error: fErr } = await supabase
.from('determined_commitment_fields')
.insert(rows)
)
if (fErr) throw fErr
}
toast.add({ severity: 'success', summary: 'Criado', detail: 'Compromisso criado com sucesso.', life: 2500 })
dlgOpen.value = false
await fetchAll()
toast.add({ severity: 'success', summary: 'Criado', detail: 'Compromisso criado.', life: 2500 })
} else {
const updateRow = {
name: payload.name,
description: payload.description,
active: !!payload.active,
bg_color: payload.bg_color || null,
text_color: payload.text_color || null
}
const { error: upErr } = await supabase
.from('determined_commitments')
.update(updateRow)
.eq('tenant_id', tenantId)
.eq('id', payload.id)
.update({
name: payload.name, description: payload.description, active: !!payload.active,
bg_color: payload.bg_color || null, text_color: payload.text_color || null
})
.eq('tenant_id', tenantId).eq('id', payload.id)
if (upErr) throw upErr
const fields = Array.isArray(payload.fields) ? payload.fields : []
const { error: delErr } = await supabase
.from('determined_commitment_fields')
.delete()
.eq('tenant_id', tenantId)
.eq('commitment_id', payload.id)
.delete().eq('tenant_id', tenantId).eq('commitment_id', payload.id)
if (delErr) throw delErr
const fields = Array.isArray(payload.fields) ? payload.fields : []
if (fields.length > 0) {
const rows = fields.map((f, idx) => ({
tenant_id: tenantId,
commitment_id: payload.id,
key: f.key,
label: f.label,
field_type: f.type,
required: !!f.required,
const { error: insErr } = await supabase.from('determined_commitment_fields').insert(
fields.map((f, idx) => ({
tenant_id: tenantId, commitment_id: payload.id, key: f.key,
label: f.label, field_type: f.type, required: !!f.required,
sort_order: Number(f.sort_order ?? ((idx + 1) * 10))
}))
const { error: insErr } = await supabase
.from('determined_commitment_fields')
.insert(rows)
)
if (insErr) throw insErr
}
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Alterações salvas.', life: 2500 })
}
dlgOpen.value = false
await fetchAll()
}
} catch (e) {
console.error(e)
const msg = e?.message || ''
const detail = (e?.code === '23505' || /duplicate key value/i.test(msg))
? 'Já existe um compromisso com esse nome neste tenant. Escolha outro nome.'
: (msg || 'Falha ao salvar compromisso.')
? 'Já existe um compromisso com esse nome. Escolha outro.'
: (msg || 'Falha ao salvar.')
toast.add({ severity: 'error', summary: 'Erro', detail, life: 4500 })
} finally {
saving.value = false
}
} finally { saving.value = false }
}
function confirmDelete (c) {
if (isDeleteLocked(c)) return
const ok = window.confirm(`Excluir ${c.name}? Essa ação não pode ser desfeita.`)
if (!ok) return
if (!window.confirm(`Excluir "${c.name}"? Essa ação não pode ser desfeita.`)) return
onDelete(c)
}
@@ -651,103 +618,48 @@ async function onDelete (c) {
if (isDeleteLocked(c)) return
const tenantId = getTenantId()
if (!tenantId) return
saving.value = true
try {
const { error: fErr } = await supabase
.from('determined_commitment_fields')
.delete()
.eq('tenant_id', tenantId)
.eq('commitment_id', c.id)
const { error: fErr } = await supabase.from('determined_commitment_fields')
.delete().eq('tenant_id', tenantId).eq('commitment_id', c.id)
if (fErr) throw fErr
const { error: lErr } = await supabase
.from('commitment_time_logs')
.delete()
.eq('tenant_id', tenantId)
.eq('commitment_id', c.id)
const { error: lErr } = await supabase.from('commitment_time_logs')
.delete().eq('tenant_id', tenantId).eq('commitment_id', c.id)
if (lErr) throw lErr
const { data: delRows, error: dErr } = await supabase
.from('determined_commitments')
.delete()
.eq('tenant_id', tenantId)
.eq('id', c.id)
.eq('is_native', false)
.select('id')
const { data: delRows, error: dErr } = await supabase.from('determined_commitments')
.delete().eq('tenant_id', tenantId).eq('id', c.id).eq('is_native', false).select('id')
if (dErr) throw dErr
if (!delRows || delRows.length === 0) {
throw new Error('DELETE bloqueado por RLS (0 linhas). Confirme policy dc_delete_custom_for_active_member.')
}
if (!delRows?.length) throw new Error('DELETE bloqueado por RLS.')
toast.add({ severity: 'success', summary: 'Excluído', detail: 'Compromisso removido.', life: 2500 })
dlgOpen.value = false
await fetchAll()
} catch (e) {
console.error(e)
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao excluir compromisso.', life: 4500 })
} finally {
saving.value = false
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao excluir.', life: 4500 })
} finally { saving.value = false }
}
const HIGHLIGHT_MS = 24 * 60 * 60 * 1000
function isRecent (row) {
if (!row?.created_at) return false
return Date.now() - new Date(row.created_at).getTime() < HIGHLIGHT_MS
}
</script>
<style scoped>
/* ── Hero Header ─────────────────────────────────── */
.cmpr-sentinel { height: 1px; }
/* Apenas o mínimo que Tailwind base não cobre */
.cmpr-datatable :deep(tr.row-new-highlight td) { background-color: #f0fdf4 !important; }
:global(.app-dark) .cmpr-datatable :deep(tr.row-new-highlight td) { background-color: rgba(16,185,129,0.08) !important; }
.cmpr-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
/* line-clamp utilitário (caso não esteja no build) */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.25rem 1.5rem;
}
.cmpr-hero--stuck {
margin-left: 0;
margin-right: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
/* Blobs */
.cmpr-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.cmpr-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
.cmpr-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(52,211,153,0.10); }
.cmpr-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(99,102,241,0.09); }
/* Linha 1 */
.cmpr-hero__row1 {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1rem;
}
.cmpr-hero__brand {
display: flex; align-items: center; gap: 0.75rem;
flex: 1; min-width: 0;
}
.cmpr-hero__icon {
display: grid; place-items: center;
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem;
flex-shrink: 0;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
}
.cmpr-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
.cmpr-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
/* Linha 2 (oculta no mobile) */
.cmpr-hero__row2 {
position: relative; z-index: 1;
display: flex; align-items: center;
gap: 0.75rem;
}
@media (max-width: 767px) {
.cmpr-hero__divider,
.cmpr-hero__row2 { display: none; }
}
/* DataTable — overrides de PrimeVue que exigem :deep */
.cmpr-datatable :deep(.p-datatable-table-container) { border-radius: 0; }
.cmpr-datatable :deep(th) { background: var(--surface-ground) !important; font-size: 0.78rem; }
.cmpr-datatable :deep(td) { font-size: 0.82rem; }
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -2,137 +2,149 @@
<Toast />
<!-- Sentinel -->
<div ref="headerSentinelRef" class="extlink-sentinel" />
<div ref="headerSentinelRef" class="h-px" />
<!-- Hero sticky -->
<div ref="headerEl" class="extlink-hero mx-3 md:mx-5 mb-4" :class="{ 'extlink-hero--stuck': headerStuck }">
<div class="extlink-hero__blobs" aria-hidden="true">
<div class="extlink-hero__blob extlink-hero__blob--1" />
<div class="extlink-hero__blob extlink-hero__blob--2" />
<!--
HERO sticky
-->
<section
ref="headerEl"
class="sticky mx-3 md:mx-4 mb-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5 transition-[border-radius] duration-200"
:class="{ 'rounded-tl-none rounded-tr-none': headerStuck }"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<!-- Blobs -->
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-indigo-500/10" />
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-emerald-400/[0.08]" />
</div>
<!-- Row 1 -->
<div class="extlink-hero__row1">
<div class="extlink-hero__brand">
<div class="extlink-hero__icon"><i class="pi pi-link text-lg" /></div>
<div class="min-w-0">
<div class="extlink-hero__title">Link de Cadastro</div>
<div class="extlink-hero__sub">Compartilhe com o paciente para preencher o pré-cadastro com calma e segurança</div>
<div class="relative z-[1] flex items-center gap-3">
<!-- Brand -->
<div class="flex items-center gap-2 flex-shrink-0">
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
<i class="pi pi-link text-base" />
</div>
<div class="min-w-0 hidden lg:block">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Link de Cadastro</div>
<div class="text-[1rem] text-[var(--text-color-secondary)]">Compartilhe com o paciente para preencher o pré-cadastro</div>
</div>
</div>
<!-- Desktop (1200px) -->
<div class="hidden xl:flex items-center gap-2 shrink-0">
<!-- Status + link rápido desktop -->
<div class="hidden xl:flex flex-1 min-w-0 mx-2 items-center gap-3">
<!-- Badge de status -->
<span
class="inline-flex items-center gap-2 text-xs px-3 py-1.5 rounded-full border transition-colors"
class="inline-flex items-center gap-1.5 text-[0.75rem] px-2.5 py-1 rounded-full border flex-shrink-0 transition-colors"
:class="inviteToken
? 'border-emerald-200 text-emerald-700 bg-emerald-50'
: 'border-[var(--surface-border)] text-[var(--text-color-secondary)] bg-[var(--surface-ground)]'"
>
<span
class="h-2 w-2 rounded-full"
:class="inviteToken ? 'bg-emerald-500 animate-pulse' : 'bg-[var(--text-color-secondary)]'"
/>
<span class="h-1.5 w-1.5 rounded-full" :class="inviteToken ? 'bg-emerald-500 animate-pulse' : 'bg-[var(--text-color-secondary)]'" />
{{ inviteToken ? 'Link ativo' : 'Gerando…' }}
</span>
<Button
label="Gerar novo link"
icon="pi pi-refresh"
severity="secondary"
outlined
class="rounded-full"
:loading="rotating"
@click="rotateLink"
/>
</div>
<!-- Mobile (<1200px) -->
<div class="flex xl:hidden items-center shrink-0">
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => mobileMenuRef.toggle(e)" />
<Menu ref="mobileMenuRef" :model="mobileMenuItems" :popup="true" />
<!-- Link inline -->
<div v-if="!inviteToken" class="flex items-center gap-2 text-[1rem] text-[var(--text-color-secondary)]">
<i class="pi pi-spin pi-spinner" /> Gerando link
</div>
</div>
<!-- Divider -->
<Divider class="extlink-hero__divider my-2" />
<!-- Row 2: link rápido (oculto no mobile) -->
<div class="extlink-hero__row2">
<div v-if="!inviteToken" class="flex items-center gap-2 text-sm text-[var(--text-color-secondary)]">
<i class="pi pi-spin pi-spinner text-xs" /> Gerando link
</div>
<InputGroup v-else class="max-w-2xl">
<InputGroup v-else class="max-w-xl">
<InputGroupAddon><i class="pi pi-link" /></InputGroupAddon>
<InputText readonly :value="publicUrl" class="font-mono text-xs" />
<InputText readonly :value="publicUrl" class="font-mono text-[0.75rem]" />
<Button icon="pi pi-copy" severity="secondary" title="Copiar link" @click="copyLink" />
<Button icon="pi pi-external-link" severity="secondary" title="Abrir no navegador" @click="openLink" />
</InputGroup>
</div>
<!-- Ações desktop -->
<div class="hidden xl:flex items-center gap-1 flex-shrink-0">
<Button label="Gerar novo link" icon="pi pi-refresh" severity="secondary" outlined class="rounded-full" :loading="rotating" @click="rotateLink" />
</div>
<!-- Conteúdo -->
<div class="flex flex-col lg:flex-row gap-4 px-3 md:px-5 mb-5">
<!-- Mobile -->
<div class="flex xl:hidden items-center gap-1 flex-shrink-0 ml-auto">
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => mobileMenuRef.toggle(e)" />
<Menu ref="mobileMenuRef" :model="mobileMenuItems" :popup="true" />
</div>
</div>
</section>
<!-- Esquerda: ações do link -->
<div class="flex-1 min-w-0 flex flex-col gap-4">
<!--
CONTEÚDO
-->
<div class="flex flex-col lg:flex-row gap-3 px-3 md:px-4 pb-5">
<!-- ESQUERDA: link + mensagem -->
<div class="flex-1 min-w-0 flex flex-col gap-3">
<!-- Card principal: link -->
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<div class="p-5 border-b border-[var(--surface-border)] flex items-center justify-between gap-3 flex-wrap">
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
<!-- Header da seção -->
<div class="flex items-center justify-between gap-3 flex-wrap px-4 pt-3.5 pb-3 border-b border-[var(--surface-border,#e2e8f0)]">
<div>
<div class="font-semibold text-[var(--text-color)]">Seu link público</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">Envie ao paciente por WhatsApp, e-mail ou mensagem direta</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Envie ao paciente por WhatsApp, e-mail ou mensagem direta</div>
</div>
<span
class="inline-flex items-center gap-2 text-xs px-3 py-1.5 rounded-full border"
class="inline-flex items-center gap-1.5 text-[0.75rem] px-2.5 py-1 rounded-full border flex-shrink-0"
:class="inviteToken
? 'border-emerald-200 text-emerald-700 bg-emerald-50'
: 'border-[var(--surface-border)] text-[var(--text-color-secondary)] bg-[var(--surface-ground)]'"
>
<span class="h-2 w-2 rounded-full" :class="inviteToken ? 'bg-emerald-500 animate-pulse' : 'bg-[var(--text-color-secondary)]'" />
<span class="h-1.5 w-1.5 rounded-full" :class="inviteToken ? 'bg-emerald-500 animate-pulse' : 'bg-[var(--text-color-secondary)]'" />
{{ inviteToken ? 'Ativo' : 'Gerando…' }}
</span>
</div>
<div class="p-5 space-y-4">
<div class="p-4 flex flex-col gap-4">
<!-- Skeleton -->
<div v-if="!inviteToken" class="space-y-3">
<div class="h-10 rounded-xl bg-[var(--surface-ground)] animate-pulse" />
<div class="h-10 rounded-xl bg-[var(--surface-ground)] animate-pulse" />
<div v-if="!inviteToken" class="flex flex-col gap-3">
<div class="h-10 rounded-md bg-[var(--surface-ground,#f8fafc)] animate-pulse" />
<div class="h-10 rounded-md bg-[var(--surface-ground,#f8fafc)] animate-pulse" />
</div>
<div v-else class="space-y-4">
<!-- Link com ações -->
<div v-else class="flex flex-col gap-4">
<!-- InputGroup do link -->
<InputGroup>
<InputGroupAddon><i class="pi pi-link" /></InputGroupAddon>
<InputText readonly :value="publicUrl" class="font-mono text-xs" />
<InputText readonly :value="publicUrl" class="font-mono text-[0.75rem]" />
<Button icon="pi pi-copy" severity="secondary" title="Copiar" @click="copyLink" />
<Button icon="pi pi-external-link" severity="secondary" title="Abrir" @click="openLink" />
</InputGroup>
<div class="text-xs text-[var(--text-color-secondary)]">
Token: <span class="font-mono select-all">{{ inviteToken }}</span>
<div class="text-[0.75rem] text-[var(--text-color-secondary)]">
Token: <span class="font-mono select-all opacity-60">{{ inviteToken }}</span>
</div>
<!-- CTAs rápidas -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button class="extlink-cta-btn" @click="copyLink">
<div class="extlink-cta-btn__icon bg-[color-mix(in_srgb,var(--p-primary-500,#6366f1)_12%,transparent)] text-[var(--p-primary-500,#6366f1)]">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
<!-- Copiar link -->
<button
class="flex items-center gap-3 px-3.5 py-3 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)] cursor-pointer text-left transition-[background,box-shadow,transform] duration-150 hover:bg-[var(--surface-hover,#f1f5f9)] hover:shadow-[0_2px_12px_rgba(0,0,0,0.06)] hover:-translate-y-px active:translate-y-0"
@click="copyLink"
>
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
<i class="pi pi-copy" />
</div>
<div class="text-left min-w-0">
<div class="font-semibold text-sm text-[var(--text-color)]">Copiar link</div>
<div class="text-xs text-[var(--text-color-secondary)]">Cole no WhatsApp ou e-mail</div>
<div class="min-w-0">
<div class="font-semibold text-[1rem] text-[var(--text-color)]">Copiar link</div>
<div class="text-[0.75rem] text-[var(--text-color-secondary)]">Cole no WhatsApp ou e-mail</div>
</div>
</button>
<button class="extlink-cta-btn" @click="copyInviteMessage">
<div class="extlink-cta-btn__icon bg-emerald-500/10 text-emerald-600">
<!-- Copiar mensagem pronta -->
<button
class="flex items-center gap-3 px-3.5 py-3 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)] cursor-pointer text-left transition-[background,box-shadow,transform] duration-150 hover:bg-[var(--surface-hover,#f1f5f9)] hover:shadow-[0_2px_12px_rgba(0,0,0,0.06)] hover:-translate-y-px active:translate-y-0"
@click="copyInviteMessage"
>
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-emerald-500/10 text-emerald-600">
<i class="pi pi-comment" />
</div>
<div class="text-left min-w-0">
<div class="font-semibold text-sm text-[var(--text-color)]">Copiar mensagem pronta</div>
<div class="text-xs text-[var(--text-color-secondary)]">Texto formatado com o link incluso</div>
<div class="min-w-0">
<div class="font-semibold text-[1rem] text-[var(--text-color)]">Copiar mensagem pronta</div>
<div class="text-[0.75rem] text-[var(--text-color-secondary)]">Texto formatado com o link incluso</div>
</div>
</button>
</div>
@@ -146,84 +158,66 @@
</div>
<!-- Mensagem pronta -->
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] p-4">
<div class="font-semibold text-[var(--text-color)] flex items-center gap-2 mb-1">
<i class="pi pi-comment text-sm text-[var(--text-color-secondary)]" />
<i class="pi pi-comment text-[1rem] text-[var(--text-color-secondary)]" />
Mensagem pronta para envio
</div>
<div class="text-sm text-[var(--text-color-secondary)] mb-3">Copie e cole ao enviar o link ao paciente:</div>
<div class="rounded-xl bg-[var(--surface-ground)] border border-[var(--surface-border)] p-4 text-sm text-[var(--text-color)] leading-relaxed">
<div class="text-[1rem] text-[var(--text-color-secondary)] mb-3">Copie e cole ao enviar o link ao paciente:</div>
<div class="rounded-md bg-[var(--surface-ground,#f8fafc)] border border-[var(--surface-border,#e2e8f0)] p-4 text-[1rem] text-[var(--text-color)] leading-relaxed">
Olá! Segue o link para seu pré-cadastro. Preencha com calma campos opcionais podem ficar em branco:
<span class="block mt-2 font-mono text-xs break-all text-[var(--text-color-secondary)]">{{ publicUrl || '…aguardando link…' }}</span>
<span class="block mt-2 font-mono text-[0.72rem] break-all text-[var(--text-color-secondary)]">{{ publicUrl || '…aguardando link…' }}</span>
</div>
<div class="mt-3">
<Button
icon="pi pi-copy"
label="Copiar mensagem"
severity="secondary"
outlined
class="rounded-full"
:disabled="!publicUrl"
@click="copyInviteMessage"
/>
<Button icon="pi pi-copy" label="Copiar mensagem" severity="secondary" outlined class="rounded-full" :disabled="!publicUrl" @click="copyInviteMessage" />
</div>
</div>
</div>
<!-- Direita: instruções -->
<div class="lg:w-80 shrink-0 flex flex-col gap-4">
<!-- DIREITA: instruções -->
<div class="w-full lg:w-[272px] lg:flex-shrink-0 flex flex-col gap-3">
<!-- Como funciona -->
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<div class="p-5 border-b border-[var(--surface-border)]">
<div class="font-semibold text-[var(--text-color)]">Como funciona</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">Simples e sem fricção para o paciente</div>
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
<div class="flex items-center gap-2.5 px-3.5 pt-3 pb-2.5 border-b border-[var(--surface-border,#f1f5f9)]">
<div class="w-8 h-8 rounded-md flex items-center justify-center flex-shrink-0 bg-indigo-500/10 text-indigo-500">
<i class="pi pi-list-check text-[0.9rem]" />
</div>
<div class="p-5">
<ol class="space-y-4">
<li class="flex gap-3">
<div class="extlink-step shrink-0">1</div>
<div class="min-w-0">
<div class="font-semibold text-sm text-[var(--text-color)]">Você envia o link</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">Por WhatsApp, e-mail ou mensagem direta.</div>
<span class="block text-[1rem] font-bold text-[var(--text-color)]">Como funciona</span>
<span class="block text-[0.72rem] text-[var(--text-color-secondary)]">Simples e sem fricção para o paciente</span>
</div>
</li>
<li class="flex gap-3">
<div class="extlink-step shrink-0">2</div>
<div class="min-w-0">
<div class="font-semibold text-sm text-[var(--text-color)]">O paciente preenche</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">Campos opcionais podem ficar em branco. Menos fricção, mais adesão.</div>
</div>
</li>
<li class="flex gap-3">
<div class="extlink-step shrink-0">3</div>
<ol class="flex flex-col divide-y divide-[var(--surface-border,#f1f5f9)]">
<li v-for="step in howItWorks" :key="step.n" class="flex items-start gap-3 px-3.5 py-3">
<div class="grid place-items-center w-7 h-7 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500 text-[0.75rem] font-bold mt-px">
{{ step.n }}
</div>
<div class="min-w-0">
<div class="font-semibold text-sm text-[var(--text-color)]">Você recebe e converte</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">O cadastro aparece em "Cadastros recebidos". Revise e converta em paciente quando quiser.</div>
<div class="font-semibold text-[1rem] text-[var(--text-color)]">{{ step.title }}</div>
<div class="text-[0.75rem] text-[var(--text-color-secondary)] mt-0.5 leading-relaxed">{{ step.desc }}</div>
</div>
</li>
</ol>
</div>
</div>
<!-- Boas práticas -->
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="font-semibold text-[var(--text-color)] flex items-center gap-2 mb-3">
<i class="pi pi-shield text-sm text-[var(--text-color-secondary)]" />
Boas práticas
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
<div class="flex items-center gap-2.5 px-3.5 pt-3 pb-2.5 border-b border-[var(--surface-border,#f1f5f9)]">
<div class="w-8 h-8 rounded-md flex items-center justify-center flex-shrink-0 bg-emerald-500/10 text-emerald-600">
<i class="pi pi-shield text-[0.9rem]" />
</div>
<ul class="space-y-2.5">
<li class="flex gap-2 text-sm text-[var(--text-color-secondary)]">
<i class="pi pi-check text-emerald-500 mt-0.5 shrink-0" />
<span>Gere um novo link se suspeitar que ele foi repassado indevidamente.</span>
</li>
<li class="flex gap-2 text-sm text-[var(--text-color-secondary)]">
<i class="pi pi-check text-emerald-500 mt-0.5 shrink-0" />
<span>Informe o paciente que campos opcionais podem ficar em branco.</span>
</li>
<li class="flex gap-2 text-sm text-[var(--text-color-secondary)]">
<i class="pi pi-check text-emerald-500 mt-0.5 shrink-0" />
<span>Evite divulgar em público; é um link para compartilhamento individual.</span>
<div class="min-w-0">
<span class="block text-[1rem] font-bold text-[var(--text-color)]">Boas práticas</span>
<span class="block text-[0.72rem] text-[var(--text-color-secondary)]">Segurança e privacidade</span>
</div>
</div>
<ul class="flex flex-col divide-y divide-[var(--surface-border,#f1f5f9)]">
<li v-for="tip in goodPractices" :key="tip" class="flex items-start gap-2.5 px-3.5 py-2.5">
<i class="pi pi-check text-emerald-500 mt-0.5 flex-shrink-0 text-[1rem]" />
<span class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">{{ tip }}</span>
</li>
</ul>
</div>
@@ -243,13 +237,13 @@ const toast = useToast()
const inviteToken = ref('')
const rotating = ref(false)
// ── Hero sticky ───────────────────────────────────────────
// ── Hero sticky ───────────────────────────────────────────
const headerEl = ref(null)
const headerSentinelRef = ref(null)
const headerStuck = ref(false)
let _observer = null
// ── Mobile menu ───────────────────────────────────────────
// ── Mobile menu ───────────────────────────────────────────
const mobileMenuRef = ref(null)
const mobileMenuItems = computed(() => [
@@ -260,7 +254,20 @@ const mobileMenuItems = computed(() => [
{ label: 'Gerar novo link', icon: 'pi pi-refresh', command: () => rotateLink() }
])
// ── URL base ────────────────────────────────────────────────
// ── Conteúdo estático ─────────────────────────────────────
const howItWorks = [
{ n: 1, title: 'Você envia o link', desc: 'Por WhatsApp, e-mail ou mensagem direta.' },
{ n: 2, title: 'O paciente preenche', desc: 'Campos opcionais podem ficar em branco. Menos fricção, mais adesão.' },
{ n: 3, title: 'Você recebe e converte', desc: 'O cadastro aparece em "Cadastros recebidos". Revise e converta em paciente quando quiser.' },
]
const goodPractices = [
'Gere um novo link se suspeitar que ele foi repassado indevidamente.',
'Informe o paciente que campos opcionais podem ficar em branco.',
'Evite divulgar em público; é um link para compartilhamento individual.',
]
// ── URL base ──────────────────────────────────────────────
const PUBLIC_BASE_URL = ''
const origin = computed(() => {
@@ -273,7 +280,7 @@ const publicUrl = computed(() => {
return `${origin.value}/cadastro/paciente?t=${encodeURIComponent(inviteToken.value)}`
})
// ── Token helpers ───────────────────────────────────────────
// ── Token helpers ─────────────────────────────────────────
function newToken () {
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID()
return 'tok_' + Math.random().toString(36).slice(2) + Date.now().toString(36)
@@ -301,10 +308,7 @@ async function loadOrCreateInvite() {
if (error) throw error
const token = data?.[0]?.token
if (token) {
inviteToken.value = token
return
}
if (token) { inviteToken.value = token; return }
const t = newToken()
const { error: insErr } = await supabase
@@ -326,8 +330,7 @@ async function rotateLink() {
const { error: e1 } = await supabase
.from('patient_invites')
.update({ active: false, updated_at: new Date().toISOString() })
.eq('owner_id', uid)
.eq('active', true)
.eq('owner_id', uid).eq('active', true)
if (e1) throw e1
const { error: e2 } = await supabase
@@ -388,95 +391,3 @@ onMounted(async () => {
onBeforeUnmount(() => { _observer?.disconnect() })
</script>
<style scoped>
/* ── Sentinel ─────────────────────────────────────── */
.extlink-sentinel { height: 1px; }
/* ── Hero ─────────────────────────────────────────── */
.extlink-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.25rem 1.5rem;
}
.extlink-hero--stuck {
margin-left: 0; margin-right: 0;
border-top-left-radius: 0; border-top-right-radius: 0;
}
/* Blobs decorativos */
.extlink-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.extlink-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
.extlink-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(99,102,241,0.10); }
.extlink-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(16,185,129,0.08); }
/* Linha 1 */
.extlink-hero__row1 {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1rem;
}
.extlink-hero__brand {
display: flex; align-items: center; gap: 0.75rem;
flex: 1; min-width: 0;
}
.extlink-hero__icon {
display: grid; place-items: center;
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem; flex-shrink: 0;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
}
.extlink-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
.extlink-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
/* Linha 2 */
.extlink-hero__row2 {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 0.75rem;
}
@media (max-width: 767px) {
.extlink-hero__divider,
.extlink-hero__row2 { display: none; }
}
/* ── CTA button ───────────────────────────────────── */
.extlink-cta-btn {
display: flex;
align-items: center;
gap: 0.875rem;
padding: 0.875rem 1rem;
border-radius: 1rem;
border: 1px solid var(--surface-border);
background: var(--surface-ground);
cursor: pointer;
transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.1s ease;
text-align: left;
}
.extlink-cta-btn:hover {
background: var(--surface-hover);
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
transform: translateY(-1px);
}
.extlink-cta-btn:active { transform: translateY(0); }
.extlink-cta-btn__icon {
display: grid; place-items: center;
width: 2.25rem; height: 2.25rem;
border-radius: 0.75rem; flex-shrink: 0;
font-size: 1rem;
}
/* ── Step numbers ─────────────────────────────────── */
.extlink-step {
display: grid; place-items: center;
width: 2rem; height: 2rem;
border-radius: 0.625rem;
font-size: 0.8rem; font-weight: 700;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
}
</style>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@ import AppRail from './AppRail.vue'
import AppRailPanel from './AppRailPanel.vue'
import AppRailSidebar from './AppRailSidebar.vue'
import AjudaDrawer from '@/components/AjudaDrawer.vue'
import SupportDebugBanner from '@/support/components/SupportDebugBanner.vue'
import { fetchDocsForPath, useAjuda } from '@/composables/useAjuda'
@@ -25,7 +26,7 @@ import { useEntitlementsStore } from '@/stores/entitlementsStore'
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore'
const route = useRoute()
const { layoutConfig, layoutState, hideMobileMenu, isDesktop } = useLayout()
const { layoutConfig, layoutState, hideMobileMenu, isDesktop, effectiveVariant } = useLayout()
const layoutArea = computed(() => route.meta?.area || null)
provide('layoutArea', layoutArea)
@@ -100,7 +101,7 @@ onBeforeUnmount(() => {
</template>
<!-- Layout Rail -->
<template v-else-if="layoutConfig.variant === 'rail'">
<template v-else-if="effectiveVariant === 'rail'">
<div class="l2-root">
<!-- Rail de ícones: oculto em mobile ( 1200px) via CSS -->
<AppRail />
@@ -142,6 +143,9 @@ onBeforeUnmount(() => {
<AjudaDrawer />
<Toast />
</template>
<!-- Global fora de todos os branches, persiste em qualquer layout/rota -->
<SupportDebugBanner />
</template>
<style>

View File

@@ -20,6 +20,7 @@ const railSections = computed(() => {
const model = menuStore.model || []
return model
.filter(s => s.label && Array.isArray(s.items) && s.items.length)
.filter(s => s.label.toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '').trim() !== 'inicio')
.map(s => ({
key: s.label,
label: s.label,
@@ -40,6 +41,20 @@ const initials = computed(() => {
const userName = computed(() => sessionUser.value?.user_metadata?.full_name || sessionUser.value?.email || 'Conta')
// ── Início (fixo) ────────────────────────────────────────────
function selectHome () {
if (layoutState.railSectionKey === '__home__' && layoutState.railPanelOpen) {
layoutState.railPanelOpen = false
} else {
layoutState.railSectionKey = '__home__'
layoutState.railPanelOpen = true
}
}
const isHomeActive = computed(() =>
layoutState.railSectionKey === '__home__' && layoutState.railPanelOpen
)
// ── Seleção de seção ─────────────────────────────────────────
function selectSection (section) {
if (layoutState.railSectionKey === section.key && layoutState.railPanelOpen) {
@@ -52,7 +67,6 @@ function selectSection (section) {
function isActiveSectionOrChild (section) {
if (layoutState.railSectionKey === section.key && layoutState.railPanelOpen) return true
// verifica se algum filho está ativo
const active = String(layoutState.activePath || '')
return section.items.some(i => {
const p = typeof i.to === 'string' ? i.to : ''
@@ -77,21 +91,33 @@ async function signOut () {
</script>
<template>
<aside class="rail">
<aside class="rail w-[60px] shrink-0 h-screen flex flex-col items-center border-r border-[var(--surface-border)] bg-[var(--surface-card)] z-50 select-none">
<!-- Brand -->
<div class="rail__brand">
<span class="rail__psi">Ψ</span>
<div class="w-full h-14 shrink-0 grid place-items-center border-b border-[var(--surface-border)]">
<span class="text-[1.35rem] font-extrabold leading-none text-[var(--primary-color)] [text-shadow:0_0_20px_color-mix(in_srgb,var(--primary-color)_40%,transparent)]">Ψ</span>
</div>
<!-- Nav icons -->
<nav class="rail__nav" role="navigation" aria-label="Menu principal">
<nav class="flex-1 w-full flex flex-col items-center gap-1 py-2.5 overflow-y-auto overflow-x-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" role="navigation" aria-label="Menu principal">
<!-- Início fixo -->
<button
class="rail-btn relative w-10 h-10 rounded-[10px] grid place-items-center border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer text-base shrink-0 transition-[background,color,transform] duration-150 hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)] hover:scale-105"
:class="isHomeActive ? 'rail-btn--active bg-[color-mix(in_srgb,var(--primary-color)_12%,transparent)] !text-[var(--primary-color)]' : ''"
v-tooltip.right="{ value: 'Início', showDelay: 0 }"
aria-label="Início"
@click="selectHome"
>
<i class="pi pi-fw pi-home" />
</button>
<button
v-for="section in railSections"
:key="section.key"
class="rail__btn"
:class="{ 'rail__btn--active': isActiveSectionOrChild(section) }"
v-tooltip.right="{ value: section.label, showDelay: 400 }"
class="rail-btn relative w-10 h-10 rounded-[10px] grid place-items-center border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer text-base shrink-0 transition-[background,color,transform] duration-150 hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)] hover:scale-105"
:class="isActiveSectionOrChild(section) ? 'rail-btn--active bg-[color-mix(in_srgb,var(--primary-color)_12%,transparent)] !text-[var(--primary-color)]' : ''"
v-tooltip.right="{ value: section.label, showDelay: 0 }"
:aria-label="section.label"
@click="selectSection(section)"
>
@@ -100,49 +126,48 @@ async function signOut () {
</nav>
<!-- Rodapé -->
<div class="rail__foot">
<!-- Configurações de layout -->
<div class="w-full flex flex-col items-center gap-1.5 py-2 pb-3 border-t border-[var(--surface-border)]">
<button
class="rail__btn rail__btn--sm"
v-tooltip.right="{ value: 'Meu Perfil', showDelay: 400 }"
aria-label="Meu Perfil"
@click="goTo('/account/profile')"
class="w-9 h-9 rounded-[10px] grid place-items-center border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer text-[0.875rem] shrink-0 transition-[background,color,transform] duration-150 hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)] hover:scale-105"
v-tooltip.right="{ value: 'Configurações', showDelay: 0 }"
aria-label="Configurações"
@click="goTo('/configuracoes')"
>
<i class="pi pi-fw pi-cog" />
</button>
<!-- Avatar / user -->
<button
class="rail__av-btn"
v-tooltip.right="{ value: userName, showDelay: 400 }"
class="w-9 h-9 rounded-[10px] border-none cursor-pointer overflow-hidden shrink-0 bg-[var(--surface-ground)] grid place-items-center transition-[transform,box-shadow] duration-150 hover:scale-105 hover:shadow-[0_0_0_2px_var(--primary-color)]"
v-tooltip.right="{ value: userName, showDelay: 0 }"
:aria-label="userName"
@click="toggleUserPop"
>
<img v-if="avatarUrl" :src="avatarUrl" class="rail__av-img" :alt="userName" />
<span v-else class="rail__av-init">{{ initials }}</span>
<img v-if="avatarUrl" :src="avatarUrl" class="w-full h-full object-cover" :alt="userName" />
<span v-else class="text-[1rem] font-bold text-[var(--text-color)]">{{ initials }}</span>
</button>
</div>
<!-- Popover usuário -->
<Popover ref="userPop" appendTo="body">
<div class="rail-pop">
<div class="rail-pop__user">
<div class="rail-pop__av">
<img v-if="avatarUrl" :src="avatarUrl" class="rail-pop__av-img" />
<span v-else class="rail-pop__av-init">{{ initials }}</span>
<div class="min-w-[210px] p-1 flex flex-col gap-0.5">
<div class="flex items-center gap-2.5 px-2.5 py-2 pb-2.5">
<div class="w-9 h-9 rounded-[9px] overflow-hidden shrink-0 bg-[var(--surface-ground)] grid place-items-center border border-[var(--surface-border)]">
<img v-if="avatarUrl" :src="avatarUrl" class="w-full h-full object-cover" />
<span v-else class="text-[1rem] font-bold text-[var(--text-color)]">{{ initials }}</span>
</div>
<div class="min-w-0">
<div class="rail-pop__name">{{ userName }}</div>
<div class="rail-pop__email">{{ sessionUser?.email }}</div>
<div class="text-[0.83rem] font-semibold text-[var(--text-color)] truncate">{{ userName }}</div>
<div class="text-[0.68rem] text-[var(--text-color-secondary)] truncate">{{ sessionUser?.email }}</div>
</div>
</div>
<div class="rail-pop__divider" />
<div class="h-px bg-[var(--surface-border)] my-0.5" />
<Button label="Meu Perfil" icon="pi pi-user" text class="w-full justify-start" @click="goTo('/account/profile')" />
<Button label="Segurança" icon="pi pi-shield" text class="w-full justify-start" @click="goTo('/account/security')" />
<div class="rail-pop__divider" />
<div class="h-px bg-[var(--surface-border)] my-0.5" />
<Button label="Sair" icon="pi pi-sign-out" severity="danger" text class="w-full justify-start" @click="signOut" />
</div>
@@ -151,78 +176,8 @@ async function signOut () {
</template>
<style scoped>
/* ─── Rail container ─────────────────────────────────────── */
.rail {
width: 60px;
flex-shrink: 0;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
border-right: 1px solid var(--surface-border);
background: var(--surface-card);
z-index: 50;
user-select: none;
}
/* ─── Brand ──────────────────────────────────────────────── */
.rail__brand {
width: 100%;
height: 56px;
display: grid;
place-items: center;
border-bottom: 1px solid var(--surface-border);
flex-shrink: 0;
}
.rail__psi {
font-size: 1.35rem;
font-weight: 800;
color: var(--primary-color);
text-shadow: 0 0 20px color-mix(in srgb, var(--primary-color) 40%, transparent);
line-height: 1;
}
/* ─── Nav ────────────────────────────────────────────────── */
.rail__nav {
flex: 1;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 10px 0;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: none;
}
.rail__nav::-webkit-scrollbar { display: none; }
/* ─── Buttons ────────────────────────────────────────────── */
.rail__btn {
width: 40px;
height: 40px;
border-radius: 10px;
display: grid;
place-items: center;
border: none;
background: transparent;
color: var(--text-color-secondary);
cursor: pointer;
font-size: 1rem;
transition: background 0.15s, color 0.15s, transform 0.12s;
position: relative;
flex-shrink: 0;
}
.rail__btn:hover {
background: var(--surface-ground);
color: var(--text-color);
transform: scale(1.08);
}
.rail__btn--active {
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
color: var(--primary-color);
}
.rail__btn--active::before {
/* Indicador lateral do botão ativo — pseudo-elemento não expressável em Tailwind */
.rail-btn--active::before {
content: '';
position: absolute;
left: -10px;
@@ -233,97 +188,4 @@ async function signOut () {
border-radius: 0 3px 3px 0;
background: var(--primary-color);
}
.rail__btn--sm {
width: 36px;
height: 36px;
font-size: 0.875rem;
}
/* ─── Footer ─────────────────────────────────────────────── */
.rail__foot {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 8px 0 12px;
border-top: 1px solid var(--surface-border);
}
/* ─── Avatar button ──────────────────────────────────────── */
.rail__av-btn {
width: 36px;
height: 36px;
border-radius: 10px;
border: none;
cursor: pointer;
overflow: hidden;
transition: transform 0.12s, box-shadow 0.15s;
background: var(--surface-ground);
display: grid;
place-items: center;
flex-shrink: 0;
}
.rail__av-btn:hover {
transform: scale(1.08);
box-shadow: 0 0 0 2px var(--primary-color);
}
.rail__av-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.rail__av-init {
font-size: 0.78rem;
font-weight: 700;
color: var(--text-color);
}
/* ─── Popover ────────────────────────────────────────────── */
.rail-pop {
min-width: 210px;
padding: 4px;
display: flex;
flex-direction: column;
gap: 2px;
}
.rail-pop__user {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px 10px;
}
.rail-pop__av {
width: 36px;
height: 36px;
border-radius: 9px;
overflow: hidden;
flex-shrink: 0;
background: var(--surface-ground);
display: grid;
place-items: center;
border: 1px solid var(--surface-border);
}
.rail-pop__av-img { width: 100%; height: 100%; object-fit: cover; }
.rail-pop__av-init { font-size: 0.78rem; font-weight: 700; color: var(--text-color); }
.rail-pop__name {
font-size: 0.83rem;
font-weight: 600;
color: var(--text-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rail-pop__email {
font-size: 0.68rem;
color: var(--text-color-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rail-pop__divider {
height: 1px;
background: var(--surface-border);
margin: 2px 0;
}
</style>

View File

@@ -1,6 +1,6 @@
<!-- src/layout/AppRailPanel.vue Painel expansível do Layout 2 -->
<script setup>
import { computed } from 'vue'
import { computed, ref, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useMenuStore } from '@/stores/menuStore'
@@ -19,9 +19,25 @@ const currentSection = computed(() => {
return model.find(s => s.label === layoutState.railSectionKey) || null
})
// ── Items da seção (com suporte a children) ──────────────────
const sectionItems = computed(() => currentSection.value?.items || [])
// Todos os grupos do menu
const allSections = computed(() => {
const model = menuStore.model || []
return model.filter(s => s.label && Array.isArray(s.items) && s.items.length)
})
// "Início" = chave especial __home__
const isHome = computed(() => layoutState.railSectionKey === '__home__')
// Seções visíveis: tudo em Início, só a selecionada nos demais
const visibleSections = computed(() =>
isHome.value ? allSections.value : (currentSection.value ? [currentSection.value] : [])
)
const panelTitle = computed(() =>
isHome.value ? 'Início' : currentSection.value?.label || 'Menu'
)
// ── Helpers ──────────────────────────────────────────────────
function isLocked (item) {
if (!item.proBadge || !item.feature) return false
try { return !entitlements.has(item.feature) } catch { return false }
@@ -57,59 +73,324 @@ function navigate (item) {
function closePanel () {
layoutState.railPanelOpen = false
}
// ── Busca (todo o menu) ──────────────────────────────────────
const query = ref('')
const showResults = ref(false)
const activeIndex = ref(-1)
const forcedOpen = ref(false)
const searchEl = ref(null)
const searchWrapEl = ref(null)
const RECENT_KEY = 'menu_search_recent'
const recent = ref([])
function loadRecent () {
try { recent.value = JSON.parse(localStorage.getItem(RECENT_KEY) || '[]') } catch { recent.value = [] }
}
function saveRecent (q) {
const v = String(q || '').trim()
if (!v) return
const list = [v, ...recent.value.filter(x => x !== v)].slice(0, 8)
recent.value = list
localStorage.setItem(RECENT_KEY, JSON.stringify(list))
}
function clearRecent () {
recent.value = []
try { localStorage.removeItem(RECENT_KEY) } catch {}
}
loadRecent()
watch(query, (v) => {
const hasText = !!v?.trim()
if (hasText) { forcedOpen.value = false; showResults.value = true; return }
showResults.value = forcedOpen.value
})
function clearSearch () {
query.value = ''
activeIndex.value = -1
showResults.value = false
forcedOpen.value = false
}
function norm (s) {
return String(s || '').toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '').trim()
}
function isVisibleItem (it) {
const v = it?.visible
if (typeof v === 'function') return !!v()
if (v === undefined || v === null) return true
return v !== false
}
function flattenMenu (items, trail = []) {
const out = []
for (const it of (items || [])) {
if (!isVisibleItem(it)) continue
const nextTrail = [...trail, it?.label].filter(Boolean)
if (it?.to && !it?.items?.length) {
out.push({ label: it.label || it.to, to: it.to, icon: it.icon, trail: nextTrail, proBadge: !!(it.__showProBadge ?? it.proBadge), feature: it.feature || null })
}
if (it?.items?.length) out.push(...flattenMenu(it.items, nextTrail))
}
return out
}
const allLinks = computed(() => flattenMenu(menuStore.model || []))
const results = computed(() => {
const q = norm(query.value)
if (!q) return []
const wantPro = q === 'pro' || q.startsWith('pro ') || q.includes(' pro')
return allLinks.value
.filter(r => {
const hay = `${norm(r.label)} ${norm(r.trail.join(' > '))} ${norm(r.to)}`
if (hay.includes(q)) return true
if (wantPro && (r.proBadge || r.feature)) return true
return false
})
.slice(0, 12)
})
watch(results, (list) => { activeIndex.value = list.length ? 0 : -1 })
function escapeHtml (s) {
return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;')
}
function highlight (text, q) {
const queryNorm = norm(q)
const raw = String(text || '')
if (!queryNorm) return escapeHtml(raw)
const rawNorm = norm(raw)
const idx = rawNorm.indexOf(queryNorm)
if (idx < 0) return escapeHtml(raw)
const before = escapeHtml(raw.slice(0, idx))
const mid = escapeHtml(raw.slice(idx, idx + queryNorm.length))
const after = escapeHtml(raw.slice(idx + queryNorm.length))
return `${before}<mark class="px-1 rounded bg-yellow-200/40">${mid}</mark>${after}`
}
function onSearchKeydown (e) {
if (e.key === 'Escape') { showResults.value = false; forcedOpen.value = false; return }
if (e.key === 'ArrowDown') {
e.preventDefault()
if (!results.value.length) return
showResults.value = true
activeIndex.value = (activeIndex.value + 1) % results.value.length
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
if (!results.value.length) return
showResults.value = true
activeIndex.value = (activeIndex.value - 1 + results.value.length) % results.value.length
return
}
if (e.key === 'Enter' && showResults.value && results.value.length && activeIndex.value >= 0) {
e.preventDefault()
goToResult(results.value[activeIndex.value])
}
}
function onSearchFocus () {
if (!query.value?.trim()) { forcedOpen.value = true; showResults.value = true }
}
function applyRecent (q) {
query.value = q
forcedOpen.value = true
showResults.value = true
activeIndex.value = 0
nextTick(() => searchEl.value?.$el?.querySelector?.('input')?.focus?.())
}
function onDocMouseDown (e) {
if (!showResults.value) return
if (!searchWrapEl.value?.contains(e.target)) { showResults.value = false; forcedOpen.value = false }
}
onMounted(() => document.addEventListener('mousedown', onDocMouseDown))
onBeforeUnmount(() => document.removeEventListener('mousedown', onDocMouseDown))
async function goToResult (r) {
saveRecent(query.value)
query.value = ''
showResults.value = false
activeIndex.value = -1
forcedOpen.value = false
layoutState.activePath = typeof r.to === 'string' ? r.to : router.resolve(r.to).path
await router.push(r.to)
}
</script>
<template>
<Transition name="panel-slide">
<aside
v-if="layoutState.railPanelOpen && currentSection"
class="rp"
v-if="layoutState.railPanelOpen"
class="w-[260px] shrink-0 h-screen flex flex-col border-r border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden"
aria-label="Menu lateral"
>
<!-- Header -->
<div class="rp__head">
<span class="rp__title">{{ currentSection.label }}</span>
<button class="rp__close" aria-label="Fechar painel" @click="closePanel">
<div class="h-14 shrink-0 flex items-center justify-between px-4 border-b border-[var(--surface-border)]">
<span class="text-[0.9rem] font-bold tracking-tight text-[var(--text-color)]">
{{ panelTitle }}
</span>
<button
class="w-7 h-7 rounded-lg border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer grid place-items-center text-xs transition-colors hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)]"
aria-label="Fechar painel"
@click="closePanel"
>
<i class="pi pi-times" />
</button>
</div>
<!-- Items -->
<nav class="rp__nav">
<template v-for="item in sectionItems" :key="item.to || item.label">
<!-- Item com filhos (sub-seção) -->
<div v-if="item.items?.length" class="rp__group">
<div class="rp__group-label">{{ item.label }}</div>
<!-- Busca no Início -->
<div v-if="isHome" ref="searchWrapEl" class="shrink-0 px-2.5 pt-2.5 border-b border-[var(--surface-border)]">
<!-- Campo -->
<div class="relative">
<div aria-hidden="true" style="position:absolute;left:-9999px;top:-9999px;width:1px;height:1px;overflow:hidden;">
<input type="email" name="email" autocomplete="username" tabindex="-1" disabled />
<input type="password" name="password" autocomplete="current-password" tabindex="-1" disabled />
</div>
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText
ref="searchEl"
id="rp_menu_search"
name="rp_menu_search"
type="text"
inputmode="search"
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
data-lpignore="true"
data-1p-ignore="true"
v-model="query"
class="w-full pr-8"
variant="filled"
@focus="onSearchFocus"
@keydown="onSearchKeydown"
/>
</IconField>
<label for="rp_menu_search">Encontrar menu...</label>
</FloatLabel>
<button
v-if="query.trim()"
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 bg-transparent border-none cursor-pointer opacity-60 hover:opacity-100 text-[var(--text-color-secondary)] text-xs grid place-items-center p-0.5"
@mousedown.prevent="clearSearch"
aria-label="Limpar busca"
>
<i class="pi pi-times" />
</button>
</div>
<!-- Recentes -->
<div
v-if="showResults && !query.trim() && recent.length"
class="mt-1.5 mb-2.5 border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] shadow-sm overflow-hidden"
>
<div class="flex items-center justify-between px-2.5 py-1.5 text-[1rem] opacity-65 text-[var(--text-color-secondary)]">
<span>Recentes</span>
<button type="button" class="bg-transparent border-none cursor-pointer opacity-70 hover:opacity-100 text-inherit text-[1rem]" @mousedown.prevent="clearRecent" aria-label="Limpar recentes">
<i class="pi pi-trash" />
</button>
</div>
<button
v-for="q in recent" :key="q"
class="w-full flex items-center gap-2 px-2.5 py-1.5 bg-transparent border-none cursor-pointer text-[var(--text-color)] text-[1rem] text-left transition-colors hover:bg-[var(--surface-hover)]"
type="button"
@click.stop.prevent="applyRecent(q)"
>
<i class="pi pi-history text-[0.8rem] opacity-70 shrink-0" />
<span class="flex-1">{{ q }}</span>
</button>
</div>
<!-- Resultados de busca -->
<div
v-else-if="showResults && results.length"
class="mt-1.5 mb-2.5 border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] shadow-sm overflow-hidden"
>
<button
v-for="(r, i) in results" :key="String(r.to)"
type="button"
@mousedown.prevent="goToResult(r)"
class="w-full flex items-center gap-2 px-2.5 py-1.5 bg-transparent border-none cursor-pointer text-[var(--text-color)] text-[1rem] text-left transition-colors"
:class="i === activeIndex ? 'bg-[var(--surface-hover)]' : 'hover:bg-[var(--surface-hover)]'"
>
<i v-if="r.icon" :class="r.icon" class="text-[0.8rem] opacity-70 shrink-0" />
<div class="flex flex-col flex-1">
<span class="font-medium leading-tight" v-html="highlight(r.label, query)" />
<small class="text-[0.68rem] opacity-60">{{ r.trail.join(' > ') }}</small>
</div>
<span v-if="r.proBadge" class="text-[0.58rem] font-extrabold uppercase tracking-widest px-1.5 py-px rounded border border-[var(--surface-border)] text-[var(--text-color-secondary)] opacity-70">PRO</span>
</button>
</div>
<div v-else-if="showResults && query && !results.length" class="py-2 pb-2.5 text-[1rem] opacity-60 text-[var(--text-color-secondary)]">
Nenhum item encontrado.
</div>
<div v-else class="pb-2.5" />
</div>
<!-- Nav: todo o menu -->
<nav class="flex-1 overflow-y-auto px-2 py-2.5 flex flex-col gap-0.5 [scrollbar-width:thin] [scrollbar-color:var(--surface-border)_transparent]">
<template v-for="section in visibleSections" :key="section.label">
<!-- Label da seção exibe quando mostrando múltiplas seções -->
<div
v-if="visibleSections.length > 1"
class="text-[0.62rem] font-extrabold uppercase tracking-widest text-[var(--text-color-secondary)] opacity-55 px-2.5 pb-1.5 pt-3 first:pt-1"
>
{{ section.label }}
</div>
<template v-for="item in section.items" :key="item.to || item.label">
<!-- Sub-grupo -->
<div v-if="item.items?.length" class="flex flex-col gap-px">
<div class="text-[0.6rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] opacity-40 px-2.5 pb-1 pt-2">
{{ item.label }}
</div>
<button
v-for="child in item.items"
:key="child.to || child.label"
class="rp__item"
class="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-[9px] border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer text-left text-[0.83rem] font-medium transition-colors hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)]"
:class="{
'rp__item--active': isActive(child),
'rp__item--locked': isLocked(child)
'!bg-[color-mix(in_srgb,var(--primary-color)_10%,transparent)] !text-[var(--primary-color)] !font-semibold': isActive(child),
'opacity-55': isLocked(child)
}"
@click="navigate(child)"
>
<i v-if="child.icon" :class="child.icon" class="rp__item-icon" />
<span class="rp__item-label">{{ child.label }}</span>
<span v-if="isLocked(child)" class="rp__pro">PRO</span>
<i v-if="child.icon" :class="child.icon" class="text-[1rem] shrink-0 opacity-75" />
<span class="flex-1">{{ child.label }}</span>
<span v-if="isLocked(child)" class="text-[0.58rem] font-extrabold uppercase tracking-widest px-1.5 py-px rounded border border-[var(--surface-border)] text-[var(--text-color-secondary)] opacity-70">PRO</span>
</button>
</div>
<!-- Item folha -->
<button
v-else
class="rp__item"
class="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-[9px] border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer text-left text-[0.83rem] font-medium transition-colors hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)]"
:class="{
'rp__item--active': isActive(item),
'rp__item--locked': isLocked(item)
'!bg-[color-mix(in_srgb,var(--primary-color)_10%,transparent)] !text-[var(--primary-color)] !font-semibold': isActive(item),
'opacity-55': isLocked(item)
}"
@click="navigate(item)"
>
<i v-if="item.icon" :class="item.icon" class="rp__item-icon" />
<span class="rp__item-label">{{ item.label }}</span>
<span v-if="isLocked(item)" class="rp__pro">PRO</span>
<i v-if="item.icon" :class="item.icon" class="text-[1rem] shrink-0 opacity-75" />
<span class="flex-1">{{ item.label }}</span>
<span v-if="isLocked(item)" class="text-[0.58rem] font-extrabold uppercase tracking-widest px-1.5 py-px rounded border border-[var(--surface-border)] text-[var(--text-color-secondary)] opacity-70">PRO</span>
</button>
</template>
</template>
</nav>
</aside>
@@ -117,134 +398,9 @@ function closePanel () {
</template>
<style scoped>
/* ─── Panel ──────────────────────────────────────────────── */
.rp {
width: 260px;
flex-shrink: 0;
height: 100vh;
display: flex;
flex-direction: column;
border-right: 1px solid var(--surface-border);
background: var(--surface-card);
overflow: hidden;
}
/* ─── Header ─────────────────────────────────────────────── */
.rp__head {
height: 56px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
border-bottom: 1px solid var(--surface-border);
}
.rp__title {
font-size: 0.9rem;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--text-color);
}
.rp__close {
width: 28px;
height: 28px;
border-radius: 7px;
border: none;
background: transparent;
color: var(--text-color-secondary);
cursor: pointer;
display: grid;
place-items: center;
font-size: 0.75rem;
transition: background 0.15s, color 0.15s;
}
.rp__close:hover {
background: var(--surface-ground);
color: var(--text-color);
}
/* ─── Nav list ───────────────────────────────────────────── */
.rp__nav {
flex: 1;
overflow-y: auto;
padding: 10px 8px;
display: flex;
flex-direction: column;
gap: 2px;
scrollbar-width: thin;
scrollbar-color: var(--surface-border) transparent;
}
/* ─── Group ──────────────────────────────────────────────── */
.rp__group {
display: flex;
flex-direction: column;
gap: 1px;
margin-top: 12px;
}
.rp__group:first-child { margin-top: 0; }
.rp__group-label {
font-size: 0.62rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-color-secondary);
opacity: 0.55;
padding: 2px 10px 6px;
}
/* ─── Item ───────────────────────────────────────────────── */
.rp__item {
width: 100%;
display: flex;
align-items: center;
gap: 9px;
padding: 8px 10px;
border-radius: 9px;
border: none;
background: transparent;
color: var(--text-color-secondary);
cursor: pointer;
text-align: left;
font-size: 0.83rem;
font-weight: 500;
transition: background 0.13s, color 0.13s;
}
.rp__item:hover {
background: var(--surface-ground);
color: var(--text-color);
}
.rp__item--active {
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
color: var(--primary-color);
font-weight: 600;
}
.rp__item--locked {
opacity: 0.55;
}
.rp__item-icon {
font-size: 0.85rem;
flex-shrink: 0;
opacity: 0.75;
}
.rp__item-label { flex: 1; }
.rp__pro {
font-size: 0.58rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.1em;
padding: 1px 6px;
border-radius: 4px;
border: 1px solid var(--surface-border);
color: var(--text-color-secondary);
opacity: 0.7;
}
/* ─── Slide transition ───────────────────────────────────── */
.panel-slide-enter-active,
.panel-slide-leave-active {
transition: width 0.22s cubic-bezier(0.22, 1, 0.36, 1),
opacity 0.18s ease;
transition: width 0.22s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.18s ease;
overflow: hidden;
}
.panel-slide-enter-from,

View File

@@ -1,6 +1,6 @@
<!-- src/layout/AppRailSidebar.vue Drawer mobile para Layout Rail -->
<script setup>
import { computed, ref, watch } from 'vue'
import { computed, ref, watch, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useMenuStore } from '@/stores/menuStore'
@@ -37,6 +37,154 @@ watch(() => layoutState.mobileMenuActive, (open) => {
}
})
function expandAll () {
openSections.value = sections.value.map(s => s.key)
}
function collapseAll () {
openSections.value = []
}
// ── Busca ────────────────────────────────────────────────────
const query = ref('')
const showResults = ref(false)
const activeIndex = ref(-1)
const forcedOpen = ref(false)
const searchEl = ref(null)
const searchWrapEl = ref(null)
const RECENT_KEY = 'menu_search_recent'
const recent = ref([])
function loadRecent () {
try { recent.value = JSON.parse(localStorage.getItem(RECENT_KEY) || '[]') } catch { recent.value = [] }
}
function saveRecent (q) {
const v = String(q || '').trim()
if (!v) return
const list = [v, ...recent.value.filter(x => x !== v)].slice(0, 8)
recent.value = list
localStorage.setItem(RECENT_KEY, JSON.stringify(list))
}
function clearRecent () {
recent.value = []
try { localStorage.removeItem(RECENT_KEY) } catch {}
}
loadRecent()
watch(query, (v) => {
const hasText = !!v?.trim()
if (hasText) { forcedOpen.value = false; showResults.value = true; return }
showResults.value = forcedOpen.value
})
function clearSearch () {
query.value = ''
activeIndex.value = -1
showResults.value = false
forcedOpen.value = false
}
function norm (s) {
return String(s || '').toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '').trim()
}
function isVisibleItem (it) {
const v = it?.visible
if (typeof v === 'function') return !!v()
if (v === undefined || v === null) return true
return v !== false
}
function flattenMenu (items, trail = []) {
const out = []
for (const it of (items || [])) {
if (!isVisibleItem(it)) continue
const nextTrail = [...trail, it?.label].filter(Boolean)
if (it?.to && !it?.items?.length) {
out.push({ label: it.label || it.to, to: it.to, icon: it.icon, trail: nextTrail, proBadge: !!(it.__showProBadge ?? it.proBadge), feature: it.feature || null })
}
if (it?.items?.length) out.push(...flattenMenu(it.items, nextTrail))
}
return out
}
const allLinks = computed(() => flattenMenu(menuStore.model || []))
const results = computed(() => {
const q = norm(query.value)
if (!q) return []
const wantPro = q === 'pro' || q.startsWith('pro ') || q.includes(' pro')
return allLinks.value
.filter(r => {
const hay = `${norm(r.label)} ${norm(r.trail.join(' > '))} ${norm(r.to)}`
if (hay.includes(q)) return true
if (wantPro && (r.proBadge || r.feature)) return true
return false
})
.slice(0, 12)
})
watch(results, (list) => { activeIndex.value = list.length ? 0 : -1 })
function escapeHtml (s) {
return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;')
}
function highlight (text, q) {
const queryNorm = norm(q)
const raw = String(text || '')
if (!queryNorm) return escapeHtml(raw)
const rawNorm = norm(raw)
const idx = rawNorm.indexOf(queryNorm)
if (idx < 0) return escapeHtml(raw)
const before = escapeHtml(raw.slice(0, idx))
const mid = escapeHtml(raw.slice(idx, idx + queryNorm.length))
const after = escapeHtml(raw.slice(idx + queryNorm.length))
return `${before}<mark class="px-1 rounded bg-yellow-200/40">${mid}</mark>${after}`
}
function onSearchKeydown (e) {
if (e.key === 'Escape') { showResults.value = false; forcedOpen.value = false; return }
if (e.key === 'ArrowDown') {
e.preventDefault()
if (!results.value.length) return
showResults.value = true
activeIndex.value = (activeIndex.value + 1) % results.value.length
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
if (!results.value.length) return
showResults.value = true
activeIndex.value = (activeIndex.value - 1 + results.value.length) % results.value.length
return
}
if (e.key === 'Enter' && showResults.value && results.value.length && activeIndex.value >= 0) {
e.preventDefault()
goToResult(results.value[activeIndex.value])
}
}
function onSearchFocus () {
if (!query.value?.trim()) { forcedOpen.value = true; showResults.value = true }
}
function applyRecent (q) {
query.value = q
forcedOpen.value = true
showResults.value = true
activeIndex.value = 0
nextTick(() => searchEl.value?.$el?.querySelector?.('input')?.focus?.())
}
async function goToResult (r) {
saveRecent(query.value)
query.value = ''
showResults.value = false
activeIndex.value = -1
forcedOpen.value = false
await router.push(r.to)
hideMobileMenu()
}
function isSectionOpen (key) {
return openSections.value.includes(key)
}
@@ -99,6 +247,115 @@ watch(() => route.path, () => hideMobileMenu())
</button>
</div>
<!-- Busca + ações -->
<div ref="searchWrapEl" class="rs__search-area">
<!-- Campo de busca -->
<div class="rs__search-field">
<div
aria-hidden="true"
style="position:absolute; left:-9999px; top:-9999px; width:1px; height:1px; overflow:hidden;"
>
<input type="email" name="email" autocomplete="username" tabindex="-1" disabled />
<input type="password" name="password" autocomplete="current-password" tabindex="-1" disabled />
</div>
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText
ref="searchEl"
id="rs_menu_search"
name="rs_menu_search"
type="search"
inputmode="search"
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
data-lpignore="true"
data-1p-ignore="true"
v-model="query"
class="w-full pr-8"
variant="filled"
@focus="onSearchFocus"
@keydown="onSearchKeydown"
/>
</IconField>
<label for="rs_menu_search">Encontrar menu...</label>
</FloatLabel>
<button
v-if="query.trim()"
type="button"
class="rs__search-clear"
@mousedown.prevent="clearSearch"
aria-label="Limpar busca"
>
<i class="pi pi-times" />
</button>
</div>
<!-- Dropdown de resultados recentes -->
<div
v-if="showResults && !query.trim() && recent.length"
class="rs__dropdown"
>
<div class="rs__dropdown-header">
<span>Recentes</span>
<button type="button" class="rs__dropdown-action" @mousedown.prevent="clearRecent" aria-label="Limpar recentes">
<i class="pi pi-trash" />
</button>
</div>
<button
v-for="q in recent"
:key="q"
class="rs__dropdown-item"
type="button"
@click.stop.prevent="applyRecent(q)"
>
<i class="pi pi-history rs__dropdown-item-icon" />
<span class="flex-1">{{ q }}</span>
</button>
</div>
<!-- Dropdown de resultados de busca -->
<div
v-else-if="showResults && results.length"
class="rs__dropdown"
>
<button
v-for="(r, i) in results"
:key="String(r.to)"
type="button"
@mousedown.prevent="goToResult(r)"
:class="['rs__dropdown-item', i === activeIndex ? 'rs__dropdown-item--active' : '']"
>
<i v-if="r.icon" :class="r.icon" class="rs__dropdown-item-icon" />
<div class="flex flex-col flex-1">
<span class="rs__dropdown-item-label" v-html="highlight(r.label, query)" />
<small class="rs__dropdown-item-trail">{{ r.trail.join(' > ') }}</small>
</div>
<span v-if="r.proBadge" class="rs__pro">PRO</span>
</button>
</div>
<div v-else-if="showResults && query && !results.length" class="rs__no-results">
Nenhum item encontrado.
</div>
<!-- Botões expandir/contrair -->
<div class="rs__actions">
<button class="rs__action-btn" type="button" @click="expandAll">
<i class="pi pi-chevron-down" />
<span>Expandir tudo</span>
</button>
<button class="rs__action-btn" type="button" @click="collapseAll">
<i class="pi pi-chevron-up" />
<span>Contrair tudo</span>
</button>
</div>
</div>
<!-- Nav -->
<nav class="rs__nav">
<template v-for="section in sections" :key="section.key">
@@ -205,7 +462,123 @@ watch(() => route.path, () => hideMobileMenu())
color: var(--text-color);
}
/* ── Nav list ─────────────────────────────────────────────── */
/* ── Search area ──────────────────────────────────────────── */
.rs__search-area {
flex-shrink: 0;
padding: 10px 12px 0;
border-bottom: 1px solid var(--surface-border);
}
.rs__search-field {
position: relative;
}
.rs__search-clear {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
background: transparent;
border: none;
cursor: pointer;
opacity: 0.6;
color: var(--text-color-secondary);
font-size: 0.75rem;
display: grid;
place-items: center;
padding: 2px;
}
.rs__search-clear:hover { opacity: 1; }
/* ── Dropdown ─────────────────────────────────────────────── */
.rs__dropdown {
margin-top: 6px;
border: 1px solid var(--surface-border);
border-radius: 10px;
background: var(--surface-card);
box-shadow: 0 2px 8px rgba(0,0,0,.08);
overflow: hidden;
}
.rs__dropdown-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
font-size: 0.72rem;
opacity: 0.65;
color: var(--text-color-secondary);
}
.rs__dropdown-action {
background: transparent;
border: none;
cursor: pointer;
opacity: 0.7;
color: inherit;
font-size: 0.72rem;
}
.rs__dropdown-action:hover { opacity: 1; }
.rs__dropdown-item {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
background: transparent;
border: none;
cursor: pointer;
color: var(--text-color);
font-size: 0.82rem;
text-align: left;
transition: background 0.12s;
}
.rs__dropdown-item:hover,
.rs__dropdown-item--active { background: var(--surface-hover); }
.rs__dropdown-item-icon {
font-size: 0.82rem;
opacity: 0.7;
flex-shrink: 0;
}
.rs__dropdown-item-label { font-weight: 500; line-height: 1.3; }
.rs__dropdown-item-trail {
font-size: 0.7rem;
opacity: 0.6;
}
.rs__no-results {
padding: 8px 4px;
font-size: 0.8rem;
opacity: 0.6;
color: var(--text-color-secondary);
}
/* ── Botões expandir/contrair ─────────────────────────────── */
.rs__actions {
display: flex;
gap: 6px;
padding: 8px 0 10px;
}
.rs__action-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
padding: 5px 8px;
border-radius: 8px;
border: 1px solid var(--surface-border);
background: transparent;
color: var(--text-color-secondary);
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: background 0.13s, color 0.13s;
}
.rs__action-btn:hover {
background: var(--surface-ground);
color: var(--text-color);
}
.rs__action-btn .pi {
font-size: 0.65rem;
}
.rs__nav {
flex: 1;
overflow-y: auto;

View File

@@ -17,6 +17,12 @@ const { canSee } = useRoleGuard()
import { useAjuda } from '@/composables/useAjuda'
const { openDrawer: openAjudaDrawer, closeDrawer: closeAjudaDrawer, drawerOpen: ajudaDrawerOpen } = useAjuda()
import { useNotifications } from '@/composables/useNotifications'
import { useNotificationStore } from '@/stores/notificationStore'
import NotificationDrawer from '@/components/notifications/NotificationDrawer.vue'
const notificationStore = useNotificationStore()
useNotifications()
function toggleAjuda () {
ajudaDrawerOpen.value ? closeAjudaDrawer() : openAjudaDrawer()
}
@@ -585,6 +591,25 @@ onMounted(async () => {
:baseZIndex="3000"
/>
<!-- Notificações -->
<div class="relative">
<button
type="button"
class="rail-topbar__btn"
title="Notificações"
@click="notificationStore.drawerOpen = true"
>
<i class="pi pi-bell" />
<span
v-if="notificationStore.unreadCount > 0"
class="rail-topbar__notification-badge"
>
{{ notificationStore.unreadCount > 99 ? '99+' : notificationStore.unreadCount }}
</span>
</button>
<NotificationDrawer />
</div>
<!-- Ajuda -->
<button
type="button"
@@ -700,6 +725,27 @@ onMounted(async () => {
.config-panel {
z-index: 200;
}
/* Badge de notificações */
.rail-topbar__notification-badge {
position: absolute;
top: 0;
right: 0;
min-width: 1rem;
height: 1rem;
padding: 0 0.25rem;
border-radius: 999px;
background: #ef4444;
color: #fff;
font-size: 0.62rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
pointer-events: none;
transform: translate(25%, -25%);
}
.topbar-ctx-row {
display: flex;
align-items: center;

View File

@@ -24,7 +24,7 @@ const secoes = [
{
key: 'bloqueios',
label: 'Bloqueios',
desc: 'Feriados nacionais, municipais e períodos bloqueados para pacientes.',
desc: 'Feriados nacionais, municipais e períodos bloqueados.',
icon: 'pi pi-ban',
to: '/configuracoes/bloqueios',
tags: ['Feriados', 'Períodos', 'Recorrentes']
@@ -32,7 +32,7 @@ const secoes = [
{
key: 'agendador',
label: 'Agendador Online',
desc: 'Link público para pacientes solicitarem horários. Aprovação, identidade visual e pagamento.',
desc: 'Link público para pacientes solicitarem horários.',
icon: 'pi pi-calendar-clock',
to: '/configuracoes/agendador',
tags: ['PRO', 'Link', 'Pix', 'LGPD']
@@ -40,7 +40,7 @@ const secoes = [
{
key: 'pagamento',
label: 'Pagamento',
desc: 'Formas de pagamento aceitas: Pix, depósito bancário, dinheiro, cartão e convênio.',
desc: 'Formas de pagamento: Pix, depósito, dinheiro, cartão, convênio.',
icon: 'pi pi-wallet',
to: '/configuracoes/pagamento',
tags: ['Pix', 'TED', 'Cartão', 'Convênio']
@@ -48,7 +48,7 @@ const secoes = [
{
key: 'precificacao',
label: 'Precificação',
desc: 'Valor padrão da sessão e preços específicos por tipo de compromisso.',
desc: 'Valor padrão da sessão e preços por tipo de compromisso.',
icon: 'pi pi-tag',
to: '/configuracoes/precificacao',
tags: ['Valores', 'Sessão', 'Compromisso']
@@ -56,7 +56,7 @@ const secoes = [
{
key: 'descontos',
label: 'Descontos por Paciente',
desc: 'Descontos recorrentes aplicados automaticamente por paciente.',
desc: 'Descontos recorrentes aplicados automaticamente.',
icon: 'pi pi-percentage',
to: '/configuracoes/descontos',
tags: ['Desconto', 'Paciente', 'Automático']
@@ -64,7 +64,7 @@ const secoes = [
{
key: 'excecoes-financeiras',
label: 'Exceções Financeiras',
desc: 'O que cobrar em faltas, cancelamentos e outras situações excepcionais.',
desc: 'O que cobrar em faltas, cancelamentos e situações excepcionais.',
icon: 'pi pi-exclamation-triangle',
to: '/configuracoes/excecoes-financeiras',
tags: ['Falta', 'Cancelamento', 'Cobrança']
@@ -72,37 +72,11 @@ const secoes = [
{
key: 'convenios',
label: 'Convênios',
desc: 'Cadastre os convênios que você atende e seus valores de tabela.',
desc: 'Cadastre os convênios que você atende e seus valores.',
icon: 'pi pi-id-card',
to: '/configuracoes/convenios',
tags: ['Convênio', 'Plano de Saúde', 'Tabela']
},
// Ative quando criar as rotas/páginas
// {
// key: 'clinica',
// label: 'Clínica',
// desc: 'Padrões clínicos, status e preferências de atendimento.',
// icon: 'pi pi-heart',
// to: '/configuracoes/clinica',
// tags: ['Status', 'Modelos', 'Preferências']
// },
// {
// key: 'intake',
// label: 'Cadastros & Intake',
// desc: 'Link externo, campos do formulário e mensagens padrão.',
// icon: 'pi pi-file-edit',
// to: '/configuracoes/intake',
// tags: ['Formulário', 'Campos', 'Textos']
// },
// {
// key: 'conta',
// label: 'Conta',
// desc: 'Perfil, segurança e preferências da conta.',
// icon: 'pi pi-user',
// to: '/configuracoes/conta',
// tags: ['Perfil', 'Segurança', 'Preferências']
// }
]
const activeTo = computed(() => {
@@ -113,6 +87,8 @@ const activeTo = computed(() => {
return hit?.to || '/configuracoes/agenda'
})
const activeSecao = computed(() => secoes.find(s => s.to === activeTo.value))
function ir(to) {
if (!to) return
if (route.path !== to) router.push(to)
@@ -134,105 +110,87 @@ onBeforeUnmount(() => { _observer?.disconnect() })
<!-- Sentinel -->
<div ref="headerSentinelRef" class="cfg-sentinel" />
<!-- Hero sticky -->
<div ref="headerEl" class="cfg-hero mb-4" :class="{ 'cfg-hero--stuck': headerStuck }">
<!-- Hero compacto padrão Compromissos -->
<div ref="headerEl" class="cfg-hero mx-3 md:mx-4 mb-3" :class="{ 'cfg-hero--stuck': headerStuck }">
<div class="cfg-hero__blobs" aria-hidden="true">
<div class="cfg-hero__blob cfg-hero__blob--1" />
<div class="cfg-hero__blob cfg-hero__blob--2" />
<div class="cfg-hero__blob cfg-hero__blob--3" />
</div>
<div class="cfg-hero__row1">
<div class="cfg-hero__inner">
<!-- Brand -->
<div class="cfg-hero__brand">
<div class="cfg-hero__icon"><i class="pi pi-cog text-lg" /></div>
<div class="min-w-0">
<div class="cfg-hero__icon"><i class="pi pi-cog text-base" /></div>
<div class="min-w-0 lg:block">
<div class="cfg-hero__title">Configurações</div>
<div class="cfg-hero__sub">Defina como sua agenda e clínica funcionam</div>
</div>
</div>
<div class="hidden xl:flex items-center gap-2 shrink-0">
<Button label="Voltar" icon="pi pi-arrow-left" severity="secondary" outlined class="rounded-full" @click="router.back()" />
</div>
<div class="flex xl:hidden items-center shrink-0">
<Button icon="pi pi-arrow-left" severity="secondary" outlined class="h-9 w-9 rounded-full" @click="router.back()" />
<div class="cfg-hero__sub">
<span v-if="activeSecao">
<i :class="activeSecao.icon" class="text-xs mr-1 opacity-60" />{{ activeSecao.label }}
</span>
<span v-else>Configurações gerais</span>
</div>
</div>
</div>
<div class="pt-0">
<div class="grid grid-cols-12 gap-4">
<!-- SIDEBAR (seções) -->
<div class="col-span-12 lg:col-span-4 xl:col-span-3">
<Card class="h-full">
<template #title>
<div class="flex items-center gap-2">
<i class="pi pi-cog" />
<span>Seções</span>
<!-- Ações -->
<div class="cfg-hero__actions">
<Button icon="pi pi-arrow-left" severity="secondary" outlined class="h-9 w-9 rounded-full" v-tooltip.bottom="'Voltar'" @click="router.back()" />
</div>
</div>
</div>
</template>
<template #content>
<div class="flex flex-col gap-2">
<!-- Stats: seções como cards clicáveis -->
<div class="flex flex-wrap gap-2 px-3 md:px-4 mb-3">
<button
v-for="s in secoes"
:key="s.key"
type="button"
class="w-full text-left p-3 rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] hover:bg-[var(--surface-hover)] transition flex items-start justify-between gap-3"
:class="activeTo === s.to ? 'ring-1 ring-primary/40 border-primary/40' : ''"
class="cfg-sec-card"
:class="{ 'cfg-sec-card--active': activeTo === s.to }"
@click="ir(s.to)"
>
<div class="flex gap-3">
<div class="mt-1">
<i :class="[s.icon, 'text-lg']" style="opacity:.85" />
</div>
<div>
<div class="text-900 font-medium leading-none">{{ s.label }}</div>
<div class="text-600 text-sm mt-2 leading-snug">{{ s.desc }}</div>
<div v-if="s.tags?.length" class="mt-3 flex flex-wrap gap-2">
<span
v-for="t in s.tags"
:key="t"
class="text-xs px-2 py-1 rounded-full border border-[var(--surface-border)] text-600"
>
{{ t }}
</span>
</div>
</div>
</div>
<i class="pi pi-angle-right mt-1" style="opacity:.55" />
<i :class="s.icon" class="cfg-sec-card__icon" />
<span class="cfg-sec-card__label">{{ s.label }}</span>
</button>
<Divider class="my-2" />
<Button
label="Voltar"
icon="pi pi-arrow-left"
severity="secondary"
outlined
class="w-full md:hidden"
@click="router.back()"
/>
</div>
</template>
</Card>
</div>
<!-- CONTEÚDO (seção selecionada) -->
<div class="col-span-12 lg:col-span-8 xl:col-span-9">
<!-- Aqui entra /configuracoes/agenda etc -->
<!-- Layout: sidebar + conteúdo -->
<div class="flex flex-col xl:flex-row gap-3 px-3 md:px-4 pb-5 items-start">
<!-- Sidebar: lista de seções (oculto no mobile temos os cards acima) -->
<div class="hidden xl:flex flex-col gap-1 w-[260px] shrink-0">
<div class="cfg-sidebar-wrap">
<div class="cfg-sidebar-head">
<i class="pi pi-cog text-xs opacity-60" />
<span>Seções</span>
</div>
<div class="flex flex-col gap-0.5">
<button
v-for="s in secoes"
:key="s.key"
class="cfg-nav-item"
:class="{ 'cfg-nav-item--active': activeTo === s.to }"
@click="ir(s.to)"
>
<i :class="s.icon" class="cfg-nav-item__icon" />
<div class="cfg-nav-item__body">
<span class="cfg-nav-item__label">{{ s.label }}</span>
<span class="cfg-nav-item__desc">{{ s.desc }}</span>
</div>
<i class="pi pi-chevron-right cfg-nav-item__arrow" />
</button>
</div>
</div>
</div>
<!-- Conteúdo da seção -->
<div class="flex-1 min-w-0 w-full">
<router-view />
</div>
</div>
</div>
</template>
<style scoped>
/* ── Hero ─────────────────────────────────────────────── */
.cfg-sentinel { height: 1px; }
.cfg-hero {
@@ -240,36 +198,163 @@ onBeforeUnmount(() => { _observer?.disconnect() })
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border-radius: 6px;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.25rem 1.5rem;
padding: 10px 12px;
}
.cfg-hero--stuck {
margin-left: 0; margin-right: 0;
border-top-left-radius: 0; border-top-right-radius: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.cfg-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.cfg-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
.cfg-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(52,211,153,0.10); }
.cfg-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(99,102,241,0.09); }
.cfg-hero__blob--3 { width: 14rem; height: 14rem; bottom: -2rem; right: 8rem; background: rgba(217,70,239,0.07); }
.cfg-hero__blob { position: absolute; border-radius: 50%; filter: blur(60px); }
.cfg-hero__blob--1 { width: 16rem; height: 16rem; top: -4rem; right: -2rem; background: rgba(52,211,153,0.10); }
.cfg-hero__blob--2 { width: 18rem; height: 18rem; top: 0; left: -4rem; background: rgba(99,102,241,0.09); }
.cfg-hero__row1 {
.cfg-hero__inner {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1rem;
}
.cfg-hero__brand {
display: flex; align-items: center; gap: 0.75rem;
flex: 1; min-width: 0;
}
.cfg-hero__brand { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
.cfg-hero__icon {
display: grid; place-items: center;
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem; flex-shrink: 0;
width: 2.25rem; height: 2.25rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
}
.cfg-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
.cfg-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
.cfg-hero__title { font-size: 1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
.cfg-hero__sub { font-size: 0.75rem; color: var(--text-color-secondary); }
.cfg-hero__actions { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; }
/* Breadcrumb seção ativa */
.cfg-breadcrumb { display: flex; align-items: center; gap: 0.5rem; }
.cfg-breadcrumb__active {
display: inline-flex; align-items: center; gap: 0.4rem;
padding: 0.25rem 0.75rem; border-radius: 999px;
border: 1px solid var(--primary-color, #6366f1);
background: color-mix(in srgb, var(--primary-color) 8%, transparent);
color: var(--primary-color, #6366f1);
font-size: 0.8rem; font-weight: 600;
}
/* ── Cards de seção (stats row) ────────────────────────── */
.cfg-sec-card {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 0.875rem;
border-radius: 6px;
border: 1px solid var(--surface-border);
background: var(--surface-card);
cursor: pointer;
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
white-space: nowrap;
}
.cfg-sec-card:hover {
border-color: color-mix(in srgb, var(--primary-color) 40%, transparent);
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.cfg-sec-card--active {
border-color: var(--primary-color, #6366f1);
background: color-mix(in srgb, var(--primary-color) 8%, var(--surface-card));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 12%, transparent);
}
.cfg-sec-card__icon {
font-size: 0.78rem;
color: var(--text-color-secondary);
opacity: 0.75;
}
.cfg-sec-card--active .cfg-sec-card__icon {
color: var(--primary-color, #6366f1);
opacity: 1;
}
.cfg-sec-card__label {
font-size: 0.78rem;
font-weight: 600;
color: var(--text-color);
}
.cfg-sec-card--active .cfg-sec-card__label {
color: var(--primary-color, #6366f1);
}
/* ── Sidebar nav ──────────────────────────────────────── */
.cfg-sidebar-wrap {
border: 1px solid var(--surface-border);
border-radius: 6px;
background: var(--surface-card);
overflow: hidden;
}
.cfg-sidebar-head {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.625rem 0.875rem;
border-bottom: 1px solid var(--surface-border);
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-color-secondary);
opacity: 0.65;
}
.cfg-nav-item {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.625rem 0.875rem;
border: none;
background: transparent;
cursor: pointer;
transition: background 0.12s;
width: 100%;
text-align: left;
border-bottom: 1px solid var(--surface-border);
}
.cfg-nav-item:last-child { border-bottom: none; }
.cfg-nav-item:hover { background: var(--surface-hover); }
.cfg-nav-item--active {
background: color-mix(in srgb, var(--primary-color) 6%, var(--surface-card));
}
.cfg-nav-item__icon {
font-size: 0.85rem;
color: var(--text-color-secondary);
opacity: 0.6;
flex-shrink: 0;
width: 16px;
text-align: center;
}
.cfg-nav-item--active .cfg-nav-item__icon {
color: var(--primary-color, #6366f1);
opacity: 1;
}
.cfg-nav-item__body {
flex: 1; min-width: 0;
display: flex; flex-direction: column; gap: 1px;
}
.cfg-nav-item__label {
font-size: 0.82rem;
font-weight: 600;
color: var(--text-color);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.cfg-nav-item--active .cfg-nav-item__label {
color: var(--primary-color, #6366f1);
}
.cfg-nav-item__desc {
font-size: 0.7rem;
color: var(--text-color-secondary);
opacity: 0.7;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.cfg-nav-item__arrow {
font-size: 0.6rem;
color: var(--text-color-secondary);
opacity: 0.3;
flex-shrink: 0;
}
.cfg-nav-item--active .cfg-nav-item__arrow {
color: var(--primary-color, #6366f1);
opacity: 0.6;
}
</style>

View File

@@ -1,4 +1,4 @@
import { computed, reactive } from 'vue'
import { computed, reactive, ref, onMounted, onBeforeUnmount } from 'vue'
// ── resolve variant salvo no localStorage ───────────────────
function _loadVariant () {
@@ -55,6 +55,14 @@ function syncDarkFromDomOnce () {
} catch {}
}
// ── reactive mobile state (atualiza no resize) ───────────────
const _isMobileRef = ref(typeof window !== 'undefined' ? window.innerWidth <= 1200 : false)
if (typeof window !== 'undefined') {
const _onResize = () => { _isMobileRef.value = window.innerWidth <= 1200 }
window.addEventListener('resize', _onResize, { passive: true })
}
export function useLayout () {
// ✅ garante coerência sempre que alguém usar useLayout()
syncDarkFromDomOnce()
@@ -82,13 +90,13 @@ export function useLayout () {
const isRailMobile = () => window.innerWidth <= 1200
const toggleMenu = () => {
// No Rail, o botão hamburguer (≤1200px) controla a sidebar mobile
if (layoutConfig.variant === 'rail') {
// No Rail, em desktop, o botão hamburguer controla a sidebar mobile do rail
if (layoutConfig.variant === 'rail' && !_isMobileRef.value) {
layoutState.mobileMenuActive = !layoutState.mobileMenuActive
return
}
// Layout clássico — comportamento original
// Layout clássico (ou mobile com qualquer variant) — comportamento original
if (isDesktop()) {
if (layoutConfig.menuMode === 'static') {
layoutState.staticMenuInactive = !layoutState.staticMenuInactive
@@ -160,6 +168,13 @@ export function useLayout () {
const isDarkTheme = computed(() => layoutConfig.darkTheme)
const hasOpenOverlay = computed(() => layoutState.overlayMenuActive)
// ── Em mobile (≤ 1200px) sempre usa o layout clássico, ───────
// independente de layoutConfig.variant
const isMobile = computed(() => _isMobileRef.value)
const effectiveVariant = computed(() =>
_isMobileRef.value ? 'classic' : layoutConfig.variant
)
return {
layoutConfig,
layoutState,
@@ -173,6 +188,8 @@ export function useLayout () {
setVariant,
isDesktop,
isRailMobile,
isMobile,
effectiveVariant,
hasOpenOverlay
}
}

View File

@@ -267,94 +267,81 @@ const loading = computed(() => loadingF.value || loadingB.value)
<template>
<Toast />
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3">
<!-- Cabeçalho do ano -->
<div class="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] px-5 py-4">
<div>
<div class="font-semibold text-base">Bloqueios da agenda</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">
Feriados e períodos em que não é possível agendar com pacientes.
<!-- Subheader degradê -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-ban" /></div>
<div class="min-w-0 flex-1">
<div class="cfg-subheader__title">Bloqueios</div>
<div class="cfg-subheader__sub">Feriados e períodos em que não é possível agendar com pacientes</div>
</div>
</div>
<div class="flex items-center gap-2">
<Button icon="pi pi-chevron-left" text rounded severity="secondary" @click="anoAnterior" />
<span class="font-bold text-lg w-14 text-center">{{ ano }}</span>
<Button icon="pi pi-chevron-right" text rounded severity="secondary" @click="anoProximo" />
<!-- Nav de ano -->
<div class="flex items-center gap-1 shrink-0 relative z-10">
<Button icon="pi pi-chevron-left" text rounded size="small" severity="secondary" @click="anoAnterior" />
<span class="font-bold text-sm w-12 text-center text-[var(--primary-color)]">{{ ano }}</span>
<Button icon="pi pi-chevron-right" text rounded size="small" severity="secondary" @click="anoProximo" />
</div>
</div>
<!-- Stats rápidos -->
<div class="grid grid-cols-3 gap-3">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 text-center">
<div class="text-2xl font-bold text-blue-500">{{ nacionais.length }}</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Feriados nacionais</div>
<!-- Stats + ações -->
<div class="flex flex-wrap items-center gap-2">
<div class="blk-stat blk-stat--blue">
<div class="blk-stat__value">{{ nacionais.length }}</div>
<div class="blk-stat__label">Nacionais</div>
</div>
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 text-center">
<div class="text-2xl font-bold text-orange-500">{{ municipais.length }}</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Feriados municipais</div>
<div class="blk-stat blk-stat--orange">
<div class="blk-stat__value">{{ municipais.length }}</div>
<div class="blk-stat__label">Municipais</div>
</div>
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 text-center">
<div class="text-2xl font-bold text-red-500">{{ bloqueios.length }}</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Bloqueios</div>
<div class="blk-stat blk-stat--red">
<div class="blk-stat__value">{{ bloqueios.length }}</div>
<div class="blk-stat__label">Bloqueios</div>
</div>
<div class="ml-auto flex gap-2">
<Button icon="pi pi-map-marker" label="Feriado municipal" severity="secondary" outlined class="rounded-full" size="small" @click="abrirFeriadoMunicipal" />
<Button icon="pi pi-ban" label="Novo bloqueio" class="rounded-full" size="small" @click="abrirAddBloqueio" />
</div>
</div>
<!-- Ações -->
<div class="flex flex-wrap gap-2">
<Button
icon="pi pi-map-marker"
label="Adicionar feriado municipal"
severity="secondary"
outlined
class="rounded-full"
@click="abrirFeriadoMunicipal"
/>
<Button
icon="pi pi-ban"
label="Adicionar bloqueio"
class="rounded-full"
@click="abrirAddBloqueio"
/>
</div>
<!-- Loading -->
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center py-16">
<i class="pi pi-spinner pi-spin text-2xl opacity-40" />
</div>
<template v-else>
<!-- Feriados Nacionais (somente leitura) -->
<!-- Feriados Nacionais -->
<div class="blk-group">
<div class="blk-group__head">
<i class="pi pi-flag text-blue-500" />
<div class="blk-group__head-icon" style="background:color-mix(in srgb,#3b82f6 12%,transparent);color:#3b82f6">
<i class="pi pi-flag" />
</div>
<span>Feriados Nacionais</span>
<span class="blk-group__count">{{ nacionais.length }}</span>
<span class="ml-auto mr-0 text-xs text-[var(--text-color-secondary)] font-normal">gerado automaticamente</span>
<span class="ml-auto text-xs text-[var(--text-color-secondary)] opacity-60 font-normal">gerado automaticamente</span>
</div>
<div class="blk-list">
<div v-for="f in nacionais" :key="f.data + f.nome" class="blk-item">
<div class="blk-item__date">{{ fmtDateShort(f.data) }}</div>
<div class="blk-item__title">{{ f.nome }}</div>
<Tag v-if="f.movel" value="Móvel" severity="secondary" class="text-xs shrink-0" />
<Tag v-if="f.movel" value="Móvel" severity="secondary" class="text-xs shrink-0 ml-auto" />
</div>
</div>
</div>
<!-- Feriados Municipais -->
<!-- Feriados Municipais -->
<div class="blk-group">
<div class="blk-group__head">
<i class="pi pi-map-marker text-orange-500" />
<div class="blk-group__head-icon" style="background:color-mix(in srgb,#f97316 12%,transparent);color:#f97316">
<i class="pi pi-map-marker" />
</div>
<span>Feriados Municipais</span>
<span class="blk-group__count">{{ municipais.length }}</span>
</div>
<div v-if="!municipais.length" class="blk-empty">
Nenhum feriado municipal cadastrado para {{ ano }}.
</div>
<div v-else class="blk-list">
<div v-for="f in municipais" :key="f.id" class="blk-item">
<div class="blk-item__date">{{ fmtDate(f.data) }}</div>
@@ -367,23 +354,23 @@ const loading = computed(() => loadingF.value || loadingB.value)
</div>
</div>
<!-- Bloqueios -->
<!-- Bloqueios -->
<div class="blk-group">
<div class="blk-group__head">
<i class="pi pi-ban text-red-500" />
<div class="blk-group__head-icon" style="background:color-mix(in srgb,#ef4444 12%,transparent);color:#ef4444">
<i class="pi pi-ban" />
</div>
<span>Bloqueios</span>
<span class="blk-group__count">{{ bloqueios.length }}</span>
</div>
<div v-if="!bloqueios.length" class="blk-empty">
Nenhum bloqueio cadastrado para {{ ano }}.
</div>
<div v-else class="blk-list">
<div v-for="b in bloqueios" :key="b.id" class="blk-item">
<div class="blk-item__date">{{ fmtPeriodo(b) }}</div>
<div class="blk-item__title">{{ b.titulo }}</div>
<Tag v-if="b.recorrente" value="Recorrente" severity="warn" class="text-xs" />
<Tag v-if="b.recorrente" value="Recorrente" severity="warn" class="text-xs shrink-0" />
<div v-if="b.observacao" class="blk-item__obs">{{ b.observacao }}</div>
<div class="blk-item__actions">
<Button icon="pi pi-pencil" text rounded size="small" severity="secondary" @click="abrirEditBloqueio(b)" />
@@ -396,222 +383,180 @@ const loading = computed(() => loadingF.value || loadingB.value)
</template>
</div>
<!-- Dialog feriado municipal -->
<Dialog
v-model:visible="fdlgOpen"
modal
:draggable="false"
header="Cadastrar feriado municipal"
:style="{ width: '420px' }"
>
<!-- Dialog feriado municipal -->
<Dialog v-model:visible="fdlgOpen" modal :draggable="false" pt:mask:class="backdrop-blur-xs" header="Cadastrar feriado municipal" :style="{ width: '420px', maxWidth: '95vw' }">
<div class="flex flex-col gap-4 pt-1">
<div>
<label class="blk-label">Nome do feriado *</label>
<InputText v-model="fform.nome" class="w-full mt-1" placeholder="Ex.: Aniversário da cidade, Padroeiro…" />
</div>
<div>
<label class="blk-label">Data *</label>
<DatePicker
v-model="fform.data"
showIcon fluid iconDisplay="input"
dateFormat="dd/mm/yy"
:manualInput="false"
class="mt-1"
>
<DatePicker v-model="fform.data" showIcon fluid iconDisplay="input" dateFormat="dd/mm/yy" :manualInput="false" class="mt-1">
<template #inputicon="sp"><i class="pi pi-calendar" @click="sp.clickCallback" /></template>
</DatePicker>
</div>
<div>
<label class="blk-label">Observação <span class="opacity-60">(opcional)</span></label>
<Textarea v-model="fform.observacao" class="w-full mt-1" rows="2" autoResize placeholder="Nota interna…" />
</div>
<div v-if="fform.data && fform.nome && isDuplicata(dateToISO(fform.data), fform.nome)"
class="text-sm text-red-500 flex items-center gap-2">
<i class="pi pi-exclamation-triangle" />
existe um feriado com esse nome nessa data.
<div v-if="fform.data && fform.nome && isDuplicata(dateToISO(fform.data), fform.nome)" class="text-sm text-red-500 flex items-center gap-2">
<i class="pi pi-exclamation-triangle" /> existe um feriado com esse nome nessa data.
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined @click="fdlgOpen = false" />
<Button
label="Cadastrar"
icon="pi pi-check"
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" @click="fdlgOpen = false" />
<Button label="Cadastrar" icon="pi pi-check" class="rounded-full"
:disabled="!fformValid || (fform.data && fform.nome && isDuplicata(dateToISO(fform.data), fform.nome))"
:loading="fsaving"
@click="salvarFeriado"
/>
:loading="fsaving" @click="salvarFeriado" />
</template>
</Dialog>
<!-- Dialog bloqueio add/edit -->
<Dialog
v-model:visible="dlgOpen"
modal
:draggable="false"
<!-- Dialog bloqueio -->
<Dialog v-model:visible="dlgOpen" modal :draggable="false" pt:mask:class="backdrop-blur-xs"
:header="dlgMode === 'edit' ? 'Editar bloqueio' : 'Novo bloqueio'"
:style="{ width: '480px' }"
>
:style="{ width: '480px', maxWidth: '95vw' }">
<div class="flex flex-col gap-4 pt-1">
<div>
<label class="blk-label">Título *</label>
<InputText v-model="form.titulo" class="w-full mt-1" placeholder="Ex.: Recesso, Férias, Licença…" />
</div>
<div class="flex gap-3">
<div class="flex-1">
<label class="blk-label">Data início *</label>
<DatePicker
v-model="form.data_inicio"
showIcon fluid iconDisplay="input"
dateFormat="dd/mm/yy"
:manualInput="false"
class="mt-1"
>
<DatePicker v-model="form.data_inicio" showIcon fluid iconDisplay="input" dateFormat="dd/mm/yy" :manualInput="false" class="mt-1">
<template #inputicon="sp"><i class="pi pi-calendar" @click="sp.clickCallback" /></template>
</DatePicker>
</div>
<div class="flex-1">
<label class="blk-label">Data fim <span class="opacity-60">(opcional)</span></label>
<DatePicker
v-model="form.data_fim"
showIcon fluid iconDisplay="input"
dateFormat="dd/mm/yy"
:manualInput="false"
:minDate="form.data_inicio || undefined"
class="mt-1"
>
<DatePicker v-model="form.data_fim" showIcon fluid iconDisplay="input" dateFormat="dd/mm/yy" :manualInput="false" :minDate="form.data_inicio || undefined" class="mt-1">
<template #inputicon="sp"><i class="pi pi-calendar" @click="sp.clickCallback" /></template>
</DatePicker>
</div>
</div>
<div class="flex gap-3">
<div class="flex-1">
<label class="blk-label">Hora início <span class="opacity-60">(opcional)</span></label>
<DatePicker
v-model="form.hora_inicio"
showIcon fluid iconDisplay="input"
timeOnly hourFormat="24" :stepMinute="15" :manualInput="false"
class="mt-1"
>
<DatePicker v-model="form.hora_inicio" showIcon fluid iconDisplay="input" timeOnly hourFormat="24" :stepMinute="15" :manualInput="false" class="mt-1">
<template #inputicon="sp"><i class="pi pi-clock" @click="sp.clickCallback" /></template>
</DatePicker>
</div>
<div class="flex-1">
<label class="blk-label">Hora fim</label>
<DatePicker
v-model="form.hora_fim"
showIcon fluid iconDisplay="input"
timeOnly hourFormat="24" :stepMinute="15" :manualInput="false"
class="mt-1"
>
<label class="blk-label">Hora fim <span class="opacity-60">(opcional)</span></label>
<DatePicker v-model="form.hora_fim" showIcon fluid iconDisplay="input" timeOnly hourFormat="24" :stepMinute="15" :manualInput="false" class="mt-1">
<template #inputicon="sp"><i class="pi pi-clock" @click="sp.clickCallback" /></template>
</DatePicker>
</div>
</div>
<div>
<label class="blk-label">Observação <span class="opacity-60">(opcional)</span></label>
<Textarea v-model="form.observacao" class="w-full mt-1" rows="2" autoResize placeholder="Nota interna…" />
<Textarea v-model="form.observacao" class="w-full mt-1" rows="2" autoResize />
</div>
</div>
<template #footer>
<Button label="Cancelar" icon="pi pi-times" severity="secondary" outlined @click="dlgOpen = false" />
<Button
:label="dlgMode === 'edit' ? 'Salvar' : 'Adicionar'"
icon="pi pi-check"
:disabled="!formValid"
:loading="saving"
@click="salvarBloqueio"
/>
<Button label="Cancelar" icon="pi pi-times" severity="secondary" outlined class="rounded-full" @click="dlgOpen = false" />
<Button :label="dlgMode === 'edit' ? 'Salvar' : 'Adicionar'" icon="pi pi-check" class="rounded-full"
:disabled="!formValid" :loading="saving" @click="salvarBloqueio" />
</template>
</Dialog>
</template>
<style scoped>
/* ── Grupos ──────────────────────────────────────────────── */
/* ── Subheader degradê ────────────────────────────── */
.cfg-subheader {
display: flex; align-items: center; gap: 0.65rem;
padding: 0.875rem 1rem; border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--primary-color, #6366f1) 30%, transparent);
background: linear-gradient(135deg,
color-mix(in srgb, var(--primary-color, #6366f1) 12%, var(--surface-card)) 0%,
color-mix(in srgb, var(--primary-color, #6366f1) 4%, var(--surface-card)) 60%,
var(--surface-card) 100%);
position: relative; overflow: hidden;
}
.cfg-subheader::before {
content: ''; position: absolute;
top: -20px; right: -20px; width: 80px; height: 80px; border-radius: 50%;
background: color-mix(in srgb, var(--primary-color, #6366f1) 15%, transparent);
filter: blur(20px); pointer-events: none;
}
.cfg-subheader__icon {
display: grid; place-items: center;
width: 2rem; height: 2rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color, #6366f1) 20%, transparent);
color: var(--primary-color, #6366f1); font-size: 0.85rem;
}
.cfg-subheader__title { font-size: 0.95rem; font-weight: 700; color: var(--primary-color, #6366f1); letter-spacing: -0.01em; }
.cfg-subheader__sub { font-size: 0.75rem; color: var(--text-color-secondary); opacity: 0.85; }
/* ── Stats ────────────────────────────────────────── */
.blk-stat {
display: flex; flex-direction: column; gap: 0.1rem;
padding: 0.5rem 0.875rem; border-radius: 6px;
border: 1px solid var(--surface-border);
background: var(--surface-card); min-width: 72px;
}
.blk-stat__value { font-size: 1.35rem; font-weight: 700; line-height: 1; }
.blk-stat__label { font-size: 0.7rem; color: var(--text-color-secondary); opacity: 0.75; }
.blk-stat--blue .blk-stat__value { color: #3b82f6; }
.blk-stat--orange .blk-stat__value { color: #f97316; }
.blk-stat--red .blk-stat__value { color: #ef4444; }
/* ── Grupos ──────────────────────────────────────── */
.blk-group {
border-radius: 1.25rem;
border-radius: 6px;
border: 1px solid var(--surface-border);
background: var(--surface-card);
overflow: hidden;
}
.blk-group__head {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.875rem 1.25rem;
display: flex; align-items: center; gap: 0.5rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--surface-border);
font-weight: 600;
font-size: 0.9rem;
font-weight: 600; font-size: 0.88rem;
background: var(--surface-ground);
}
.blk-group__head-icon {
display: grid; place-items: center;
width: 1.75rem; height: 1.75rem; border-radius: 6px; flex-shrink: 0;
font-size: 0.78rem;
}
.blk-group__count {
font-size: 0.75rem;
background: var(--surface-ground);
font-size: 0.7rem;
background: var(--surface-card);
border: 1px solid var(--surface-border);
border-radius: 999px;
padding: 1px 8px;
border-radius: 999px; padding: 1px 8px;
color: var(--text-color-secondary);
}
/* ── Itens ───────────────────────────────────────────────── */
/* ── Itens ──────────────────────────────────────── */
.blk-empty {
padding: 1.25rem;
font-size: 0.875rem;
color: var(--text-color-secondary);
}
.blk-list {
display: flex;
flex-direction: column;
}
.blk-list { display: flex; flex-direction: column; }
.blk-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1.25rem;
display: flex; align-items: center; gap: 0.75rem;
padding: 0.625rem 1rem;
border-bottom: 1px solid var(--surface-border);
flex-wrap: wrap;
flex-wrap: wrap; transition: background 0.1s;
}
.blk-item:last-child { border-bottom: none; }
.blk-item:hover { background: var(--surface-hover); }
.blk-item__date {
font-size: 0.8rem;
color: var(--text-color-secondary);
white-space: nowrap;
min-width: 5.5rem;
font-size: 0.75rem; color: var(--text-color-secondary);
white-space: nowrap; min-width: 5.5rem;
font-variant-numeric: tabular-nums;
}
.blk-item__title {
flex: 1;
font-weight: 500;
font-size: 0.875rem;
min-width: 0;
}
.blk-item__title { flex: 1; font-weight: 500; font-size: 0.85rem; min-width: 0; }
.blk-item__obs {
font-size: 0.75rem;
color: var(--text-color-secondary);
width: 100%;
padding-left: 6.25rem;
margin-top: -0.25rem;
}
.blk-item__actions {
display: flex;
gap: 0.25rem;
margin-left: auto;
font-size: 0.72rem; color: var(--text-color-secondary);
width: 100%; padding-left: 6.25rem; margin-top: -0.25rem;
}
.blk-item__actions { display: flex; gap: 0.25rem; margin-left: auto; }
/* ── Dialog ──────────────────────────────────────────────── */
.blk-label {
font-size: 0.75rem;
color: var(--text-color-secondary);
font-weight: 500;
}
/* ── Dialog labels ──────────────────────────────── */
.blk-label { font-size: 0.75rem; color: var(--text-color-secondary); font-weight: 500; }
</style>

View File

@@ -796,6 +796,15 @@ const jornadaEndDate = computed({
<!-- COLUNA ESQUERDA: CARDS -->
<div class="flex flex-col gap-3 xl:w-[58%]">
<!-- Subheader -->
<div class="cfg-subheader">
<i class="pi pi-calendar cfg-subheader__icon" />
<div class="min-w-0">
<div class="cfg-subheader__title">Agenda</div>
<div class="cfg-subheader__sub">Horários semanais, duração e intervalo padrão</div>
</div>
</div>
<!-- CARD 1: JORNADA -->
<div class="cfg-card" :class="{ 'cfg-card--open': expandedCard === 'jornada' }">
@@ -952,7 +961,7 @@ const jornadaEndDate = computed({
<div
v-for="d in selectedDays"
:key="d.value"
class="flex items-center gap-3 p-2 rounded-xl bg-[var(--surface-ground)]"
class="flex items-center gap-3 p-2 rounded-[6px] bg-[var(--surface-ground)]"
>
<span class="w-10 text-sm font-medium">{{ d.short }}</span>
<div class="w-32">
@@ -1060,7 +1069,7 @@ const jornadaEndDate = computed({
</div>
<!-- Campos manuais (personalizado) -->
<div v-if="showAdvancedRitmo || !durationPresets.some(p => isActivePreset(p))" class="mb-5 p-4 rounded-2xl bg-[var(--surface-ground)]">
<div v-if="showAdvancedRitmo || !durationPresets.some(p => isActivePreset(p))" class="mb-5 p-4 rounded-[6px] bg-[var(--surface-ground)]">
<div class="cfg-label mb-3">Personalizado</div>
<div class="flex flex-row gap-6">
<div class="flex flex-col gap-1">
@@ -1121,7 +1130,7 @@ const jornadaEndDate = computed({
<div class="border-t border-[var(--surface-border)] pt-4">
<!-- Aviso slots órfãos -->
<div v-if="orphanSlotDays.length" class="flex items-start gap-2 mb-4 p-3 rounded-xl bg-[var(--yellow-50)] border border-[var(--yellow-200)] text-sm text-[var(--yellow-800)]">
<div v-if="orphanSlotDays.length" class="flex items-start gap-2 mb-4 p-3 rounded-[6px] bg-[var(--yellow-50)] border border-[var(--yellow-200)] text-sm text-[var(--yellow-800)]">
<i class="pi pi-exclamation-triangle mt-0.5 shrink-0" />
<span>
slots configurados para <b>{{ orphanSlotDays.join(', ') }}</b>, mas esses dias não estão mais na sua jornada.
@@ -1130,7 +1139,7 @@ const jornadaEndDate = computed({
</div>
<!-- Toggle ativo -->
<div class="flex items-center justify-between mb-5 p-4 rounded-2xl bg-[var(--surface-ground)]">
<div class="flex items-center justify-between mb-5 p-4 rounded-[6px] bg-[var(--surface-ground)]">
<div>
<div class="font-medium">Permitir que pacientes agendem online</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">
@@ -1172,7 +1181,7 @@ const jornadaEndDate = computed({
<template v-if="previewDay != null">
<!-- Área cinza: ações rápidas + slots -->
<div class="mx-3 mb-2 p-3 rounded-xl bg-[var(--surface-ground)]">
<div class="mx-3 mb-2 p-3 rounded-[6px] bg-[var(--surface-ground)]">
<div v-if="(slotsByDay[previewDay] || []).length === 0" class="text-sm text-[var(--text-color-secondary)] py-1">
Nenhum slot disponível para este dia. Configure a jornada primeiro.
</div>
@@ -1242,7 +1251,7 @@ const jornadaEndDate = computed({
<!-- COLUNA DIREITA: PREVIEW -->
<div class="xl:w-[42%] xl:sticky xl:top-4 xl:self-start">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden shadow-sm">
<div class="rounded-[6px] border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden shadow-sm">
<!-- Header do preview -->
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border)]">
@@ -1307,7 +1316,7 @@ const jornadaEndDate = computed({
<style scoped>
/* ── Cards ─────────────────────────────────────────────────── */
.cfg-card {
border-radius: 1.25rem;
border-radius: 6px;
border: 1px solid var(--surface-border);
background: var(--surface-card);
overflow: hidden;
@@ -1508,4 +1517,52 @@ const jornadaEndDate = computed({
.toggle-switch--on .toggle-switch__thumb {
transform: translateX(1.25rem);
}
/* ── Subheader de seção ──────────────────────────────── */
.cfg-subheader {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.875rem 1rem;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--primary-color, #6366f1) 30%, transparent);
background: linear-gradient(
135deg,
color-mix(in srgb, var(--primary-color, #6366f1) 12%, var(--surface-card)) 0%,
color-mix(in srgb, var(--primary-color, #6366f1) 4%, var(--surface-card)) 60%,
var(--surface-card) 100%
);
overflow: hidden;
position: relative;
}
/* Brilho sutil no canto */
.cfg-subheader::before {
content: '';
position: absolute;
top: -20px; right: -20px;
width: 80px; height: 80px;
border-radius: 50%;
background: color-mix(in srgb, var(--primary-color, #6366f1) 15%, transparent);
filter: blur(20px);
pointer-events: none;
}
.cfg-subheader__icon {
display: grid; place-items: center;
width: 2rem; height: 2rem;
border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color, #6366f1) 20%, transparent);
color: var(--primary-color, #6366f1);
font-size: 0.85rem;
}
.cfg-subheader__title {
font-size: 0.95rem;
font-weight: 700;
color: var(--primary-color, #6366f1);
letter-spacing: -0.01em;
}
.cfg-subheader__sub {
font-size: 0.75rem;
color: var(--text-color-secondary);
opacity: 0.85;
}
</style>

View File

@@ -17,7 +17,7 @@ const hasLinkPersonalizado = computed(() => entitlements.can('agendador.link_per
// ── Estado ─────────────────────────────────────────────────────
const loading = ref(true)
const ownerId = ref(null)
const expandedCard = ref(null)
const expandedCard = ref(new Set())
const savingCard = ref(null)
// ── Upload de imagens ────────────────────────────────────────────
@@ -62,8 +62,8 @@ async function onFileSelected (event, field) {
// ── Expand / Collapse all ────────────────────────────────────────
const CARDS = ['identidade', 'perfil', 'fluxo', 'pagamento', 'triagem', 'textos']
function expandAll () { expandedCard.value = CARDS[0] } // abre o primeiro como ponto de entrada
function collapseAll () { expandedCard.value = null }
function expandAll () { expandedCard.value = new Set(CARDS) }
function collapseAll () { expandedCard.value = new Set() }
// ── Defaults ───────────────────────────────────────────────────
const DEFAULT_CFG = {
@@ -405,7 +405,7 @@ async function saveCard (cardKey) {
)
toast.add({ severity: 'success', summary: 'Salvo', life: 2500 })
expandedCard.value = null
expandedCard.value = new Set()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 4000 })
} finally {
@@ -474,7 +474,10 @@ function buildPayload (cardKey) {
}
function toggleCard (key) {
expandedCard.value = expandedCard.value === key ? null : key
const s = new Set(expandedCard.value)
if (s.has(key)) s.delete(key)
else s.add(key)
expandedCard.value = s
}
onMounted(load)
@@ -490,43 +493,27 @@ onMounted(load)
<template v-else>
<!-- HEADER SECUNDÁRIO -->
<div class="flex items-center justify-between gap-3 px-1">
<div>
<div class="text-base font-semibold">Configurações do Agendador</div>
<div class="text-xs text-surface-400 mt-0.5">Personalize a aparência, fluxo e comportamento do seu agendador público.</div>
<!-- Subheader -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-calendar-clock" /></div>
<div class="min-w-0">
<div class="cfg-subheader__title">Agendador Online</div>
<div class="cfg-subheader__sub">Personalize a aparência, fluxo e comportamento do seu agendador público</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<Button
size="small"
icon="pi pi-arrows-v"
label="Expandir tudo"
severity="secondary"
outlined
class="rounded-full"
@click="expandAll"
/>
<Button
size="small"
icon="pi pi-minus"
label="Contrair"
severity="secondary"
outlined
class="rounded-full"
@click="collapseAll"
/>
<div class="cfg-subheader__actions">
<Button size="small" icon="pi pi-arrows-v" label="Expandir" severity="secondary" outlined class="rounded-full" @click="expandAll" />
<Button size="small" icon="pi pi-minus" label="Contrair" severity="secondary" outlined class="rounded-full" @click="collapseAll" />
</div>
</div>
<!-- CARD: STATUS / ATIVAR -->
<Card>
<template #content>
<div class="agd-card">
<div class="flex flex-col gap-4">
<!-- Cabeçalho PRO -->
<div class="flex items-start justify-between gap-4">
<div class="flex items-center gap-3">
<div class="grid place-items-center w-11 h-11 rounded-2xl shrink-0"
<div class="grid place-items-center w-11 h-11 rounded-[6px] shrink-0"
:class="cfg.ativo ? 'bg-green-100 dark:bg-green-900/30 text-green-600' : 'bg-surface-100 text-surface-400'">
<i class="pi pi-calendar-clock text-xl" />
</div>
@@ -586,9 +573,9 @@ onMounted(load)
</div>
<!-- Link personalizado bloqueado -->
<div v-if="!hasLinkPersonalizado" class="mt-3 flex items-center gap-3 p-3 rounded-xl border border-dashed
<div v-if="!hasLinkPersonalizado" class="mt-3 flex items-center gap-3 p-3 rounded-[6px] border border-dashed
border-surface-300 dark:border-surface-600 bg-surface-50 dark:bg-surface-800/50">
<div class="grid place-items-center w-9 h-9 rounded-xl bg-amber-100 dark:bg-amber-900/30 text-amber-500 shrink-0">
<div class="grid place-items-center w-9 h-9 rounded-[6px] bg-amber-100 dark:bg-amber-900/30 text-amber-500 shrink-0">
<i class="pi pi-lock text-base" />
</div>
<div class="flex-1 min-w-0">
@@ -617,34 +604,27 @@ onMounted(load)
Você controla quem pode agendar e quais horários ficam disponíveis.
</div>
</div>
</template>
</Card>
</div>
<!-- CARD: IDENTIDADE VISUAL -->
<Card class="overflow-hidden">
<template #content>
<!-- Cabeçalho do card -->
<div class="agd-accordion" :class="{ 'agd-accordion--open': expandedCard.has('identidade') }">
<button
type="button"
class="w-full flex items-center justify-between gap-3 text-left"
class="agd-accordion__header"
@click="toggleCard('identidade')"
>
<div class="flex items-center gap-3">
<div class="grid place-items-center w-9 h-9 rounded-xl bg-purple-100 dark:bg-purple-900/30 text-purple-600 shrink-0">
<div class="agd-accordion__icon bg-purple-100 dark:bg-purple-900/30 text-purple-600">
<i class="pi pi-palette" />
</div>
<div>
<div class="font-semibold leading-none">Identidade Visual</div>
<div v-if="expandedCard !== 'identidade'" class="text-xs text-surface-400 mt-1">{{ resumoIdentidade }}</div>
<div class="min-w-0 flex-1 text-left">
<div class="agd-accordion__title">Identidade Visual</div>
<div v-if="!expandedCard.has('identidade')" class="agd-accordion__summary">{{ resumoIdentidade }}</div>
</div>
</div>
<i class="pi transition-transform duration-200 text-surface-400 shrink-0"
:class="expandedCard === 'identidade' ? 'pi-angle-up' : 'pi-angle-down'" />
<i class="pi agd-accordion__chevron"
:class="expandedCard.has('identidade') ? 'pi-chevron-up' : 'pi-chevron-down'" />
</button>
<!-- Conteúdo expandido -->
<template v-if="expandedCard === 'identidade'">
<Divider />
<div v-if="expandedCard.has('identidade')" class="agd-accordion__body">
<div class="flex flex-col gap-5">
<!-- Nome de exibição (aqui pois é parte da identidade) -->
@@ -660,7 +640,7 @@ onMounted(load)
<div class="flex items-center gap-3">
<ColorPicker v-model="cfg.cor_primaria" format="hex" />
<InputText v-model="cfg.cor_primaria" placeholder="#4b6bff" class="w-32 font-mono" maxlength="7" />
<div class="w-10 h-10 rounded-xl border border-surface-200 shrink-0"
<div class="w-10 h-10 rounded-[6px] border border-surface-200 shrink-0"
:style="{ background: cfg.cor_primaria }" />
</div>
<div class="text-xs text-surface-400 mt-1">Botões e destaques do agendador.</div>
@@ -708,7 +688,7 @@ onMounted(load)
@change="e => onFileSelected(e, 'header')" />
</div>
<InputText v-model="cfg.imagem_header_url" placeholder="ou cole uma URL pública..." class="w-full text-xs" />
<div v-if="cfg.imagem_header_url" class="rounded-xl overflow-hidden h-20 w-full">
<div v-if="cfg.imagem_header_url" class="rounded-[6px] overflow-hidden h-20 w-full">
<img :src="cfg.imagem_header_url" alt="Header" class="w-full h-full object-cover" />
</div>
</div>
@@ -728,7 +708,7 @@ onMounted(load)
@change="e => onFileSelected(e, 'fundo')" />
</div>
<InputText v-model="cfg.imagem_fundo_url" placeholder="ou cole uma URL pública..." class="w-full text-xs" />
<div v-if="cfg.imagem_fundo_url" class="rounded-xl overflow-hidden h-28 w-full">
<div v-if="cfg.imagem_fundo_url" class="rounded-[6px] overflow-hidden h-28 w-full">
<img :src="cfg.imagem_fundo_url" alt="Fundo" class="w-full h-full object-cover" />
</div>
</div>
@@ -744,33 +724,28 @@ onMounted(load)
/>
</div>
</div>
</template>
</template>
</Card>
</div>
</div>
<!-- CARD: PERFIL PÚBLICO -->
<Card>
<template #content>
<div class="agd-accordion" :class="{ 'agd-accordion--open': expandedCard.has('perfil') }">
<button
type="button"
class="w-full flex items-center justify-between gap-3 text-left"
class="agd-accordion__header"
@click="toggleCard('perfil')"
>
<div class="flex items-center gap-3">
<div class="grid place-items-center w-9 h-9 rounded-xl bg-blue-100 dark:bg-blue-900/30 text-blue-600 shrink-0">
<div class="agd-accordion__icon bg-blue-100 dark:bg-blue-900/30 text-blue-600">
<i class="pi pi-map-marker" />
</div>
<div>
<div class="font-semibold leading-none">Perfil Público</div>
<div v-if="expandedCard !== 'perfil'" class="text-xs text-surface-400 mt-1">{{ resumoPerfil }}</div>
<div class="min-w-0 flex-1 text-left">
<div class="agd-accordion__title">Perfil Público</div>
<div v-if="!expandedCard.has('perfil')" class="agd-accordion__summary">{{ resumoPerfil }}</div>
</div>
</div>
<i class="pi transition-transform duration-200 text-surface-400 shrink-0"
:class="expandedCard === 'perfil' ? 'pi-angle-up' : 'pi-angle-down'" />
<i class="pi agd-accordion__chevron"
:class="expandedCard.has('perfil') ? 'pi-chevron-up' : 'pi-chevron-down'" />
</button>
<template v-if="expandedCard === 'perfil'">
<Divider />
<div v-if="expandedCard.has('perfil')" class="agd-accordion__body">
<div class="flex flex-col gap-5">
<!-- Endereço -->
@@ -780,7 +755,7 @@ onMounted(load)
</div>
<!-- Botão Como Chegar -->
<div class="flex items-center justify-between p-3 bg-surface-50 dark:bg-surface-800 rounded-xl">
<div class="flex items-center justify-between p-3 bg-surface-50 dark:bg-surface-800 rounded-[6px]">
<div>
<div class="font-medium text-sm">Botão "Como chegar"</div>
<div class="text-xs text-surface-400 mt-0.5">Exibe um botão que abre o mapa para o paciente.</div>
@@ -804,33 +779,28 @@ onMounted(load)
/>
</div>
</div>
</template>
</template>
</Card>
</div>
</div>
<!-- CARD: FLUXO DE AGENDAMENTO -->
<Card>
<template #content>
<div class="agd-accordion" :class="{ 'agd-accordion--open': expandedCard.has('fluxo') }">
<button
type="button"
class="w-full flex items-center justify-between gap-3 text-left"
class="agd-accordion__header"
@click="toggleCard('fluxo')"
>
<div class="flex items-center gap-3">
<div class="grid place-items-center w-9 h-9 rounded-xl bg-cyan-100 dark:bg-cyan-900/30 text-cyan-600 shrink-0">
<div class="agd-accordion__icon bg-cyan-100 dark:bg-cyan-900/30 text-cyan-600">
<i class="pi pi-sitemap" />
</div>
<div>
<div class="font-semibold leading-none">Fluxo de Agendamento</div>
<div v-if="expandedCard !== 'fluxo'" class="text-xs text-surface-400 mt-1">{{ resumoFluxo }}</div>
<div class="min-w-0 flex-1 text-left">
<div class="agd-accordion__title">Fluxo de Agendamento</div>
<div v-if="!expandedCard.has('fluxo')" class="agd-accordion__summary">{{ resumoFluxo }}</div>
</div>
</div>
<i class="pi transition-transform duration-200 text-surface-400 shrink-0"
:class="expandedCard === 'fluxo' ? 'pi-angle-up' : 'pi-angle-down'" />
<i class="pi agd-accordion__chevron"
:class="expandedCard.has('fluxo') ? 'pi-chevron-up' : 'pi-chevron-down'" />
</button>
<template v-if="expandedCard === 'fluxo'">
<Divider />
<div v-if="expandedCard.has('fluxo')" class="agd-accordion__body">
<div class="flex flex-col gap-5">
<!-- Modo de aprovação -->
@@ -840,7 +810,7 @@ onMounted(load)
<div
v-for="opt in modoOptions"
:key="opt.value"
class="flex items-center gap-3 p-3 rounded-xl border cursor-pointer transition"
class="flex items-center gap-3 p-3 rounded-[6px] border cursor-pointer transition"
:class="cfg.modo_aprovacao === opt.value
? 'border-primary bg-primary/5 dark:bg-primary/10'
: 'border-surface-200 dark:border-surface-700 hover:border-surface-300'"
@@ -957,33 +927,28 @@ onMounted(load)
/>
</div>
</div>
</template>
</template>
</Card>
</div>
</div>
<!-- CARD: PAGAMENTO -->
<Card>
<template #content>
<div class="agd-accordion" :class="{ 'agd-accordion--open': expandedCard.has('pagamento') }">
<button
type="button"
class="w-full flex items-center justify-between gap-3 text-left"
class="agd-accordion__header"
@click="toggleCard('pagamento')"
>
<div class="flex items-center gap-3">
<div class="grid place-items-center w-9 h-9 rounded-xl bg-green-100 dark:bg-green-900/30 text-green-600 shrink-0">
<div class="agd-accordion__icon bg-green-100 dark:bg-green-900/30 text-green-600">
<i class="pi pi-credit-card" />
</div>
<div>
<div class="font-semibold leading-none">Pagamento</div>
<div v-if="expandedCard !== 'pagamento'" class="text-xs text-surface-400 mt-1">{{ resumoPagamento }}</div>
<div class="min-w-0 flex-1 text-left">
<div class="agd-accordion__title">Pagamento</div>
<div v-if="!expandedCard.has('pagamento')" class="agd-accordion__summary">{{ resumoPagamento }}</div>
</div>
</div>
<i class="pi transition-transform duration-200 text-surface-400 shrink-0"
:class="expandedCard === 'pagamento' ? 'pi-angle-up' : 'pi-angle-down'" />
<i class="pi agd-accordion__chevron"
:class="expandedCard.has('pagamento') ? 'pi-chevron-up' : 'pi-chevron-down'" />
</button>
<template v-if="expandedCard === 'pagamento'">
<Divider />
<div v-if="expandedCard.has('pagamento')" class="agd-accordion__body">
<div class="flex flex-col gap-5">
<!-- Modo de pagamento -->
@@ -994,13 +959,13 @@ onMounted(load)
v-for="modo in modosPagamento"
:key="modo.value"
type="button"
class="flex items-center gap-3 p-3 rounded-xl border text-left transition"
class="flex items-center gap-3 p-3 rounded-[6px] border text-left transition"
:class="cfg.pagamento_modo === modo.value
? 'border-primary bg-primary/5 ring-1 ring-primary/30'
: 'border-surface-border bg-surface-50 dark:bg-surface-800 hover:bg-surface-100 dark:hover:bg-surface-700'"
@click="cfg.pagamento_modo = modo.value"
>
<div class="grid place-items-center w-9 h-9 rounded-lg shrink-0"
<div class="grid place-items-center w-9 h-9 rounded-[6px] shrink-0"
:class="cfg.pagamento_modo === modo.value ? 'bg-primary/15 text-primary' : 'bg-surface-200 dark:bg-surface-700 text-surface-400'">
<i :class="['pi', modo.icon]" />
</div>
@@ -1023,7 +988,7 @@ onMounted(load)
<RouterLink to="/configuracoes/pagamento" class="underline">Configurações Pagamento</RouterLink>.
</p>
<div v-if="!algumMetodoConfigurado" class="rounded-xl border border-orange-200 bg-orange-50 dark:bg-orange-900/20 p-3 text-sm text-orange-700 dark:text-orange-300">
<div v-if="!algumMetodoConfigurado" class="rounded-[6px] border border-orange-200 bg-orange-50 dark:bg-orange-900/20 p-3 text-sm text-orange-700 dark:text-orange-300">
<i class="pi pi-exclamation-triangle mr-1" />
Nenhuma forma de pagamento configurada ainda.
<RouterLink to="/configuracoes/pagamento" class="underline font-medium ml-1">Configurar agora</RouterLink>
@@ -1033,7 +998,7 @@ onMounted(load)
<label
v-for="m in metodosDisponiveis"
:key="m.key"
class="flex items-center gap-3 p-3 rounded-xl border cursor-pointer transition select-none"
class="flex items-center gap-3 p-3 rounded-[6px] border cursor-pointer transition select-none"
:class="[
!m.ativo ? 'opacity-40 cursor-not-allowed border-surface-border bg-surface-50 dark:bg-surface-800' :
isMetodoVisivel(m.key) ? 'border-primary bg-primary/5 ring-1 ring-primary/20' : 'border-surface-border bg-surface-50 dark:bg-surface-800 hover:bg-surface-100 dark:hover:bg-surface-700'
@@ -1125,39 +1090,34 @@ onMounted(load)
/>
</div>
</div>
</template>
</template>
</Card>
</div>
</div>
<!-- CARD: TRIAGEM & CONFORMIDADE -->
<Card>
<template #content>
<div class="agd-accordion" :class="{ 'agd-accordion--open': expandedCard.has('triagem') }">
<button
type="button"
class="w-full flex items-center justify-between gap-3 text-left"
class="agd-accordion__header"
@click="toggleCard('triagem')"
>
<div class="flex items-center gap-3">
<div class="grid place-items-center w-9 h-9 rounded-xl bg-orange-100 dark:bg-orange-900/30 text-orange-600 shrink-0">
<div class="agd-accordion__icon bg-orange-100 dark:bg-orange-900/30 text-orange-600">
<i class="pi pi-shield" />
</div>
<div>
<div class="font-semibold leading-none">Triagem & Conformidade</div>
<div v-if="expandedCard !== 'triagem'" class="text-xs text-surface-400 mt-1">{{ resumoTriagem }}</div>
<div class="min-w-0 flex-1 text-left">
<div class="agd-accordion__title">Triagem & Conformidade</div>
<div v-if="!expandedCard.has('triagem')" class="agd-accordion__summary">{{ resumoTriagem }}</div>
</div>
</div>
<i class="pi transition-transform duration-200 text-surface-400 shrink-0"
:class="expandedCard === 'triagem' ? 'pi-angle-up' : 'pi-angle-down'" />
<i class="pi agd-accordion__chevron"
:class="expandedCard.has('triagem') ? 'pi-chevron-up' : 'pi-chevron-down'" />
</button>
<template v-if="expandedCard === 'triagem'">
<Divider />
<div v-if="expandedCard.has('triagem')" class="agd-accordion__body">
<div class="flex flex-col gap-4">
<div class="text-sm font-semibold text-surface-600">Campos extras no formulário</div>
<!-- Triagem: motivo -->
<div class="flex items-center justify-between p-3 bg-surface-50 dark:bg-surface-800 rounded-xl">
<div class="flex items-center justify-between p-3 bg-surface-50 dark:bg-surface-800 rounded-[6px]">
<div>
<div class="font-medium text-sm">Motivo da consulta</div>
<div class="text-xs text-surface-400 mt-0.5">Campo de texto livre opcional para o paciente informar o motivo.</div>
@@ -1166,7 +1126,7 @@ onMounted(load)
</div>
<!-- Triagem: como conheceu -->
<div class="flex items-center justify-between p-3 bg-surface-50 dark:bg-surface-800 rounded-xl">
<div class="flex items-center justify-between p-3 bg-surface-50 dark:bg-surface-800 rounded-[6px]">
<div>
<div class="font-medium text-sm">Como nos conheceu?</div>
<div class="text-xs text-surface-400 mt-0.5">Pergunta de origem (indicação, redes sociais, busca).</div>
@@ -1179,7 +1139,7 @@ onMounted(load)
<div class="text-sm font-semibold text-surface-600">Segurança & LGPD</div>
<!-- Verificação de email -->
<div class="flex items-center justify-between p-3 bg-surface-50 dark:bg-surface-800 rounded-xl">
<div class="flex items-center justify-between p-3 bg-surface-50 dark:bg-surface-800 rounded-[6px]">
<div>
<div class="font-medium text-sm">Verificação de e-mail</div>
<div class="text-xs text-surface-400 mt-0.5">
@@ -1190,7 +1150,7 @@ onMounted(load)
</div>
<!-- Aceite LGPD -->
<div class="flex items-center justify-between p-3 bg-surface-50 dark:bg-surface-800 rounded-xl">
<div class="flex items-center justify-between p-3 bg-surface-50 dark:bg-surface-800 rounded-[6px]">
<div>
<div class="font-medium text-sm">Aceite obrigatório de termos (LGPD)</div>
<div class="text-xs text-surface-400 mt-0.5">
@@ -1209,33 +1169,28 @@ onMounted(load)
/>
</div>
</div>
</template>
</template>
</Card>
</div>
</div>
<!-- CARD: TEXTOS DA JORNADA -->
<Card>
<template #content>
<div class="agd-accordion" :class="{ 'agd-accordion--open': expandedCard.has('textos') }">
<button
type="button"
class="w-full flex items-center justify-between gap-3 text-left"
class="agd-accordion__header"
@click="toggleCard('textos')"
>
<div class="flex items-center gap-3">
<div class="grid place-items-center w-9 h-9 rounded-xl bg-pink-100 dark:bg-pink-900/30 text-pink-600 shrink-0">
<div class="agd-accordion__icon bg-pink-100 dark:bg-pink-900/30 text-pink-600">
<i class="pi pi-file-edit" />
</div>
<div>
<div class="font-semibold leading-none">Textos da Jornada</div>
<div v-if="expandedCard !== 'textos'" class="text-xs text-surface-400 mt-1">{{ resumoTextos }}</div>
<div class="min-w-0 flex-1 text-left">
<div class="agd-accordion__title">Textos da Jornada</div>
<div v-if="!expandedCard.has('textos')" class="agd-accordion__summary">{{ resumoTextos }}</div>
</div>
</div>
<i class="pi transition-transform duration-200 text-surface-400 shrink-0"
:class="expandedCard === 'textos' ? 'pi-angle-up' : 'pi-angle-down'" />
<i class="pi agd-accordion__chevron"
:class="expandedCard.has('textos') ? 'pi-chevron-up' : 'pi-chevron-down'" />
</button>
<template v-if="expandedCard === 'textos'">
<Divider />
<div v-if="expandedCard.has('textos')" class="agd-accordion__body">
<div class="flex flex-col gap-5">
<!-- Mensagem de boas-vindas -->
@@ -1289,28 +1244,119 @@ onMounted(load)
/>
</div>
</div>
</template>
</template>
</Card>
</div>
</div>
</template>
</div>
</template>
<style scoped>
/* ── Upload zone ──────────────────────────────────── */
.agd-upload-zone {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.75rem 1rem;
border: 1.5px dashed var(--surface-border);
border-radius: 0.875rem;
border-radius: 6px;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
background: var(--surface-ground);
}
.agd-upload-zone:hover {
border-color: var(--p-primary-500, #6366f1);
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 5%, transparent);
border-color: var(--primary-color, #6366f1);
background: color-mix(in srgb, var(--primary-color, #6366f1) 5%, transparent);
}
/* ── Subheader degradê ────────────────────────────── */
.cfg-subheader {
display: flex; align-items: center; gap: 0.65rem;
padding: 0.875rem 1rem; border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--primary-color, #6366f1) 30%, transparent);
background: linear-gradient(135deg,
color-mix(in srgb, var(--primary-color, #6366f1) 12%, var(--surface-card)) 0%,
color-mix(in srgb, var(--primary-color, #6366f1) 4%, var(--surface-card)) 60%,
var(--surface-card) 100%);
position: relative; overflow: hidden;
}
.cfg-subheader::before {
content: ''; position: absolute;
top: -20px; right: -20px;
width: 80px; height: 80px; border-radius: 50%;
background: color-mix(in srgb, var(--primary-color, #6366f1) 15%, transparent);
filter: blur(20px); pointer-events: none;
}
.cfg-subheader__icon {
display: grid; place-items: center;
width: 2rem; height: 2rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color, #6366f1) 20%, transparent);
color: var(--primary-color, #6366f1); font-size: 0.85rem;
}
.cfg-subheader__title { font-size: 0.95rem; font-weight: 700; letter-spacing: -0.01em; color: var(--primary-color, #6366f1); }
.cfg-subheader__sub { font-size: 0.75rem; color: var(--text-color-secondary); opacity: 0.85; }
.cfg-subheader__actions { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; flex-shrink: 0; position: relative; z-index: 1; }
/* ── Card status (sem accordion) ─────────────────── */
.agd-card {
border: 1px solid var(--surface-border);
border-radius: 6px;
background: var(--surface-card);
padding: 1rem;
}
/* ── Accordion cards ──────────────────────────────── */
.agd-accordion {
border: 1px solid var(--surface-border);
border-radius: 6px;
background: var(--surface-card);
overflow: hidden;
transition: border-color 0.15s;
}
.agd-accordion--open {
border-color: color-mix(in srgb, var(--primary-color, #6366f1) 35%, transparent);
}
.agd-accordion__header {
display: flex; align-items: center; gap: 0.75rem;
width: 100%; padding: 0.875rem 1rem;
background: transparent; border: none; cursor: pointer;
transition: background 0.12s;
text-align: left;
}
.agd-accordion__header:hover { background: var(--surface-hover); }
.agd-accordion--open .agd-accordion__header {
background: color-mix(in srgb, var(--primary-color, #6366f1) 4%, var(--surface-card));
border-bottom: 1px solid var(--surface-border);
}
.agd-accordion__icon {
display: grid; place-items: center;
width: 2rem; height: 2rem; border-radius: 6px; flex-shrink: 0;
}
.agd-accordion__title {
font-size: 0.88rem; font-weight: 700;
color: var(--text-color);
}
.agd-accordion--open .agd-accordion__title {
color: var(--primary-color, #6366f1);
}
.agd-accordion__summary {
font-size: 0.72rem; color: var(--text-color-secondary);
opacity: 0.75; margin-top: 1px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.agd-accordion__chevron {
font-size: 0.7rem; color: var(--text-color-secondary);
opacity: 0.5; flex-shrink: 0;
transition: transform 0.2s;
}
.agd-accordion--open .agd-accordion__chevron {
color: var(--primary-color, #6366f1); opacity: 0.8;
}
.agd-accordion__body {
padding: 1rem;
display: flex; flex-direction: column; gap: 1.25rem;
}
</style>

View File

@@ -239,32 +239,19 @@ onMounted(async () => {
<template>
<Toast />
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3">
<!-- Header -->
<Card>
<template #content>
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-3">
<div class="cfg-icon-box">
<i class="pi pi-id-card text-lg" />
<!-- Subheader -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-id-card" /></div>
<div class="min-w-0">
<div class="cfg-subheader__title">Convênios</div>
<div class="cfg-subheader__sub">Convênios e planos de saúde que você atende</div>
</div>
<div>
<div class="text-900 font-semibold text-lg">Convênios</div>
<div class="text-600 text-sm">
Cadastre os convênios que você atende e seus procedimentos com valores de tabela.
<div class="cfg-subheader__actions">
<Button label="Novo convênio" icon="pi pi-plus" size="small" :disabled="pageLoading || addingNew" class="rounded-full" @click="addingNew = true; cancelEdit()" />
</div>
</div>
</div>
<Button
label="Novo convênio"
icon="pi pi-plus"
:disabled="pageLoading || addingNew"
@click="addingNew = true; cancelEdit()"
/>
</div>
</template>
</Card>
<!-- Loading -->
<div v-if="pageLoading || loading" class="flex justify-center py-10">
@@ -273,15 +260,13 @@ onMounted(async () => {
<template v-else>
<!-- Formulário novo convênio -->
<Card v-if="addingNew">
<template #title>
<div class="flex items-center gap-2">
<i class="pi pi-plus-circle text-primary-500" />
<span>Novo convênio</span>
<!-- Form novo convênio -->
<div v-if="addingNew" class="cfg-wrap">
<div class="cfg-wrap__head">
<div class="cfg-wrap__icon"><i class="pi pi-plus" /></div>
<span class="cfg-wrap__title">Novo convênio</span>
</div>
</template>
<template #content>
<div class="cfg-wrap__body">
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12 sm:col-span-6">
<FloatLabel variant="on">
@@ -296,30 +281,30 @@ onMounted(async () => {
</FloatLabel>
</div>
</div>
<div class="flex gap-2 justify-end mt-4">
<Button label="Cancelar" severity="secondary" outlined @click="addingNew = false; newForm = emptyForm()" />
<Button label="Salvar" icon="pi pi-check" :loading="savingNew" @click="saveNew" />
<div class="flex gap-2 justify-end mt-3">
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" @click="addingNew = false; newForm = emptyForm()" />
<Button label="Salvar" icon="pi pi-check" class="rounded-full" :loading="savingNew" @click="saveNew" />
</div>
</div>
</div>
</template>
</Card>
<!-- Lista vazia -->
<Card v-if="!plans.length && !addingNew">
<template #content>
<div class="text-center py-6 text-color-secondary">
<i class="pi pi-id-card text-4xl opacity-30 mb-3 block" />
<div class="font-medium mb-1">Nenhum convênio cadastrado</div>
<div class="text-sm">Clique em "Novo convênio" para começar.</div>
<div v-if="!plans.length && !addingNew" class="cfg-empty">
<i class="pi pi-id-card text-3xl opacity-25" />
<div class="text-sm font-medium">Nenhum convênio cadastrado</div>
<div class="text-xs opacity-70">Clique em "Novo convênio" para começar.</div>
</div>
</template>
</Card>
<!-- Lista de convênios -->
<Card v-for="plan in plans" :key="plan.id" :class="{ 'opacity-60': !plan.active }">
<template #content>
<div
v-for="plan in plans"
:key="plan.id"
class="cfg-wrap"
:class="{ 'opacity-60': !plan.active }"
>
<!-- Modo edição do plano -->
<template v-if="editingId === plan.id">
<div class="cfg-wrap__body">
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12 sm:col-span-6">
<FloatLabel variant="on">
@@ -334,121 +319,97 @@ onMounted(async () => {
</FloatLabel>
</div>
</div>
<div class="flex gap-2 justify-end mt-4">
<Button label="Cancelar" severity="secondary" outlined @click="cancelEdit" />
<Button label="Salvar" icon="pi pi-check" :loading="savingEdit" @click="saveEdit" />
<div class="flex gap-2 justify-end mt-3">
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" @click="cancelEdit" />
<Button label="Salvar" icon="pi pi-check" class="rounded-full" :loading="savingEdit" @click="saveEdit" />
</div>
</div>
</template>
<!-- Modo leitura -->
<template v-else>
<!-- Cabeçalho do plano -->
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-3 min-w-0">
<div class="cfg-icon-box-sm shrink-0">
<i class="pi pi-id-card" />
</div>
<div class="cnv-plan-head">
<div class="flex items-center gap-2.5 min-w-0 flex-1">
<div class="cfg-wrap__icon shrink-0"><i class="pi pi-id-card" /></div>
<div class="min-w-0">
<div class="font-semibold text-900">{{ plan.name }}</div>
<div v-if="plan.notes" class="text-sm text-color-secondary italic truncate">{{ plan.notes }}</div>
<div class="font-semibold text-sm">{{ plan.name }}</div>
<div v-if="plan.notes" class="text-xs text-[var(--text-color-secondary)] opacity-70 truncate">{{ plan.notes }}</div>
</div>
</div>
<div class="flex items-center gap-2 shrink-0 flex-wrap">
<div class="flex items-center gap-1.5 shrink-0 flex-wrap">
<Tag :value="plan.active ? 'Ativo' : 'Inativo'" :severity="plan.active ? 'success' : 'secondary'" />
<Button
:label="`Procedimentos (${totalProcedimentos(plan)})`"
:icon="expandedPlanId === plan.id ? 'pi pi-chevron-up' : 'pi pi-chevron-down'"
severity="secondary"
outlined
size="small"
@click="expandedPlanId === plan.id ? (expandedPlanId = null, addingServicePlanId = null) : (expandedPlanId = plan.id, addingServicePlanId = null)"
severity="secondary" outlined size="small" class="rounded-full"
@click="togglePanel(plan.id)"
/>
<Button
:icon="plan.active ? 'pi pi-eye-slash' : 'pi pi-eye'"
:severity="plan.active ? 'secondary' : 'success'"
outlined
size="small"
outlined size="small"
v-tooltip.top="plan.active ? 'Desativar' : 'Ativar'"
@click="togglePlan(plan)"
/>
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" v-tooltip.top="'Editar'" @click="startEdit(plan)" />
<Button icon="pi pi-trash" severity="danger" outlined size="small" v-tooltip.top="'Desativar'" @click="removePlan(plan.id)" />
<Button icon="pi pi-trash" severity="danger" outlined size="small" v-tooltip.top="'Remover'" @click="removePlan(plan.id)" />
</div>
</div>
<!-- Painel expansível: procedimentos -->
<div v-if="expandedPlanId === plan.id" class="mt-4 border-t border-surface pt-4">
<!-- Painel procedimentos expandível -->
<div v-if="expandedPlanId === plan.id" class="cnv-procedures">
<!-- Lista de procedimentos (ativos e inativos) -->
<div v-if="plan.insurance_plan_services?.length" class="mb-3 flex flex-col gap-1">
<!-- Lista de procedimentos -->
<div v-if="plan.insurance_plan_services?.length" class="cnv-proc-list">
<template v-for="ps in plan.insurance_plan_services" :key="ps.id">
<!-- Modo edição inline do procedimento -->
<div v-if="editingServiceId === ps.id" class="flex flex-wrap gap-2 items-end py-2 border-b border-surface">
<div class="flex-1 min-w-[140px]">
<label class="text-xs text-color-secondary mb-1 block">Nome</label>
<!-- Edição inline do procedimento -->
<div v-if="editingServiceId === ps.id" class="cnv-proc-edit">
<div class="grid grid-cols-12 gap-2 flex-1">
<div class="col-span-12 sm:col-span-6">
<label class="cnv-label">Nome</label>
<InputText v-model="editServiceForm.name" class="w-full" size="small" />
</div>
<div class="w-36">
<label class="text-xs text-color-secondary mb-1 block">Valor (R$)</label>
<InputNumber
v-model="editServiceForm.value"
mode="currency" currency="BRL" locale="pt-BR"
:min="0" :minFractionDigits="2"
class="w-full" size="small"
/>
<div class="col-span-12 sm:col-span-6">
<label class="cnv-label">Valor (R$)</label>
<InputNumber v-model="editServiceForm.value" mode="currency" currency="BRL" locale="pt-BR" :min="0" :minFractionDigits="2" class="w-full" size="small" />
</div>
<div class="flex gap-2">
<Button label="Cancelar" severity="secondary" outlined size="small" @click="cancelEditService" />
<Button label="Salvar" icon="pi pi-check" size="small" :loading="savingServiceEdit" @click="saveServiceEdit" />
</div>
<div class="flex gap-2 justify-end mt-2">
<Button label="Cancelar" severity="secondary" outlined size="small" class="rounded-full" @click="cancelEditService" />
<Button label="Salvar" icon="pi pi-check" size="small" class="rounded-full" :loading="savingServiceEdit" @click="saveServiceEdit" />
</div>
</div>
<!-- Modo leitura do procedimento -->
<div
v-else
class="flex items-center justify-between gap-2 py-2 border-b border-surface last:border-0"
:class="{ 'opacity-60': !ps.active }"
>
<div class="flex items-center gap-2 min-w-0">
<Tag v-if="!ps.active" value="Inativo" severity="secondary" class="text-xs" />
<span class="text-sm font-medium text-900 truncate">{{ ps.name }}</span>
<!-- Leitura do procedimento -->
<div v-else class="cnv-proc-row" :class="{ 'opacity-50': !ps.active }">
<div class="flex items-center gap-2 min-w-0 flex-1">
<Tag v-if="!ps.active" value="Inativo" severity="secondary" class="text-xs shrink-0" />
<span class="text-sm font-medium truncate">{{ ps.name }}</span>
</div>
<div class="flex items-center gap-3 shrink-0">
<span class="text-sm font-semibold text-primary-500">{{ fmtBRL(ps.value) }}</span>
<Button
:icon="ps.active ? 'pi pi-eye-slash' : 'pi pi-eye'"
:severity="ps.active ? 'secondary' : 'success'"
text size="small"
v-tooltip.top="ps.active ? 'Desativar' : 'Ativar'"
@click="onToggleService(ps)"
/>
<Button
icon="pi pi-pencil"
severity="secondary" text size="small"
v-tooltip.top="'Editar'"
@click="startEditService(ps)"
/>
<Button
icon="pi pi-trash"
severity="danger" text size="small"
v-tooltip.top="'Remover definitivamente'"
@click="deleteService(ps.id)"
/>
<div class="flex items-center gap-2 shrink-0">
<span class="text-sm font-semibold text-[var(--primary-color)]">{{ fmtBRL(ps.value) }}</span>
<Button :icon="ps.active ? 'pi pi-eye-slash' : 'pi pi-eye'" :severity="ps.active ? 'secondary' : 'success'" text size="small" v-tooltip.top="ps.active ? 'Desativar' : 'Ativar'" @click="onToggleService(ps)" />
<Button icon="pi pi-pencil" severity="secondary" text size="small" v-tooltip.top="'Editar'" @click="startEditService(ps)" />
<Button icon="pi pi-trash" severity="danger" text size="small" v-tooltip.top="'Remover'" @click="deleteService(ps.id)" />
</div>
</div>
</template>
</div>
<div v-else-if="addingServicePlanId !== plan.id" class="text-sm text-color-secondary mb-3 italic">
<div v-else-if="addingServicePlanId !== plan.id" class="text-sm text-[var(--text-color-secondary)] italic px-1 py-2">
Nenhum procedimento cadastrado.
</div>
<!-- Formulário adicionar procedimento -->
<div v-if="addingServicePlanId === plan.id" class="mt-3">
<!-- Cards de serviços para auto-preencher -->
<!-- Form adicionar procedimento -->
<div v-if="addingServicePlanId === plan.id" class="cnv-proc-form">
<!-- Quick-fill dos serviços -->
<div v-if="services.filter(s => s.active).length" class="mb-3">
<div class="text-xs text-color-secondary mb-2">Clique num serviço para pré-preencher:</div>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
<div class="cnv-label mb-1.5">Clique num serviço para pré-preencher:</div>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-1.5">
<button
v-for="svc in services.filter(s => s.active)"
:key="svc.id"
@@ -461,24 +422,21 @@ onMounted(async () => {
</div>
</div>
<div class="flex flex-wrap gap-2 items-end">
<div class="flex-1 min-w-[140px]">
<label class="text-xs text-color-secondary mb-1 block">Nome do procedimento *</label>
<!-- Campos nome + valor -->
<div class="grid grid-cols-12 gap-2">
<div class="col-span-12 sm:col-span-7">
<label class="cnv-label">Nome do procedimento *</label>
<InputText v-model="newServiceForm.name" placeholder="Ex: Consulta" class="w-full" size="small" />
</div>
<div class="w-36">
<label class="text-xs text-color-secondary mb-1 block">Valor (R$) *</label>
<InputNumber
v-model="newServiceForm.value"
mode="currency" currency="BRL" locale="pt-BR"
:min="0" :minFractionDigits="2"
class="w-full" size="small"
/>
<div class="col-span-12 sm:col-span-5">
<label class="cnv-label">Valor (R$) *</label>
<InputNumber v-model="newServiceForm.value" mode="currency" currency="BRL" locale="pt-BR" :min="0" :minFractionDigits="2" class="w-full" size="small" />
</div>
<div class="flex gap-2">
<Button label="Cancelar" severity="secondary" outlined size="small" @click="cancelAddService" />
<Button label="Adicionar" icon="pi pi-check" size="small" :loading="savingService" @click="saveService(plan.id)" />
</div>
<!-- Botões em linha separada -->
<div class="flex gap-2 justify-end mt-2">
<Button label="Cancelar" severity="secondary" outlined size="small" class="rounded-full" @click="cancelAddService" />
<Button label="Adicionar" icon="pi pi-check" size="small" class="rounded-full" :loading="savingService" @click="saveService(plan.id)" />
</div>
</div>
@@ -486,17 +444,13 @@ onMounted(async () => {
v-if="addingServicePlanId !== plan.id"
label="Adicionar procedimento"
icon="pi pi-plus"
severity="secondary"
outlined
size="small"
class="mt-2"
severity="secondary" outlined size="small" class="mt-2 rounded-full"
@click="startAddService(plan.id)"
/>
</div>
</template>
</template>
</Card>
</div>
<Message severity="info" :closable="false">
<span class="text-sm">
@@ -509,44 +463,129 @@ onMounted(async () => {
</template>
<style scoped>
.cfg-icon-box {
display: grid;
place-items: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 0.875rem;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
flex-shrink: 0;
/* ── Subheader degradê ────────────────────────────── */
.cfg-subheader {
display: flex; align-items: center; gap: 0.65rem;
padding: 0.875rem 1rem; border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--primary-color,#6366f1) 30%, transparent);
background: linear-gradient(135deg,
color-mix(in srgb, var(--primary-color,#6366f1) 12%, var(--surface-card)) 0%,
color-mix(in srgb, var(--primary-color,#6366f1) 4%, var(--surface-card)) 60%,
var(--surface-card) 100%);
position: relative; overflow: hidden;
}
.cfg-subheader::before {
content: ''; position: absolute; top: -20px; right: -20px;
width: 80px; height: 80px; border-radius: 50%;
background: color-mix(in srgb, var(--primary-color,#6366f1) 15%, transparent);
filter: blur(20px); pointer-events: none;
}
.cfg-subheader__icon {
display: grid; place-items: center;
width: 2rem; height: 2rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 20%, transparent);
color: var(--primary-color,#6366f1); font-size: 0.85rem;
}
.cfg-subheader__title { font-size: 0.95rem; font-weight: 700; letter-spacing: -0.01em; color: var(--primary-color,#6366f1); }
.cfg-subheader__sub { font-size: 0.75rem; color: var(--text-color-secondary); opacity: 0.85; }
.cfg-subheader__actions { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; flex-shrink: 0; position: relative; z-index: 1; }
/* ── Card wrap ────────────────────────────────────── */
.cfg-wrap {
border: 1px solid var(--surface-border);
border-radius: 6px; background: var(--surface-card);
overflow: hidden;
}
.cfg-wrap__head {
display: flex; align-items: center; gap: 0.625rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-ground);
}
.cfg-wrap__icon {
display: grid; place-items: center;
width: 1.75rem; height: 1.75rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 12%, transparent);
color: var(--primary-color,#6366f1); font-size: 0.8rem;
}
.cfg-wrap__title { font-size: 0.88rem; font-weight: 700; color: var(--text-color); }
.cfg-wrap__body { padding: 1rem; display: flex; flex-direction: column; gap: 0.75rem; }
/* ── Empty state ──────────────────────────────────── */
.cfg-empty {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 0.75rem; padding: 2.5rem 1rem; text-align: center;
color: var(--text-color-secondary);
border: 1px dashed var(--surface-border);
border-radius: 6px; background: var(--surface-ground);
}
.cfg-icon-box-sm {
display: grid;
place-items: center;
width: 2rem;
height: 2rem;
border-radius: 0.625rem;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 10%, transparent);
color: var(--p-primary-500, #6366f1);
flex-shrink: 0;
/* ── Cabeçalho do plano ───────────────────────────── */
.cnv-plan-head {
display: flex; align-items: center; justify-content: space-between;
gap: 0.75rem; padding: 0.75rem 1rem; flex-wrap: wrap;
}
/* ── Painel procedimentos ─────────────────────────── */
.cnv-procedures {
border-top: 1px solid var(--surface-border);
padding: 0.75rem 1rem;
display: flex; flex-direction: column; gap: 0.25rem;
background: var(--surface-ground);
}
.cnv-proc-list {
display: flex; flex-direction: column;
border: 1px solid var(--surface-border);
border-radius: 6px; overflow: hidden;
background: var(--surface-card);
margin-bottom: 0.5rem;
}
.cnv-proc-row {
display: flex; align-items: center; justify-content: space-between;
gap: 0.5rem; padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--surface-border);
transition: background 0.1s;
}
.cnv-proc-row:last-child { border-bottom: none; }
.cnv-proc-row:hover { background: var(--surface-hover); }
.cnv-proc-edit {
padding: 0.75rem;
border-bottom: 1px solid var(--surface-border);
background: color-mix(in srgb, var(--primary-color,#6366f1) 3%, var(--surface-card));
}
.cnv-proc-edit:last-child { border-bottom: none; }
/* ── Form adicionar procedimento ──────────────────── */
.cnv-proc-form {
border: 1px solid var(--surface-border);
border-radius: 6px;
background: var(--surface-card);
padding: 0.75rem;
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
/* ── Labels ───────────────────────────────────────── */
.cnv-label {
display: block;
font-size: 0.72rem; font-weight: 500;
color: var(--text-color-secondary); margin-bottom: 0.25rem;
}
/* ── Quick-fill serviços ──────────────────────────── */
.svc-quick-card {
display: flex;
flex-direction: column;
gap: 0.125rem;
padding: 0.375rem 0.625rem;
border-radius: 0.5rem;
border: 1px solid var(--p-surface-200, #e5e7eb);
background: var(--p-surface-50, #f9fafb);
text-align: left;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
display: flex; flex-direction: column; gap: 0.1rem;
padding: 0.375rem 0.625rem; border-radius: 6px;
border: 1px solid var(--surface-border);
background: var(--surface-ground);
text-align: left; cursor: pointer;
transition: border-color 0.12s, background 0.12s;
}
.svc-quick-card:hover {
border-color: var(--p-primary-400, #818cf8);
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 6%, transparent);
border-color: var(--primary-color,#6366f1);
background: color-mix(in srgb, var(--primary-color,#6366f1) 5%, transparent);
}
.svc-quick-name { font-size: 0.75rem; font-weight: 600; color: var(--p-text-color); }
.svc-quick-price { font-size: 0.7rem; color: var(--p-text-muted-color); }
.svc-quick-name { font-size: 0.72rem; font-weight: 600; color: var(--text-color); }
.svc-quick-price { font-size: 0.68rem; color: var(--text-color-secondary); }
</style>

View File

@@ -184,32 +184,19 @@ onMounted(async () => {
<template>
<Toast />
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3">
<!-- Header -->
<Card>
<template #content>
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-3">
<div class="cfg-icon-box">
<i class="pi pi-percentage text-lg" />
<!-- Subheader -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-percentage" /></div>
<div class="min-w-0">
<div class="cfg-subheader__title">Descontos por Paciente</div>
<div class="cfg-subheader__sub">Descontos recorrentes aplicados automaticamente por paciente</div>
</div>
<div>
<div class="text-900 font-semibold text-lg">Descontos por Paciente</div>
<div class="text-600 text-sm">
Configure descontos recorrentes aplicados automaticamente por paciente.
<div class="cfg-subheader__actions">
<Button label="Novo desconto" icon="pi pi-plus" size="small" :disabled="pageLoading || addingNew" class="rounded-full" @click="addingNew = true; cancelEdit()" />
</div>
</div>
</div>
<Button
label="Novo desconto"
icon="pi pi-plus"
:disabled="pageLoading || addingNew"
@click="addingNew = true; cancelEdit()"
/>
</div>
</template>
</Card>
<!-- Loading -->
<div v-if="pageLoading || loading" class="flex justify-center py-10">
@@ -218,309 +205,144 @@ onMounted(async () => {
<template v-else>
<!-- Lista de descontos -->
<Card v-if="discounts.length || addingNew">
<template #content>
<div class="flex flex-col gap-3">
<!-- Lista + form -->
<div v-if="discounts.length || addingNew" class="cfg-wrap">
<div class="cfg-wrap__head">
<div class="cfg-wrap__icon"><i class="pi pi-percentage" /></div>
<span class="cfg-wrap__title">Descontos cadastrados</span>
<span class="cfg-wrap__count">{{ discounts.length }}</span>
</div>
<div class="dsc-list">
<template v-for="disc in discounts" :key="disc.id">
<!-- Modo edição inline -->
<div v-if="editingId === disc.id" class="discount-row editing">
<div class="grid grid-cols-12 gap-3 flex-1">
<!-- Paciente (desabilitado na edição) -->
<!-- Edição inline -->
<div v-if="editingId === disc.id" class="dsc-form-row dsc-form-row--editing">
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
<Select
v-model="editForm.patient_id"
inputId="edit-patient"
:options="patients"
optionLabel="nome_completo"
optionValue="id"
disabled
class="w-full"
/>
<Select v-model="editForm.patient_id" inputId="edit-patient" :options="patients" optionLabel="nome_completo" optionValue="id" disabled class="w-full" />
<label for="edit-patient">Paciente</label>
</FloatLabel>
</div>
<!-- Desconto % -->
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<InputNumber
v-model="editForm.discount_pct"
inputId="edit-pct"
:min="0"
:max="100"
:minFractionDigits="0"
:maxFractionDigits="2"
suffix="%"
fluid
/>
<InputNumber v-model="editForm.discount_pct" inputId="edit-pct" :min="0" :max="100" :minFractionDigits="0" :maxFractionDigits="2" suffix="%" fluid />
<label for="edit-pct">Desconto %</label>
</FloatLabel>
</div>
<!-- Desconto R$ -->
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<InputNumber
v-model="editForm.discount_flat"
inputId="edit-flat"
mode="currency"
currency="BRL"
locale="pt-BR"
:min="0"
fluid
/>
<InputNumber v-model="editForm.discount_flat" inputId="edit-flat" mode="currency" currency="BRL" locale="pt-BR" :min="0" fluid />
<label for="edit-flat">Desconto R$</label>
</FloatLabel>
</div>
<!-- Vigência: de -->
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<DatePicker
v-model="editForm.active_from"
inputId="edit-from"
dateFormat="dd/mm/yy"
showButtonBar
fluid
/>
<DatePicker v-model="editForm.active_from" inputId="edit-from" dateFormat="dd/mm/yy" showButtonBar fluid />
<label for="edit-from">Vigência: de</label>
</FloatLabel>
</div>
<!-- Vigência: até -->
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<DatePicker
v-model="editForm.active_to"
inputId="edit-to"
dateFormat="dd/mm/yy"
showButtonBar
fluid
/>
<DatePicker v-model="editForm.active_to" inputId="edit-to" dateFormat="dd/mm/yy" showButtonBar fluid />
<label for="edit-to">Vigência: até</label>
</FloatLabel>
</div>
<!-- Motivo -->
<div class="col-span-12">
<FloatLabel variant="on">
<InputText
v-model="editForm.reason"
inputId="edit-reason"
class="w-full"
/>
<InputText v-model="editForm.reason" inputId="edit-reason" class="w-full" />
<label for="edit-reason">Motivo (opcional)</label>
</FloatLabel>
</div>
</div>
<div class="flex gap-2 mt-1">
<Button
icon="pi pi-check"
size="small"
:loading="savingEdit"
@click="saveEdit"
/>
<Button
icon="pi pi-times"
size="small"
severity="secondary"
outlined
@click="cancelEdit"
/>
<div class="flex gap-2 justify-end mt-3">
<Button label="Cancelar" icon="pi pi-times" size="small" severity="secondary" outlined class="rounded-full" @click="cancelEdit" />
<Button label="Salvar" icon="pi pi-check" size="small" :loading="savingEdit" class="rounded-full" @click="saveEdit" />
</div>
</div>
<!-- Modo leitura -->
<div v-else class="discount-row">
<div class="discount-info">
<div class="font-medium text-900">{{ patientName(disc.patient_id) }}</div>
<div class="flex flex-wrap gap-2 mt-1">
<span v-if="fmtPct(disc.discount_pct)" class="discount-badge">
{{ fmtPct(disc.discount_pct) }}
</span>
<span v-if="fmtBRL(disc.discount_flat)" class="discount-badge">
{{ fmtBRL(disc.discount_flat) }}
</span>
<!-- Leitura -->
<div v-else class="dsc-row">
<div class="dsc-row__info">
<div class="font-semibold text-sm">{{ patientName(disc.patient_id) }}</div>
<div class="flex flex-wrap gap-1.5 mt-1">
<span v-if="fmtPct(disc.discount_pct)" class="dsc-badge">{{ fmtPct(disc.discount_pct) }}</span>
<span v-if="fmtBRL(disc.discount_flat)" class="dsc-badge">{{ fmtBRL(disc.discount_flat) }}</span>
</div>
<div class="text-sm text-600 mt-0.5">
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5">
<span v-if="disc.active_from || disc.active_to">
{{ fmtDate(disc.active_from) || 'Indefinido' }}
{{ fmtDate(disc.active_to) || 'Indefinido' }}
{{ fmtDate(disc.active_from) || 'Indefinido' }} {{ fmtDate(disc.active_to) || 'Indefinido' }}
</span>
<span v-else>Vigência indefinida</span>
</div>
<div v-if="disc.reason" class="text-sm text-500 mt-0.5 italic">
{{ disc.reason }}
<div v-if="disc.reason" class="text-xs text-[var(--text-color-secondary)] italic mt-0.5">{{ disc.reason }}</div>
</div>
</div>
<div class="discount-meta">
<Tag
:value="disc.active ? 'Ativo' : 'Inativo'"
:severity="disc.active ? 'success' : 'secondary'"
/>
</div>
<div class="flex gap-2 ml-auto">
<Button
icon="pi pi-pencil"
size="small"
severity="secondary"
text
@click="startEdit(disc); addingNew = false"
/>
<Button
v-if="disc.active"
icon="pi pi-ban"
size="small"
severity="danger"
text
v-tooltip.top="'Desativar'"
@click="confirmRemove(disc.id)"
/>
<div class="flex items-center gap-2 shrink-0">
<Tag :value="disc.active ? 'Ativo' : 'Inativo'" :severity="disc.active ? 'success' : 'secondary'" />
<Button icon="pi pi-pencil" size="small" severity="secondary" text v-tooltip.top="'Editar'" @click="startEdit(disc); addingNew = false" />
<Button v-if="disc.active" icon="pi pi-ban" size="small" severity="danger" text v-tooltip.top="'Desativar'" @click="confirmRemove(disc.id)" />
</div>
</div>
</template>
<!-- Divisor antes do form novo -->
<Divider v-if="discounts.length && addingNew" />
<!-- Formulário novo desconto inline -->
<div v-if="addingNew" class="discount-row new-row">
<div class="grid grid-cols-12 gap-3 flex-1">
<!-- Paciente -->
<!-- Form novo desconto -->
<div v-if="addingNew" class="dsc-form-row dsc-form-row--new">
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
<Select
v-model="newForm.patient_id"
inputId="new-patient"
:options="patients"
optionLabel="nome_completo"
optionValue="id"
filter
class="w-full"
/>
<Select v-model="newForm.patient_id" inputId="new-patient" :options="patients" optionLabel="nome_completo" optionValue="id" filter class="w-full" />
<label for="new-patient">Paciente *</label>
</FloatLabel>
</div>
<!-- Desconto % -->
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<InputNumber
v-model="newForm.discount_pct"
inputId="new-pct"
:min="0"
:max="100"
:minFractionDigits="0"
:maxFractionDigits="2"
suffix="%"
fluid
/>
<InputNumber v-model="newForm.discount_pct" inputId="new-pct" :min="0" :max="100" :minFractionDigits="0" :maxFractionDigits="2" suffix="%" fluid />
<label for="new-pct">Desconto %</label>
</FloatLabel>
</div>
<!-- Desconto R$ -->
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<InputNumber
v-model="newForm.discount_flat"
inputId="new-flat"
mode="currency"
currency="BRL"
locale="pt-BR"
:min="0"
fluid
/>
<InputNumber v-model="newForm.discount_flat" inputId="new-flat" mode="currency" currency="BRL" locale="pt-BR" :min="0" fluid />
<label for="new-flat">Desconto R$</label>
</FloatLabel>
</div>
<!-- Vigência: de -->
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<DatePicker
v-model="newForm.active_from"
inputId="new-from"
dateFormat="dd/mm/yy"
showButtonBar
fluid
/>
<DatePicker v-model="newForm.active_from" inputId="new-from" dateFormat="dd/mm/yy" showButtonBar fluid />
<label for="new-from">Vigência: de</label>
</FloatLabel>
</div>
<!-- Vigência: até -->
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<DatePicker
v-model="newForm.active_to"
inputId="new-to"
dateFormat="dd/mm/yy"
showButtonBar
fluid
/>
<DatePicker v-model="newForm.active_to" inputId="new-to" dateFormat="dd/mm/yy" showButtonBar fluid />
<label for="new-to">Vigência: até</label>
</FloatLabel>
</div>
<!-- Motivo -->
<div class="col-span-12">
<FloatLabel variant="on">
<InputText
v-model="newForm.reason"
inputId="new-reason"
class="w-full"
/>
<InputText v-model="newForm.reason" inputId="new-reason" class="w-full" />
<label for="new-reason">Motivo (opcional)</label>
</FloatLabel>
</div>
</div>
<div class="flex gap-2 mt-1">
<Button
icon="pi pi-check"
label="Adicionar"
size="small"
:loading="savingNew"
@click="saveNew"
/>
<Button
icon="pi pi-times"
size="small"
severity="secondary"
outlined
@click="addingNew = false; newForm = emptyForm()"
/>
<div class="flex gap-2 justify-end mt-3">
<Button label="Cancelar" icon="pi pi-times" size="small" severity="secondary" outlined class="rounded-full" @click="addingNew = false; newForm = emptyForm()" />
<Button label="Adicionar" icon="pi pi-check" size="small" :loading="savingNew" class="rounded-full" @click="saveNew" />
</div>
</div>
</div>
</template>
</Card>
</div>
<!-- Estado vazio -->
<Card v-else>
<template #content>
<div class="flex flex-col items-center gap-3 py-6 text-center">
<i class="pi pi-percentage text-4xl text-400" />
<div class="text-600">Nenhum desconto cadastrado ainda.</div>
<Button
label="Adicionar primeiro desconto"
icon="pi pi-plus"
outlined
@click="addingNew = true"
/>
<div v-else class="cfg-empty">
<i class="pi pi-percentage text-3xl opacity-25" />
<div class="text-sm font-medium">Nenhum desconto cadastrado ainda.</div>
<Button label="Adicionar primeiro desconto" icon="pi pi-plus" outlined size="small" class="rounded-full" @click="addingNew = true" />
</div>
</template>
</Card>
<!-- Dica -->
<Message severity="info" :closable="false">
@@ -535,56 +357,103 @@ onMounted(async () => {
</template>
<style scoped>
.cfg-icon-box {
display: grid;
place-items: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 0.875rem;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
flex-shrink: 0;
/* ── Subheader degradê ────────────────────────────── */
.cfg-subheader {
display: flex; align-items: center; gap: 0.65rem;
padding: 0.875rem 1rem; border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--primary-color,#6366f1) 30%, transparent);
background: linear-gradient(135deg,
color-mix(in srgb, var(--primary-color,#6366f1) 12%, var(--surface-card)) 0%,
color-mix(in srgb, var(--primary-color,#6366f1) 4%, var(--surface-card)) 60%,
var(--surface-card) 100%);
position: relative; overflow: hidden;
}
.cfg-subheader::before {
content: ''; position: absolute; top: -20px; right: -20px;
width: 80px; height: 80px; border-radius: 50%;
background: color-mix(in srgb, var(--primary-color,#6366f1) 15%, transparent);
filter: blur(20px); pointer-events: none;
}
.cfg-subheader__icon {
display: grid; place-items: center;
width: 2rem; height: 2rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 20%, transparent);
color: var(--primary-color,#6366f1); font-size: 0.85rem;
}
.cfg-subheader__title { font-size: 0.95rem; font-weight: 700; letter-spacing: -0.01em; color: var(--primary-color,#6366f1); }
.cfg-subheader__sub { font-size: 0.75rem; color: var(--text-color-secondary); opacity: 0.85; }
.cfg-subheader__actions { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; flex-shrink: 0; position: relative; z-index: 1; }
.discount-row {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem;
border-radius: 0.75rem;
/* ── Card wrap ────────────────────────────────────── */
.cfg-wrap {
border: 1px solid var(--surface-border);
border-radius: 6px; background: var(--surface-card);
overflow: hidden;
}
.cfg-wrap__head {
display: flex; align-items: center; gap: 0.625rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-ground);
flex-wrap: wrap;
}
.cfg-wrap__icon {
display: grid; place-items: center;
width: 1.75rem; height: 1.75rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 12%, transparent);
color: var(--primary-color,#6366f1); font-size: 0.8rem;
}
.cfg-wrap__title { font-size: 0.88rem; font-weight: 700; color: var(--text-color); flex: 1; }
.cfg-wrap__count {
font-size: 0.7rem; font-weight: 700;
background: var(--primary-color,#6366f1); color: #fff;
padding: 1px 8px; border-radius: 999px; flex-shrink: 0;
}
.discount-row.editing {
border-color: var(--p-primary-300, #a5b4fc);
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 4%, var(--surface-ground));
}
/* ── Lista de descontos ───────────────────────────── */
.dsc-list { display: flex; flex-direction: column; }
.discount-row.new-row {
border-style: dashed;
/* Linha de leitura */
.dsc-row {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 0.75rem; padding: 0.75rem 1rem;
border-bottom: 1px solid var(--surface-border);
transition: background 0.1s; flex-wrap: wrap;
}
.dsc-row:last-child { border-bottom: none; }
.dsc-row:hover { background: var(--surface-hover); }
.dsc-row__info { flex: 1; min-width: 0; }
.discount-info {
flex: 1;
min-width: 8rem;
}
.discount-meta {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.discount-badge {
font-weight: 600;
color: var(--p-primary-600, #4f46e5);
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 10%, transparent);
padding: 0.2rem 0.6rem;
border-radius: 1rem;
font-size: 0.875rem;
/* Badge de valor */
.dsc-badge {
font-size: 0.75rem; font-weight: 600;
color: var(--primary-color,#6366f1);
background: color-mix(in srgb, var(--primary-color,#6366f1) 10%, transparent);
padding: 0.15rem 0.5rem; border-radius: 6px;
white-space: nowrap;
}
/* Form de adição/edição */
.dsc-form-row {
padding: 1rem;
border-bottom: 1px solid var(--surface-border);
}
.dsc-form-row:last-child { border-bottom: none; }
.dsc-form-row--editing {
background: color-mix(in srgb, var(--primary-color,#6366f1) 3%, var(--surface-card));
border-left: 3px solid color-mix(in srgb, var(--primary-color,#6366f1) 50%, transparent);
}
.dsc-form-row--new {
background: var(--surface-ground);
border-top: 1px dashed var(--surface-border);
border-bottom: none;
}
/* ── Empty state ──────────────────────────────────── */
.cfg-empty {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 0.75rem; padding: 2.5rem 1rem; text-align: center;
color: var(--text-color-secondary);
border: 1px dashed var(--surface-border);
border-radius: 6px; background: var(--surface-ground);
}
</style>

View File

@@ -145,24 +145,16 @@ onMounted(async () => {
<template>
<Toast />
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3">
<!-- Header -->
<Card>
<template #content>
<div class="flex items-center gap-3">
<div class="cfg-icon-box">
<i class="pi pi-exclamation-triangle text-lg" />
</div>
<div>
<div class="text-900 font-semibold text-lg">Exceções Financeiras</div>
<div class="text-600 text-sm">
Defina o que cobrar em situações excepcionais de cancelamento ou falta.
<!-- Subheader -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-exclamation-triangle" /></div>
<div class="min-w-0">
<div class="cfg-subheader__title">Exceções Financeiras</div>
<div class="cfg-subheader__sub">Defina o que cobrar em cancelamentos, faltas e situações excepcionais</div>
</div>
</div>
</div>
</template>
</Card>
<!-- Loading -->
<div v-if="pageLoading || loading" class="flex justify-center py-10">
@@ -172,154 +164,99 @@ onMounted(async () => {
<template v-else>
<!-- Um card por tipo de exceção -->
<Card v-for="et in exceptionTypes" :key="et.value">
<template #content>
<!-- Modo leitura -->
<template v-if="editingType !== et.value">
<div class="exception-row">
<div class="exception-info">
<div class="font-semibold text-900 text-base">{{ et.label }}</div>
<div v-for="et in exceptionTypes" :key="et.value" class="cfg-wrap">
<!-- Cabeçalho do card -->
<div class="cfg-wrap__head">
<div class="cfg-wrap__icon"><i class="pi pi-exclamation-triangle" /></div>
<span class="cfg-wrap__title">{{ et.label }}</span>
<div class="ml-auto flex items-center gap-2 shrink-0">
<template v-if="recordFor(et.value)">
<div class="text-sm text-600 mt-1">
{{ summaryFor(recordFor(et.value)) }}
<span
v-if="et.value === 'patient_cancellation' && recordFor(et.value)?.min_hours_notice"
class="text-500"
>
cobrar apenas se cancelado com menos de
{{ recordFor(et.value).min_hours_notice }}h de antecedência
</span>
</div>
<div class="flex gap-2 mt-2 flex-wrap">
<Tag
:value="chargeModeLabel(recordFor(et.value)?.charge_mode)"
:severity="chargeModeSeverity[recordFor(et.value)?.charge_mode] ?? 'secondary'"
/>
<Tag
v-if="isGlobalRecord(recordFor(et.value))"
value="Regra da clínica"
severity="secondary"
/>
</div>
<Tag v-if="isGlobalRecord(recordFor(et.value))" value="Regra da clínica" severity="secondary" />
</template>
<div v-else class="text-sm text-500 mt-1 italic">
Não configurado comportamento padrão: não cobrar.
</div>
</div>
<Button
v-if="!isGlobalRecord(recordFor(et.value))"
v-if="editingType !== et.value && !isGlobalRecord(recordFor(et.value))"
label="Configurar"
icon="pi pi-cog"
size="small"
severity="secondary"
outlined
class="ml-auto flex-shrink-0"
class="rounded-full"
@click="startEdit(et.value)"
/>
</div>
</div>
<!-- Modo leitura -->
<div v-if="editingType !== et.value" class="exc-read">
<template v-if="recordFor(et.value)">
<div class="text-sm text-[var(--text-color-secondary)]">
{{ summaryFor(recordFor(et.value)) }}
<span v-if="et.value === 'patient_cancellation' && recordFor(et.value)?.min_hours_notice">
cobrar apenas se cancelado com menos de {{ recordFor(et.value).min_hours_notice }}h de antecedência
</span>
</div>
</template>
<div v-else class="text-sm text-[var(--text-color-secondary)] italic">
Não configurado comportamento padrão: não cobrar.
</div>
</div>
<!-- Modo edição inline -->
<template v-else>
<div class="exception-row editing">
<div class="flex flex-col gap-4 flex-1">
<div class="font-semibold text-900">{{ et.label }}</div>
<!-- Modo edição -->
<div v-else class="exc-edit">
<!-- Modo de cobrança -->
<div>
<label class="text-sm text-600 block mb-2">Modo de cobrança</label>
<label class="exc-label">Modo de cobrança</label>
<SelectButton
v-model="editForm.charge_mode"
:options="chargeModeOptions"
optionLabel="label"
optionValue="value"
class="flex-wrap"
class="flex-wrap mt-1"
/>
</div>
<div class="grid grid-cols-12 gap-3">
<!-- Taxa fixa (R$) -->
<!-- Taxa fixa -->
<div v-if="showChargeValue" class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
<InputNumber
v-model="editForm.charge_value"
inputId="edit-charge-value"
mode="currency"
currency="BRL"
locale="pt-BR"
:min="0"
fluid
/>
<InputNumber v-model="editForm.charge_value" inputId="edit-charge-value" mode="currency" currency="BRL" locale="pt-BR" :min="0" fluid />
<label for="edit-charge-value">Taxa fixa (R$)</label>
</FloatLabel>
</div>
<!-- Percentual (%) -->
<!-- Percentual -->
<div v-if="showChargePct" class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
<InputNumber
v-model="editForm.charge_pct"
inputId="edit-charge-pct"
:min="0"
:max="100"
:minFractionDigits="0"
:maxFractionDigits="2"
suffix="%"
fluid
/>
<InputNumber v-model="editForm.charge_pct" inputId="edit-charge-pct" :min="0" :max="100" :minFractionDigits="0" :maxFractionDigits="2" suffix="%" fluid />
<label for="edit-charge-pct">Percentual da sessão (%)</label>
</FloatLabel>
</div>
<!-- Antecedência mínima (apenas patient_cancellation) -->
<div v-if="showMinHours" class="col-span-12 sm:col-span-5">
<!-- Antecedência mínima -->
<div v-if="showMinHours" class="col-span-12 sm:col-span-6">
<FloatLabel variant="on">
<InputNumber
v-model="editForm.min_hours_notice"
inputId="edit-min-hours"
:min="0"
:max="720"
suffix=" h"
fluid
showButtons
/>
<InputNumber v-model="editForm.min_hours_notice" inputId="edit-min-hours" :min="0" :max="720" suffix=" h" fluid showButtons />
<label for="edit-min-hours">Cobrar se cancelado com menos de X horas (vazio = sempre)</label>
</FloatLabel>
<small class="text-500 mt-1 block">
<small class="text-[var(--text-color-secondary)] opacity-70 mt-1 block text-xs">
Deixe em branco para cobrar independentemente da antecedência.
</small>
</div>
</div>
<div class="flex gap-2">
<Button
icon="pi pi-check"
label="Salvar"
size="small"
:loading="savingEdit"
@click="saveEdit"
/>
<Button
icon="pi pi-times"
size="small"
severity="secondary"
outlined
@click="cancelEdit"
/>
<!-- Botões na linha separada -->
<div class="flex gap-2 justify-end mt-1">
<Button label="Cancelar" icon="pi pi-times" size="small" severity="secondary" outlined class="rounded-full" @click="cancelEdit" />
<Button label="Salvar" icon="pi pi-check" size="small" :loading="savingEdit" class="rounded-full" @click="saveEdit" />
</div>
</div>
</div>
</div>
</template>
</template>
</Card>
<!-- Dica -->
<Message severity="info" :closable="false">
@@ -334,33 +271,69 @@ onMounted(async () => {
</template>
<style scoped>
.cfg-icon-box {
display: grid;
place-items: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 0.875rem;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
flex-shrink: 0;
/* ── Subheader degradê ────────────────────────────── */
.cfg-subheader {
display: flex; align-items: center; gap: 0.65rem;
padding: 0.875rem 1rem; border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--primary-color,#6366f1) 30%, transparent);
background: linear-gradient(135deg,
color-mix(in srgb, var(--primary-color,#6366f1) 12%, var(--surface-card)) 0%,
color-mix(in srgb, var(--primary-color,#6366f1) 4%, var(--surface-card)) 60%,
var(--surface-card) 100%);
position: relative; overflow: hidden;
}
.cfg-subheader::before {
content: ''; position: absolute; top: -20px; right: -20px;
width: 80px; height: 80px; border-radius: 50%;
background: color-mix(in srgb, var(--primary-color,#6366f1) 15%, transparent);
filter: blur(20px); pointer-events: none;
}
.cfg-subheader__icon {
display: grid; place-items: center;
width: 2rem; height: 2rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 20%, transparent);
color: var(--primary-color,#6366f1); font-size: 0.85rem;
}
.cfg-subheader__title { font-size: 0.95rem; font-weight: 700; letter-spacing: -0.01em; color: var(--primary-color,#6366f1); }
.cfg-subheader__sub { font-size: 0.75rem; color: var(--text-color-secondary); opacity: 0.85; }
.cfg-subheader__actions { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; flex-shrink: 0; position: relative; z-index: 1; }
/* ── Card wrap ────────────────────────────────────── */
.cfg-wrap {
border: 1px solid var(--surface-border);
border-radius: 6px; background: var(--surface-card);
overflow: hidden;
}
.cfg-wrap__head {
display: flex; align-items: center; gap: 0.625rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-ground); flex-wrap: wrap;
}
.cfg-wrap__icon {
display: grid; place-items: center;
width: 1.75rem; height: 1.75rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 12%, transparent);
color: var(--primary-color,#6366f1); font-size: 0.8rem;
}
.cfg-wrap__title { font-size: 0.88rem; font-weight: 700; color: var(--text-color); }
/* ── Leitura ──────────────────────────────────────── */
.exc-read {
padding: 0.75rem 1rem;
}
.exception-row {
display: flex;
align-items: flex-start;
gap: 1rem;
flex-wrap: wrap;
}
.exception-row.editing {
border: 1px solid var(--p-primary-300, #a5b4fc);
border-radius: 0.75rem;
/* ── Edição ───────────────────────────────────────── */
.exc-edit {
padding: 1rem;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 4%, var(--surface-ground));
display: flex; flex-direction: column; gap: 1rem;
background: color-mix(in srgb, var(--primary-color,#6366f1) 3%, var(--surface-card));
border-top: 2px solid color-mix(in srgb, var(--primary-color,#6366f1) 40%, transparent);
}
.exception-info {
flex: 1;
min-width: 10rem;
/* ── Label ────────────────────────────────────────── */
.exc-label {
display: block; font-size: 0.75rem; font-weight: 600;
color: var(--text-color-secondary); margin-bottom: 0.375rem;
}
</style>

View File

@@ -10,7 +10,8 @@ const tenantStore = useTenantStore()
const loading = ref(true)
const ownerId = ref(null)
const expandedCard = ref(null)
const CARDS = ['pix', 'deposito', 'dinheiro', 'cartao', 'convenio', 'observacoes']
const expandedCard = ref(new Set())
const savingCard = ref(null)
// ── Defaults ────────────────────────────────────────────────────
@@ -84,8 +85,13 @@ const bancos = [
]
// ── Toggle cards ─────────────────────────────────────────────────
function expandAll () { expandedCard.value = new Set(CARDS) }
function collapseAll () { expandedCard.value = new Set() }
function toggleCard (key) {
expandedCard.value = expandedCard.value === key ? null : key
const s = new Set(expandedCard.value)
if (s.has(key)) s.delete(key)
else s.add(key)
expandedCard.value = s
}
// ── Load ─────────────────────────────────────────────────────────
@@ -173,30 +179,40 @@ onMounted(load)
<template>
<Toast />
<div v-if="loading" class="flex items-center gap-2 p-6 text-slate-500">
<div v-if="loading" class="flex items-center gap-2 p-6 text-[var(--text-color-secondary)]">
<i class="pi pi-spin pi-spinner" /> Carregando
</div>
<div v-else class="flex flex-col gap-4">
<!-- Subheader -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-wallet" /></div>
<div class="min-w-0">
<div class="cfg-subheader__title">Pagamento</div>
<div class="cfg-subheader__sub">Formas de pagamento aceitas: Pix, depósito, dinheiro, cartão e convênio</div>
</div>
<div class="cfg-subheader__actions">
<Button size="small" icon="pi pi-arrows-v" label="Expandir" severity="secondary" outlined class="rounded-full" @click="expandAll" />
<Button size="small" icon="pi pi-minus" label="Contrair" severity="secondary" outlined class="rounded-full" @click="collapseAll" />
</div>
</div>
<!-- Pix -->
<div class="rounded-2xl border bg-[var(--surface-card)] overflow-hidden"
<div class="rounded-[6px] border bg-[var(--surface-card)] overflow-hidden"
:class="cfg.pix_ativo ? 'border-green-300' : 'border-[var(--surface-border)]'">
<!-- Header -->
<button
type="button"
class="w-full flex items-center justify-between gap-4 px-5 py-4 hover:bg-[var(--surface-hover)] transition text-left"
@click="toggleCard('pix')"
<button type="button" class="pag-accordion__header" @click="toggleCard('pix')"
>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl flex items-center justify-center shrink-0"
:class="cfg.pix_ativo ? 'bg-green-100 text-green-700' : 'bg-slate-100 text-slate-400'">
<div class="w-10 h-10 rounded-[6px] flex items-center justify-center shrink-0"
:class="cfg.pix_ativo ? 'bg-green-100 text-green-700' : 'bg-[var(--surface-ground)] text-[var(--text-color-secondary)]'">
<i class="pi pi-qrcode text-lg" />
</div>
<div>
<div class="font-semibold text-slate-800">Pix</div>
<div class="text-sm text-slate-500">
<div class="font-semibold text-[var(--text-color)]">Pix</div>
<div class="text-sm text-[var(--text-color-secondary)]">
{{ cfg.pix_ativo && cfg.pix_chave ? `Chave: ${cfg.pix_chave}` : 'Pagamento instantâneo via chave Pix' }}
</div>
</div>
@@ -204,21 +220,21 @@ onMounted(load)
<div class="flex items-center gap-3 shrink-0">
<Tag v-if="cfg.pix_ativo" value="Ativo" severity="success" />
<Tag v-else value="Inativo" severity="secondary" />
<i class="pi text-slate-400" :class="expandedCard === 'pix' ? 'pi-angle-up' : 'pi-angle-down'" />
<i class="pi text-[var(--text-color-secondary)]" :class="expandedCard.has('pix') ? 'pi-angle-up' : 'pi-angle-down'" />
</div>
</button>
<!-- Body -->
<div v-if="expandedCard === 'pix'" class="border-t border-[var(--surface-border)] px-5 py-5 flex flex-col gap-4">
<div v-if="expandedCard.has('pix')" class="pag-accordion__body">
<div class="flex items-center justify-between">
<span class="font-medium text-slate-700">Habilitar Pix</span>
<span class="font-medium text-[var(--text-color)]">Habilitar Pix</span>
<ToggleSwitch v-model="cfg.pix_ativo" />
</div>
<template v-if="cfg.pix_ativo">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-600 mb-1">Tipo de chave</label>
<label class="block text-sm font-medium text-[var(--text-color)] mb-1">Tipo de chave</label>
<Select
v-model="cfg.pix_tipo"
:options="pixTipoOptions"
@@ -228,13 +244,13 @@ onMounted(load)
/>
</div>
<div>
<label class="block text-sm font-medium text-slate-600 mb-1">
<label class="block text-sm font-medium text-[var(--text-color)] mb-1">
{{ pixTipoLabel[cfg.pix_tipo] || 'Chave' }}
</label>
<InputText v-model="cfg.pix_chave" class="w-full" :placeholder="pixTipoLabel[cfg.pix_tipo]" />
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-slate-600 mb-1">Nome do titular</label>
<label class="block text-sm font-medium text-[var(--text-color)] mb-1">Nome do titular</label>
<InputText v-model="cfg.pix_nome_titular" class="w-full" placeholder="Nome que aparece na chave" />
</div>
</div>
@@ -252,22 +268,19 @@ onMounted(load)
</div>
<!-- Depósito bancário -->
<div class="rounded-2xl border bg-[var(--surface-card)] overflow-hidden"
<div class="rounded-[6px] border bg-[var(--surface-card)] overflow-hidden"
:class="cfg.deposito_ativo ? 'border-blue-300' : 'border-[var(--surface-border)]'">
<button
type="button"
class="w-full flex items-center justify-between gap-4 px-5 py-4 hover:bg-[var(--surface-hover)] transition text-left"
@click="toggleCard('deposito')"
<button type="button" class="pag-accordion__header" @click="toggleCard('deposito')"
>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl flex items-center justify-center shrink-0"
:class="cfg.deposito_ativo ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-400'">
<div class="w-10 h-10 rounded-[6px] flex items-center justify-center shrink-0"
:class="cfg.deposito_ativo ? 'bg-blue-100 text-blue-700' : 'bg-[var(--surface-ground)] text-[var(--text-color-secondary)]'">
<i class="pi pi-building-columns text-lg" />
</div>
<div>
<div class="font-semibold text-slate-800">Depósito / TED</div>
<div class="text-sm text-slate-500">
<div class="font-semibold text-[var(--text-color)]">Depósito / TED</div>
<div class="text-sm text-[var(--text-color-secondary)]">
{{ cfg.deposito_ativo && cfg.deposito_banco ? `${cfg.deposito_banco} · Ag. ${cfg.deposito_agencia || '—'} · Conta ${cfg.deposito_conta || '—'}` : 'Transferência bancária ou depósito' }}
</div>
</div>
@@ -275,20 +288,20 @@ onMounted(load)
<div class="flex items-center gap-3 shrink-0">
<Tag v-if="cfg.deposito_ativo" value="Ativo" severity="success" />
<Tag v-else value="Inativo" severity="secondary" />
<i class="pi text-slate-400" :class="expandedCard === 'deposito' ? 'pi-angle-up' : 'pi-angle-down'" />
<i class="pi text-[var(--text-color-secondary)]" :class="expandedCard.has('deposito') ? 'pi-angle-up' : 'pi-angle-down'" />
</div>
</button>
<div v-if="expandedCard === 'deposito'" class="border-t border-[var(--surface-border)] px-5 py-5 flex flex-col gap-4">
<div v-if="expandedCard.has('deposito')" class="pag-accordion__body">
<div class="flex items-center justify-between">
<span class="font-medium text-slate-700">Habilitar Depósito / TED</span>
<span class="font-medium text-[var(--text-color)]">Habilitar Depósito / TED</span>
<ToggleSwitch v-model="cfg.deposito_ativo" />
</div>
<template v-if="cfg.deposito_ativo">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-600 mb-1">Banco</label>
<label class="block text-sm font-medium text-[var(--text-color)] mb-1">Banco</label>
<Select
v-model="cfg.deposito_banco"
:options="bancos"
@@ -300,7 +313,7 @@ onMounted(load)
/>
</div>
<div>
<label class="block text-sm font-medium text-slate-600 mb-1">Tipo de conta</label>
<label class="block text-sm font-medium text-[var(--text-color)] mb-1">Tipo de conta</label>
<Select
v-model="cfg.deposito_tipo_conta"
:options="tipoConta"
@@ -310,19 +323,19 @@ onMounted(load)
/>
</div>
<div>
<label class="block text-sm font-medium text-slate-600 mb-1">Agência</label>
<label class="block text-sm font-medium text-[var(--text-color)] mb-1">Agência</label>
<InputText v-model="cfg.deposito_agencia" class="w-full" placeholder="0000" />
</div>
<div>
<label class="block text-sm font-medium text-slate-600 mb-1">Conta</label>
<label class="block text-sm font-medium text-[var(--text-color)] mb-1">Conta</label>
<InputText v-model="cfg.deposito_conta" class="w-full" placeholder="00000-0" />
</div>
<div>
<label class="block text-sm font-medium text-slate-600 mb-1">Titular</label>
<label class="block text-sm font-medium text-[var(--text-color)] mb-1">Titular</label>
<InputText v-model="cfg.deposito_titular" class="w-full" placeholder="Nome completo ou razão social" />
</div>
<div>
<label class="block text-sm font-medium text-slate-600 mb-1">CPF / CNPJ do titular</label>
<label class="block text-sm font-medium text-[var(--text-color)] mb-1">CPF / CNPJ do titular</label>
<InputText v-model="cfg.deposito_cpf_cnpj" class="w-full" placeholder="000.000.000-00" />
</div>
</div>
@@ -340,36 +353,33 @@ onMounted(load)
</div>
<!-- Dinheiro -->
<div class="rounded-2xl border bg-[var(--surface-card)] overflow-hidden"
<div class="rounded-[6px] border bg-[var(--surface-card)] overflow-hidden"
:class="cfg.dinheiro_ativo ? 'border-yellow-300' : 'border-[var(--surface-border)]'">
<button
type="button"
class="w-full flex items-center justify-between gap-4 px-5 py-4 hover:bg-[var(--surface-hover)] transition text-left"
@click="toggleCard('dinheiro')"
<button type="button" class="pag-accordion__header" @click="toggleCard('dinheiro')"
>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl flex items-center justify-center shrink-0"
:class="cfg.dinheiro_ativo ? 'bg-yellow-100 text-yellow-700' : 'bg-slate-100 text-slate-400'">
<div class="w-10 h-10 rounded-[6px] flex items-center justify-center shrink-0"
:class="cfg.dinheiro_ativo ? 'bg-yellow-100 text-yellow-700' : 'bg-[var(--surface-ground)] text-[var(--text-color-secondary)]'">
<i class="pi pi-wallet text-lg" />
</div>
<div>
<div class="font-semibold text-slate-800">Dinheiro (espécie)</div>
<div class="text-sm text-slate-500">Pagamento presencial em dinheiro</div>
<div class="font-semibold text-[var(--text-color)]">Dinheiro (espécie)</div>
<div class="text-sm text-[var(--text-color-secondary)]">Pagamento presencial em dinheiro</div>
</div>
</div>
<div class="flex items-center gap-3 shrink-0">
<Tag v-if="cfg.dinheiro_ativo" value="Ativo" severity="success" />
<Tag v-else value="Inativo" severity="secondary" />
<i class="pi text-slate-400" :class="expandedCard === 'dinheiro' ? 'pi-angle-up' : 'pi-angle-down'" />
<i class="pi text-[var(--text-color-secondary)]" :class="expandedCard.has('dinheiro') ? 'pi-angle-up' : 'pi-angle-down'" />
</div>
</button>
<div v-if="expandedCard === 'dinheiro'" class="border-t border-[var(--surface-border)] px-5 py-5 flex flex-col gap-4">
<div v-if="expandedCard.has('dinheiro')" class="pag-accordion__body">
<div class="flex items-center justify-between">
<div>
<span class="font-medium text-slate-700">Habilitar pagamento em dinheiro</span>
<p class="text-sm text-slate-500 mt-1">
<span class="font-medium text-[var(--text-color)]">Habilitar pagamento em dinheiro</span>
<p class="text-sm text-[var(--text-color-secondary)] mt-1">
Aceitar pagamento em espécie nas sessões presenciais.
</p>
</div>
@@ -388,36 +398,33 @@ onMounted(load)
</div>
<!-- Cartão -->
<div class="rounded-2xl border bg-[var(--surface-card)] overflow-hidden"
<div class="rounded-[6px] border bg-[var(--surface-card)] overflow-hidden"
:class="cfg.cartao_ativo ? 'border-purple-300' : 'border-[var(--surface-border)]'">
<button
type="button"
class="w-full flex items-center justify-between gap-4 px-5 py-4 hover:bg-[var(--surface-hover)] transition text-left"
@click="toggleCard('cartao')"
<button type="button" class="pag-accordion__header" @click="toggleCard('cartao')"
>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl flex items-center justify-center shrink-0"
:class="cfg.cartao_ativo ? 'bg-purple-100 text-purple-700' : 'bg-slate-100 text-slate-400'">
<div class="w-10 h-10 rounded-[6px] flex items-center justify-center shrink-0"
:class="cfg.cartao_ativo ? 'bg-purple-100 text-purple-700' : 'bg-[var(--surface-ground)] text-[var(--text-color-secondary)]'">
<i class="pi pi-credit-card text-lg" />
</div>
<div>
<div class="font-semibold text-slate-800">Cartão (maquininha)</div>
<div class="text-sm text-slate-500">Crédito e débito presencial</div>
<div class="font-semibold text-[var(--text-color)]">Cartão (maquininha)</div>
<div class="text-sm text-[var(--text-color-secondary)]">Crédito e débito presencial</div>
</div>
</div>
<div class="flex items-center gap-3 shrink-0">
<Tag v-if="cfg.cartao_ativo" value="Ativo" severity="success" />
<Tag v-else value="Inativo" severity="secondary" />
<i class="pi text-slate-400" :class="expandedCard === 'cartao' ? 'pi-angle-up' : 'pi-angle-down'" />
<i class="pi text-[var(--text-color-secondary)]" :class="expandedCard.has('cartao') ? 'pi-angle-up' : 'pi-angle-down'" />
</div>
</button>
<div v-if="expandedCard === 'cartao'" class="border-t border-[var(--surface-border)] px-5 py-5 flex flex-col gap-4">
<div v-if="expandedCard.has('cartao')" class="pag-accordion__body">
<div class="flex items-center justify-between">
<div>
<span class="font-medium text-slate-700">Habilitar pagamento por cartão</span>
<p class="text-sm text-slate-500 mt-1">
<span class="font-medium text-[var(--text-color)]">Habilitar pagamento por cartão</span>
<p class="text-sm text-[var(--text-color-secondary)] mt-1">
Aceitar cartão de crédito e débito via maquininha nas sessões presenciais.
</p>
</div>
@@ -426,7 +433,7 @@ onMounted(load)
<template v-if="cfg.cartao_ativo">
<div>
<label class="block text-sm font-medium text-slate-600 mb-1">Instrução ao paciente <span class="text-slate-400">(opcional)</span></label>
<label class="block text-sm font-medium text-[var(--text-color)] mb-1">Instrução ao paciente <span class="text-[var(--text-color-secondary)]">(opcional)</span></label>
<InputText
v-model="cfg.cartao_instrucao"
class="w-full"
@@ -447,22 +454,19 @@ onMounted(load)
</div>
<!-- Plano de Saúde / Convênio -->
<div class="rounded-2xl border bg-[var(--surface-card)] overflow-hidden"
<div class="rounded-[6px] border bg-[var(--surface-card)] overflow-hidden"
:class="cfg.convenio_ativo ? 'border-teal-300' : 'border-[var(--surface-border)]'">
<button
type="button"
class="w-full flex items-center justify-between gap-4 px-5 py-4 hover:bg-[var(--surface-hover)] transition text-left"
@click="toggleCard('convenio')"
<button type="button" class="pag-accordion__header" @click="toggleCard('convenio')"
>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl flex items-center justify-center shrink-0"
:class="cfg.convenio_ativo ? 'bg-teal-100 text-teal-700' : 'bg-slate-100 text-slate-400'">
<div class="w-10 h-10 rounded-[6px] flex items-center justify-center shrink-0"
:class="cfg.convenio_ativo ? 'bg-teal-100 text-teal-700' : 'bg-[var(--surface-ground)] text-[var(--text-color-secondary)]'">
<i class="pi pi-heart text-lg" />
</div>
<div>
<div class="font-semibold text-slate-800">Plano de saúde / Convênio</div>
<div class="text-sm text-slate-500">
<div class="font-semibold text-[var(--text-color)]">Plano de saúde / Convênio</div>
<div class="text-sm text-[var(--text-color-secondary)]">
{{ cfg.convenio_ativo && cfg.convenio_lista ? cfg.convenio_lista.slice(0, 60) + (cfg.convenio_lista.length > 60 ? '…' : '') : 'Atendimento por convênio' }}
</div>
</div>
@@ -470,15 +474,15 @@ onMounted(load)
<div class="flex items-center gap-3 shrink-0">
<Tag v-if="cfg.convenio_ativo" value="Ativo" severity="success" />
<Tag v-else value="Inativo" severity="secondary" />
<i class="pi text-slate-400" :class="expandedCard === 'convenio' ? 'pi-angle-up' : 'pi-angle-down'" />
<i class="pi text-[var(--text-color-secondary)]" :class="expandedCard.has('convenio') ? 'pi-angle-up' : 'pi-angle-down'" />
</div>
</button>
<div v-if="expandedCard === 'convenio'" class="border-t border-[var(--surface-border)] px-5 py-5 flex flex-col gap-4">
<div v-if="expandedCard.has('convenio')" class="pag-accordion__body">
<div class="flex items-center justify-between">
<div>
<span class="font-medium text-slate-700">Aceitar plano de saúde / convênio</span>
<p class="text-sm text-slate-500 mt-1">
<span class="font-medium text-[var(--text-color)]">Aceitar plano de saúde / convênio</span>
<p class="text-sm text-[var(--text-color-secondary)] mt-1">
Habilite para informar quais convênios são aceitos.
</p>
</div>
@@ -487,7 +491,7 @@ onMounted(load)
<template v-if="cfg.convenio_ativo">
<div>
<label class="block text-sm font-medium text-slate-600 mb-1">Convênios aceitos</label>
<label class="block text-sm font-medium text-[var(--text-color)] mb-1">Convênios aceitos</label>
<Textarea
v-model="cfg.convenio_lista"
rows="3"
@@ -495,7 +499,7 @@ onMounted(load)
placeholder="Ex: Unimed, Bradesco Saúde, Amil, SulAmérica..."
autoResize
/>
<small class="text-slate-400">Liste os convênios separados por vírgula ou um por linha.</small>
<small class="text-[var(--text-color-secondary)]">Liste os convênios separados por vírgula ou um por linha.</small>
</div>
</template>
@@ -511,26 +515,23 @@ onMounted(load)
</div>
<!-- Observações gerais -->
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<div class="pag-accordion">
<button
type="button"
class="w-full flex items-center justify-between gap-4 px-5 py-4 hover:bg-[var(--surface-hover)] transition text-left"
@click="toggleCard('observacoes')"
<button type="button" class="pag-accordion__header" @click="toggleCard('observacoes')"
>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-slate-100 text-slate-400 flex items-center justify-center shrink-0">
<div class="pag-accordion__icon bg-[var(--surface-ground)] text-[var(--text-color-secondary)]">
<i class="pi pi-comment text-lg" />
</div>
<div>
<div class="font-semibold text-slate-800">Observações ao paciente</div>
<div class="text-sm text-slate-500">Texto exibido junto às formas de pagamento</div>
<div class="font-semibold text-[var(--text-color)]">Observações ao paciente</div>
<div class="text-sm text-[var(--text-color-secondary)]">Texto exibido junto às formas de pagamento</div>
</div>
</div>
<i class="pi text-slate-400" :class="expandedCard === 'observacoes' ? 'pi-angle-up' : 'pi-angle-down'" />
<i class="pi text-[var(--text-color-secondary)]" :class="expandedCard.has('observacoes') ? 'pi-angle-up' : 'pi-angle-down'" />
</button>
<div v-if="expandedCard === 'observacoes'" class="border-t border-[var(--surface-border)] px-5 py-5 flex flex-col gap-4">
<div v-if="expandedCard.has('observacoes')" class="pag-accordion__body">
<Textarea
v-model="cfg.observacoes_pagamento"
rows="4"
@@ -551,3 +552,87 @@ onMounted(load)
</div>
</template>
<style scoped>
/* ── Subheader degradê ────────────────────────────── */
.cfg-subheader {
display: flex; align-items: center; gap: 0.65rem;
padding: 0.875rem 1rem; border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--primary-color,#6366f1) 30%, transparent);
background: linear-gradient(135deg,
color-mix(in srgb, var(--primary-color,#6366f1) 12%, var(--surface-card)) 0%,
color-mix(in srgb, var(--primary-color,#6366f1) 4%, var(--surface-card)) 60%,
var(--surface-card) 100%);
position: relative; overflow: hidden;
}
.cfg-subheader::before {
content: ''; position: absolute; top: -20px; right: -20px;
width: 80px; height: 80px; border-radius: 50%;
background: color-mix(in srgb, var(--primary-color,#6366f1) 15%, transparent);
filter: blur(20px); pointer-events: none;
}
.cfg-subheader__icon {
display: grid; place-items: center;
width: 2rem; height: 2rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 20%, transparent);
color: var(--primary-color,#6366f1); font-size: 0.85rem;
}
.cfg-subheader__title { font-size: 0.95rem; font-weight: 700; letter-spacing: -0.01em; color: var(--primary-color,#6366f1); }
.cfg-subheader__sub { font-size: 0.75rem; color: var(--text-color-secondary); opacity: 0.85; }
.cfg-subheader__actions { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; flex-shrink: 0; position: relative; z-index: 1; }
/* ── Card wrap ────────────────────────────────────── */
.cfg-wrap {
border: 1px solid var(--surface-border);
border-radius: 6px; background: var(--surface-card);
overflow: hidden;
}
.cfg-wrap__head {
display: flex; align-items: center; justify-content: space-between;
gap: 0.75rem; padding: 0.75rem 1rem;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-ground);
}
.cfg-wrap__icon {
display: grid; place-items: center;
width: 1.75rem; height: 1.75rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 12%, transparent);
color: var(--primary-color,#6366f1); font-size: 0.8rem;
}
.cfg-wrap__title { font-size: 0.88rem; font-weight: 700; color: var(--text-color); }
.cfg-wrap__body { padding: 1rem; display: flex; flex-direction: column; gap: 0.75rem; }
/* ── Empty state ──────────────────────────────────── */
.cfg-empty {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 0.75rem; padding: 2.5rem 1rem; text-align: center;
color: var(--text-color-secondary);
border: 1px dashed var(--surface-border);
border-radius: 6px; background: var(--surface-ground);
}
/* ── Pagamento accordions ─────────────────────────── */
.pag-accordion {
border: 1px solid var(--surface-border);
border-radius: 6px; background: var(--surface-card);
overflow: hidden; transition: border-color 0.15s;
}
.pag-accordion--on {
border-color: color-mix(in srgb, #22c55e 40%, transparent);
}
.pag-accordion__header {
display: flex; align-items: center; justify-content: space-between;
gap: 1rem; width: 100%; padding: 0.875rem 1rem;
background: transparent; border: none; cursor: pointer;
transition: background 0.12s; text-align: left;
}
.pag-accordion__header:hover { background: var(--surface-hover); }
.pag-accordion__icon {
display: flex; align-items: center; justify-content: center;
width: 2.25rem; height: 2.25rem; border-radius: 6px; flex-shrink: 0;
}
.pag-accordion__body {
border-top: 1px solid var(--surface-border);
padding: 1rem; display: flex; flex-direction: column; gap: 1rem;
}
</style>

View File

@@ -148,32 +148,19 @@ onMounted(async () => {
<template>
<Toast />
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3">
<!-- Header -->
<Card>
<template #content>
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-3">
<div class="cfg-icon-box">
<i class="pi pi-tag text-lg" />
<!-- Subheader -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-tag" /></div>
<div class="min-w-0">
<div class="cfg-subheader__title">Precificação</div>
<div class="cfg-subheader__sub">Valor padrão da sessão e preços por tipo de compromisso</div>
</div>
<div>
<div class="text-900 font-semibold text-lg">Serviços e Precificação</div>
<div class="text-600 text-sm">
Gerencie os serviços que você oferece e seus respectivos preços.
<div class="cfg-subheader__actions">
<Button label="Novo serviço" icon="pi pi-plus" size="small" :disabled="pageLoading || addingNew" class="rounded-full" @click="addingNew = true; cancelEdit()" />
</div>
</div>
</div>
<Button
label="Novo serviço"
icon="pi pi-plus"
:disabled="pageLoading || addingNew"
@click="addingNew = true; cancelEdit()"
/>
</div>
</template>
</Card>
<!-- Loading -->
<div v-if="pageLoading || loading" class="flex justify-center py-10">
@@ -183,20 +170,16 @@ onMounted(async () => {
<template v-else>
<Message v-if="isDynamic" severity="info" :closable="false">
<span class="text-sm">
Modo <b>dinâmico</b> ativo a duração da sessão é definida pelo serviço selecionado.
</span>
<span class="text-sm">Modo <b>dinâmico</b> ativo a duração da sessão é definida pelo serviço selecionado.</span>
</Message>
<!-- Formulário novo serviço -->
<Card v-if="addingNew">
<template #title>
<div class="flex items-center gap-2">
<i class="pi pi-plus-circle text-primary-500" />
<span>Novo serviço</span>
<!-- Form novo serviço -->
<div v-if="addingNew" class="cfg-wrap">
<div class="cfg-wrap__head">
<div class="cfg-wrap__icon"><i class="pi pi-plus" /></div>
<span class="cfg-wrap__title">Novo serviço</span>
</div>
</template>
<template #content>
<div class="svc-form">
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
@@ -206,16 +189,7 @@ onMounted(async () => {
</div>
<div class="col-span-12 sm:col-span-3">
<FloatLabel variant="on">
<InputNumber
v-model="newForm.price"
inputId="new-price"
mode="currency"
currency="BRL"
locale="pt-BR"
:min="0"
:minFractionDigits="2"
fluid
/>
<InputNumber v-model="newForm.price" inputId="new-price" mode="currency" currency="BRL" locale="pt-BR" :min="0" :minFractionDigits="2" fluid />
<label for="new-price">Preço (R$) *</label>
</FloatLabel>
</div>
@@ -232,30 +206,59 @@ onMounted(async () => {
</FloatLabel>
</div>
</div>
<div class="flex gap-2 justify-end mt-4">
<Button label="Cancelar" severity="secondary" outlined @click="addingNew = false; newForm = emptyForm()" />
<Button label="Salvar" icon="pi pi-check" :loading="savingNew" @click="saveNew" />
<div class="flex gap-2 justify-end mt-3">
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" @click="addingNew = false; newForm = emptyForm()" />
<Button label="Salvar" icon="pi pi-check" class="rounded-full" :loading="savingNew" @click="saveNew" />
</div>
</div>
</div>
</template>
</Card>
<!-- Lista vazia -->
<Card v-if="!services.length && !addingNew">
<template #content>
<div class="text-center py-6 text-color-secondary">
<i class="pi pi-tag text-4xl opacity-30 mb-3 block" />
<div class="font-medium mb-1">Nenhum serviço cadastrado</div>
<div class="text-sm">Clique em "Novo serviço" para começar.</div>
<div v-if="!services.length && !addingNew" class="cfg-empty">
<i class="pi pi-tag text-3xl opacity-25" />
<div class="text-sm font-medium">Nenhum serviço cadastrado</div>
<div class="text-xs opacity-70">Clique em "Novo serviço" para começar.</div>
</div>
</template>
</Card>
<!-- Lista de serviços -->
<Card v-for="svc in services" :key="svc.id" :class="{ 'opacity-60': !svc.active }">
<template #content>
<div v-for="svc in services" :key="svc.id" class="cfg-wrap" :class="{ 'opacity-60': !svc.active }">
<!-- Modo leitura: head clicável -->
<template v-if="editingId !== svc.id">
<div class="svc-row">
<div class="svc-row__icon">
<i class="pi pi-tag" />
</div>
<div class="svc-row__info">
<div class="font-semibold text-sm">{{ svc.name }}</div>
<div class="flex flex-wrap gap-x-3 gap-y-0.5 mt-0.5 text-xs text-[var(--text-color-secondary)]">
<span class="font-semibold text-[var(--primary-color)]">{{ fmtBRL(svc.price) }}</span>
<span v-if="svc.duration_min">{{ svc.duration_min }} min</span>
<span v-if="svc.description" class="italic">{{ svc.description }}</span>
</div>
</div>
<div class="flex items-center gap-1.5 shrink-0">
<Tag :value="svc.active ? 'Ativo' : 'Inativo'" :severity="svc.active ? 'success' : 'secondary'" />
<Button
:icon="svc.active ? 'pi pi-eye-slash' : 'pi pi-eye'"
:severity="svc.active ? 'secondary' : 'success'"
outlined size="small"
v-tooltip.top="svc.active ? 'Desativar' : 'Ativar'"
@click="toggleService(svc)"
/>
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" v-tooltip.top="'Editar'" @click="startEdit(svc)" />
<Button icon="pi pi-trash" severity="danger" outlined size="small" v-tooltip.top="'Remover'" @click="confirmRemove(svc.id)" />
</div>
</div>
</template>
<!-- Modo edição -->
<template v-if="editingId === svc.id">
<template v-else>
<div class="cfg-wrap__head">
<div class="cfg-wrap__icon"><i class="pi pi-pencil" /></div>
<span class="cfg-wrap__title">Editar {{ svc.name }}</span>
</div>
<div class="svc-form svc-form--editing">
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
@@ -265,16 +268,7 @@ onMounted(async () => {
</div>
<div class="col-span-12 sm:col-span-3">
<FloatLabel variant="on">
<InputNumber
v-model="editForm.price"
:inputId="`edit-price-${svc.id}`"
mode="currency"
currency="BRL"
locale="pt-BR"
:min="0"
:minFractionDigits="2"
fluid
/>
<InputNumber v-model="editForm.price" :inputId="`edit-price-${svc.id}`" mode="currency" currency="BRL" locale="pt-BR" :min="0" :minFractionDigits="2" fluid />
<label :for="`edit-price-${svc.id}`">Preço (R$) *</label>
</FloatLabel>
</div>
@@ -291,51 +285,17 @@ onMounted(async () => {
</FloatLabel>
</div>
</div>
<div class="flex gap-2 justify-end mt-4">
<Button label="Cancelar" severity="secondary" outlined @click="cancelEdit" />
<Button label="Salvar" icon="pi pi-check" :loading="savingEdit" @click="saveEdit" />
</div>
</template>
<!-- Modo leitura -->
<template v-else>
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-3">
<div class="cfg-icon-box-sm">
<i class="pi pi-tag" />
</div>
<div>
<div class="font-semibold text-900">{{ svc.name }}</div>
<div class="text-sm text-color-secondary flex flex-wrap gap-x-3 gap-y-0.5 mt-0.5">
<span><b class="text-primary-500">{{ fmtBRL(svc.price) }}</b></span>
<span v-if="svc.duration_min">{{ svc.duration_min }}min</span>
<span v-if="svc.description" class="italic">{{ svc.description }}</span>
</div>
</div>
</div>
<div class="flex items-center gap-2">
<Tag :value="svc.active ? 'Ativo' : 'Inativo'" :severity="svc.active ? 'success' : 'secondary'" />
<Button
:icon="svc.active ? 'pi pi-eye-slash' : 'pi pi-eye'"
:severity="svc.active ? 'secondary' : 'success'"
outlined
size="small"
v-tooltip.top="svc.active ? 'Desativar' : 'Ativar'"
@click="toggleService(svc)"
/>
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" v-tooltip.top="'Editar'" @click="startEdit(svc)" />
<Button icon="pi pi-trash" severity="danger" outlined size="small" v-tooltip.top="'Remover'" @click="confirmRemove(svc.id)" />
<div class="flex gap-2 justify-end mt-3">
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" @click="cancelEdit" />
<Button label="Salvar" icon="pi pi-check" class="rounded-full" :loading="savingEdit" @click="saveEdit" />
</div>
</div>
</template>
</template>
</Card>
</div>
<Message severity="info" :closable="false">
<span class="text-sm">
Os serviços cadastrados aqui aparecem para seleção ao criar ou editar um evento na agenda.
</span>
<span class="text-sm">Os serviços cadastrados aqui aparecem para seleção ao criar ou editar um evento na agenda.</span>
</Message>
</template>
@@ -343,25 +303,84 @@ onMounted(async () => {
</template>
<style scoped>
.cfg-icon-box {
display: grid;
place-items: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 0.875rem;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
flex-shrink: 0;
/* ── Subheader degradê ────────────────────────────── */
.cfg-subheader {
display: flex; align-items: center; gap: 0.65rem;
padding: 0.875rem 1rem; border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--primary-color,#6366f1) 30%, transparent);
background: linear-gradient(135deg,
color-mix(in srgb, var(--primary-color,#6366f1) 12%, var(--surface-card)) 0%,
color-mix(in srgb, var(--primary-color,#6366f1) 4%, var(--surface-card)) 60%,
var(--surface-card) 100%);
position: relative; overflow: hidden;
}
.cfg-subheader::before {
content: ''; position: absolute; top: -20px; right: -20px;
width: 80px; height: 80px; border-radius: 50%;
background: color-mix(in srgb, var(--primary-color,#6366f1) 15%, transparent);
filter: blur(20px); pointer-events: none;
}
.cfg-subheader__icon {
display: grid; place-items: center;
width: 2rem; height: 2rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 20%, transparent);
color: var(--primary-color,#6366f1); font-size: 0.85rem;
}
.cfg-subheader__title { font-size: 0.95rem; font-weight: 700; letter-spacing: -0.01em; color: var(--primary-color,#6366f1); }
.cfg-subheader__sub { font-size: 0.75rem; color: var(--text-color-secondary); opacity: 0.85; }
.cfg-subheader__actions { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; flex-shrink: 0; position: relative; z-index: 1; }
/* ── Card wrap ────────────────────────────────────── */
.cfg-wrap {
border: 1px solid var(--surface-border);
border-radius: 6px; background: var(--surface-card);
overflow: hidden;
}
.cfg-wrap__head {
display: flex; align-items: center; gap: 0.625rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-ground);
}
.cfg-wrap__icon {
display: grid; place-items: center;
width: 1.75rem; height: 1.75rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 12%, transparent);
color: var(--primary-color,#6366f1); font-size: 0.8rem;
}
.cfg-wrap__title { font-size: 0.88rem; font-weight: 700; color: var(--text-color); }
/* ── Linha de leitura do serviço ──────────────────── */
.svc-row {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.75rem 1rem; flex-wrap: wrap;
transition: background 0.1s;
}
.svc-row:hover { background: var(--surface-hover); }
.svc-row__icon {
display: grid; place-items: center;
width: 1.75rem; height: 1.75rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 10%, transparent);
color: var(--primary-color,#6366f1); font-size: 0.78rem;
}
.svc-row__info { flex: 1; min-width: 0; }
/* ── Form (novo + edição) ─────────────────────────── */
.svc-form {
padding: 1rem;
display: flex; flex-direction: column; gap: 0.75rem;
}
.svc-form--editing {
background: color-mix(in srgb, var(--primary-color,#6366f1) 3%, var(--surface-card));
border-top: 2px solid color-mix(in srgb, var(--primary-color,#6366f1) 40%, transparent);
}
.cfg-icon-box-sm {
display: grid;
place-items: center;
width: 2rem;
height: 2rem;
border-radius: 0.625rem;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 10%, transparent);
color: var(--p-primary-500, #6366f1);
flex-shrink: 0;
/* ── Empty state ──────────────────────────────────── */
.cfg-empty {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 0.75rem; padding: 2.5rem 1rem; text-align: center;
color: var(--text-color-secondary);
border: 1px dashed var(--surface-border);
border-radius: 6px; background: var(--surface-ground);
}
</style>

View File

@@ -1,7 +1,7 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from '@/router'
import { pinia } from '@/plugins/pinia' // ← singleton criado antes do router
import { setOnSignedOut, initSession, listenAuthChanges, refreshSession } from '@/app/session'
import Aura from '@primeuix/themes/aura'
@@ -61,11 +61,7 @@ async function applyUserThemeEarly () {
if (error || !settings?.theme_mode) return
const isDark = settings.theme_mode === 'dark'
// o PrimeVue usa o selector .app-dark
const root = document.documentElement
root.classList.toggle('app-dark', isDark)
document.documentElement.classList.toggle('app-dark', isDark)
localStorage.setItem('ui_theme_mode', settings.theme_mode)
} catch {}
}
@@ -80,30 +76,20 @@ window.__fromVisibilityRefresh = false
window.__appBootstrapped = false
// ========================================
// 🛟 ao voltar da aba: refresh leve, sem martelar e sem rodar antes do app subir
let lastVisibilityRefreshAt = 0
document.addEventListener('visibilitychange', async () => {
if (document.visibilityState !== 'visible') return
// só depois do app montar (evita refresh no meio do bootstrap)
if (!window.__appBootstrapped) return
const now = Date.now()
// no máximo 1 refresh a cada 10s
if (now - lastVisibilityRefreshAt < 10_000) return
// se já tem refresh em andamento, não entra
if (window.__sessionRefreshing) return
// (opcional) se não houver user, não precisa refresh
try {
const { data } = await supabase.auth.getUser()
if (!data?.user) return
} catch {
// se falhar getUser, deixa tentar refreshSession mesmo assim
}
} catch {}
lastVisibilityRefreshAt = now
console.log('[VISIBILITY] Aba voltou -> refreshSession()')
@@ -114,8 +100,6 @@ document.addEventListener('visibilitychange', async () => {
await refreshSession()
// 🔔 avisa o app inteiro SOMENTE em áreas TENANT.
// Portal (/portal) e área global (/account) NÃO devem rehidratar tenantStore/menu.
try {
const path = router.currentRoute.value?.path || ''
const isTenantArea =
@@ -130,9 +114,7 @@ document.addEventListener('visibilitychange', async () => {
} else {
console.log('[VISIBILITY] refresh ok (skip event) - area não-tenant:', path)
}
} catch {
// se algo der errado, não dispare evento global por segurança
}
} catch {}
} finally {
window.__fromVisibilityRefresh = false
window.__sessionRefreshing = false
@@ -147,17 +129,14 @@ async function bootstrap () {
const app = createApp(App)
const pinia = createPinia()
// ✅ usa o pinia singleton — o mesmo que o router/guards já conhecem
app.use(pinia)
app.use(router)
// ✅ garante router pronto antes de montar
await router.isReady()
// ✅ PrimeVue global config (tema + locale pt-BR)
app.use(PrimeVue, {
locale: ptBR, // 🔥 isso traduz Calendar/DatePicker
locale: ptBR,
theme: {
preset: Aura,
options: { darkModeSelector: '.app-dark' }
@@ -167,7 +146,6 @@ async function bootstrap () {
app.use(ToastService)
app.use(ConfirmationService)
// Registro global de componentes PrimeVue frequentes
app.component('Button', Button)
app.component('InputText', InputText)
app.component('Tag', Tag)
@@ -186,7 +164,6 @@ async function bootstrap () {
app.mount('#app')
// ✅ marca boot completo
window.__appBootstrapped = true
}

View File

@@ -66,7 +66,8 @@ export default function saasMenu (sessionCtx, opts = {}) {
to: '/saas/docs',
...docsBadge
},
{ label: 'FAQ', icon: 'pi pi-fw pi-comments', to: '/saas/faq' }
{ label: 'FAQ', icon: 'pi pi-fw pi-comments', to: '/saas/faq' },
{ label: 'Carrossel Login', icon: 'pi pi-fw pi-images', to: '/saas/login-carousel' }
]
}
]

6
src/plugins/pinia.js Normal file
View File

@@ -0,0 +1,6 @@
// src/plugins/pinia.js
// Instância única do Pinia — criada antes do router para que os
// guards (beforeEach) já consigam usar stores via useSupportDebugStore(pinia).
import { createPinia } from 'pinia'
export const pinia = createPinia()

View File

@@ -12,8 +12,10 @@ import saasRoutes from './routes.saas';
import therapistRoutes from './routes.therapist';
import supervisorRoutes from './routes.supervisor';
import editorRoutes from './routes.editor';
import featuresRoutes from './routes.features'
import featuresRoutes from './routes.features';
import { pinia } from '@/plugins/pinia' // ← singleton compartilhado
import { supportGuard } from '@/support/supportGuard'
import { applyGuards } from './guards';
const routes = [
@@ -31,7 +33,7 @@ const routes = [
...(Array.isArray(configuracoesRoutes) ? configuracoesRoutes : [configuracoesRoutes]),
...(Array.isArray(featuresRoutes) ? featuresRoutes : [featuresRoutes]),
// ✅ compat: rota antiga /login → /auth/login (evita 404 se algum trecho legado usar /login)
// ✅ compat: rota antiga /login → /auth/login
{
path: '/login',
redirect: (to) => ({
@@ -39,18 +41,13 @@ const routes = [
query: to.query || {}
})
},
// inserido no routes.misc { path: '/:pathMatch(.*)*', name: 'notfound', component: () => import('@/views/pages/NotFound.vue') }
];
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior(to, from, savedPosition) {
// volta/avançar do navegador mantém posição
if (savedPosition) return savedPosition;
// qualquer navegação normal NÃO altera o scroll
return false;
}
});
@@ -58,10 +55,7 @@ const router = createRouter({
/* 🔎 DEBUG: listar todas as rotas registradas */
console.log(
'[ROUTES]',
router
.getRoutes()
.map((r) => r.path)
.sort()
router.getRoutes().map((r) => r.path).sort()
);
// ===== DEBUG NAV + TRACE (remover depois) =====
@@ -69,19 +63,11 @@ const _push = router.push.bind(router);
router.push = async (loc) => {
console.log('[router.push]', loc);
console.trace('[push caller]');
const res = await _push(loc);
if (isNavigationFailure(res, NavigationFailureType.duplicated)) {
console.warn('[NAV FAIL] duplicated', res);
} else if (isNavigationFailure(res, NavigationFailureType.cancelled)) {
console.warn('[NAV FAIL] cancelled', res);
} else if (isNavigationFailure(res, NavigationFailureType.aborted)) {
console.warn('[NAV FAIL] aborted', res);
} else if (isNavigationFailure(res, NavigationFailureType.redirected)) {
console.warn('[NAV FAIL] redirected', res);
}
if (isNavigationFailure(res, NavigationFailureType.duplicated)) console.warn('[NAV FAIL] duplicated', res);
else if (isNavigationFailure(res, NavigationFailureType.cancelled)) console.warn('[NAV FAIL] cancelled', res);
else if (isNavigationFailure(res, NavigationFailureType.aborted)) console.warn('[NAV FAIL] aborted', res);
else if (isNavigationFailure(res, NavigationFailureType.redirected)) console.warn('[NAV FAIL] redirected', res);
return res;
};
@@ -89,22 +75,20 @@ const _replace = router.replace.bind(router);
router.replace = async (loc) => {
console.log('[router.replace]', loc);
console.trace('[replace caller]');
const res = await _replace(loc);
if (isNavigationFailure(res, NavigationFailureType.cancelled)) {
console.warn('[NAV FAIL replace] cancelled', res);
} else if (isNavigationFailure(res, NavigationFailureType.aborted)) {
console.warn('[NAV FAIL replace] aborted', res);
} else if (isNavigationFailure(res, NavigationFailureType.redirected)) {
console.warn('[NAV FAIL replace] redirected', res);
}
if (isNavigationFailure(res, NavigationFailureType.cancelled)) console.warn('[NAV FAIL replace] cancelled', res);
else if (isNavigationFailure(res, NavigationFailureType.aborted)) console.warn('[NAV FAIL replace] aborted', res);
else if (isNavigationFailure(res, NavigationFailureType.redirected)) console.warn('[NAV FAIL replace] redirected', res);
return res;
};
router.onError((e) => console.error('[router.onError]', e));
// ✅ support guard — passa pinia para garantir acesso ao store antes do app.use(pinia)
router.beforeEach(async (to) => {
await supportGuard(to, pinia)
})
router.beforeEach((to, from) => {
console.log('[beforeEach]', from.fullPath, '->', to.fullPath);
return true;
@@ -116,7 +100,6 @@ router.afterEach((to, from, failure) => {
});
// ===== /DEBUG NAV + TRACE =====
// ✅ mantém seus guards, mas agora a landing tem meta.public
applyGuards(router);
export default router;

View File

@@ -81,6 +81,11 @@ export default {
name: 'saas-support',
component: () => import('@/views/pages/saas/SaasSupportPage.vue'),
meta: { requiresAuth: true, saasAdmin: true }
},
{
path: 'login-carousel',
name: 'saas-login-carousel',
component: () => import('@/views/pages/saas/SaasLoginCarousel.vue')
}
]
}

View File

@@ -0,0 +1,182 @@
-- ============================================================
-- SISTEMA DE NOTIFICAÇÕES CENTRALIZADO
-- Rodar no Supabase SQL Editor
-- ============================================================
-- ------------------------------------------------------------
-- 1. TABELA notifications
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.notifications (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
tenant_id uuid,
type text NOT NULL CHECK (type IN ('new_scheduling', 'new_patient', 'recurrence_alert', 'session_status')),
ref_id uuid,
ref_table text,
payload jsonb NOT NULL DEFAULT '{}',
read_at timestamptz,
archived boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
);
-- ------------------------------------------------------------
-- 2. RLS
-- ------------------------------------------------------------
ALTER TABLE public.notifications ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "owner only" ON public.notifications;
CREATE POLICY "owner only"
ON public.notifications
FOR ALL
USING (owner_id = auth.uid())
WITH CHECK (owner_id = auth.uid());
-- ------------------------------------------------------------
-- 3. INDEXES
-- ------------------------------------------------------------
CREATE INDEX IF NOT EXISTS notifications_owner_created
ON public.notifications (owner_id, created_at DESC);
CREATE INDEX IF NOT EXISTS notifications_owner_unread
ON public.notifications (owner_id, read_at)
WHERE read_at IS NULL;
-- ------------------------------------------------------------
-- 4. TRIGGER: nova solicitação de agendamento
-- ------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.notify_on_scheduling()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
IF NEW.status = 'pendente' THEN
INSERT INTO public.notifications (
owner_id,
tenant_id,
type,
ref_id,
ref_table,
payload
)
VALUES (
NEW.owner_id,
NEW.tenant_id,
'new_scheduling',
NEW.id,
'agendador_solicitacoes',
jsonb_build_object(
'title', 'Nova solicitação de agendamento',
'detail', COALESCE(NEW.paciente_nome, 'Paciente') || ' ' || COALESCE(NEW.paciente_sobrenome, '') || '' || COALESCE(NEW.tipo, ''),
'deeplink', '/therapist/agendamentos-recebidos',
'avatar_initials', upper(left(COALESCE(NEW.paciente_nome, '?'), 1) || left(COALESCE(NEW.paciente_sobrenome, ''), 1))
)
);
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_notify_on_scheduling ON public.agendador_solicitacoes;
CREATE TRIGGER trg_notify_on_scheduling
AFTER INSERT ON public.agendador_solicitacoes
FOR EACH ROW
EXECUTE FUNCTION public.notify_on_scheduling();
-- ------------------------------------------------------------
-- 5. TRIGGER: novo cadastro externo (patient_intake_requests)
-- ------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.notify_on_intake()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
IF NEW.status = 'new' THEN
INSERT INTO public.notifications (
owner_id,
tenant_id,
type,
ref_id,
ref_table,
payload
)
VALUES (
NEW.owner_id,
NEW.tenant_id,
'new_patient',
NEW.id,
'patient_intake_requests',
jsonb_build_object(
'title', 'Novo cadastro externo',
'detail', COALESCE(NEW.nome_completo, 'Paciente'),
'deeplink', '/therapist/patients/cadastro/recebidos',
'avatar_initials', upper(left(COALESCE(NEW.nome_completo, '?'), 2))
)
);
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_notify_on_intake ON public.patient_intake_requests;
CREATE TRIGGER trg_notify_on_intake
AFTER INSERT ON public.patient_intake_requests
FOR EACH ROW
EXECUTE FUNCTION public.notify_on_intake();
-- ------------------------------------------------------------
-- 6. TRIGGER: mudança de status de sessão (agenda_eventos)
-- ------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.notify_on_session_status()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
v_nome text;
BEGIN
IF NEW.status IN ('faltou', 'cancelado') AND OLD.status IS DISTINCT FROM NEW.status THEN
-- tenta buscar nome do paciente
SELECT nome_completo
INTO v_nome
FROM public.patients
WHERE id = NEW.patient_id
LIMIT 1;
INSERT INTO public.notifications (
owner_id,
tenant_id,
type,
ref_id,
ref_table,
payload
)
VALUES (
NEW.owner_id,
NEW.tenant_id,
'session_status',
NEW.id,
'agenda_eventos',
jsonb_build_object(
'title', CASE WHEN NEW.status = 'faltou' THEN 'Paciente faltou' ELSE 'Sessão cancelada' END,
'detail', COALESCE(v_nome, 'Paciente') || '' || to_char(NEW.starts_at, 'DD/MM HH24:MI'),
'deeplink', '/therapist/agenda',
'avatar_initials', upper(left(COALESCE(v_nome, '?'), 2))
)
);
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_notify_on_session_status ON public.agenda_eventos;
CREATE TRIGGER trg_notify_on_session_status
AFTER UPDATE OF status ON public.agenda_eventos
FOR EACH ROW
EXECUTE FUNCTION public.notify_on_session_status();
-- ------------------------------------------------------------
-- Realtime: habilitar para a tabela notifications
-- ------------------------------------------------------------
ALTER PUBLICATION supabase_realtime ADD TABLE public.notifications;

View File

@@ -0,0 +1,37 @@
-- ============================================================
-- FIX: notify_on_intake — campos corretos da tabela
-- patient_intake_requests usa nome_completo
-- ============================================================
CREATE OR REPLACE FUNCTION public.notify_on_intake()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
IF NEW.status = 'new' THEN
INSERT INTO public.notifications (
owner_id,
tenant_id,
type,
ref_id,
ref_table,
payload
)
VALUES (
NEW.owner_id,
NEW.tenant_id,
'new_patient',
NEW.id,
'patient_intake_requests',
jsonb_build_object(
'title', 'Novo cadastro externo',
'detail', COALESCE(NEW.nome_completo, 'Paciente'),
'deeplink', '/therapist/patients/cadastro/recebidos',
'avatar_initials', upper(left(COALESCE(NEW.nome_completo, '?'), 2))
)
);
END IF;
RETURN NEW;
END;
$$;

View File

@@ -0,0 +1,37 @@
-- ============================================================
-- FIX: notify_on_scheduling — campos corretos da tabela
-- agendador_solicitacoes usa paciente_nome / paciente_sobrenome / tipo
-- ============================================================
CREATE OR REPLACE FUNCTION public.notify_on_scheduling()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
IF NEW.status = 'pendente' THEN
INSERT INTO public.notifications (
owner_id,
tenant_id,
type,
ref_id,
ref_table,
payload
)
VALUES (
NEW.owner_id,
NEW.tenant_id,
'new_scheduling',
NEW.id,
'agendador_solicitacoes',
jsonb_build_object(
'title', 'Nova solicitação de agendamento',
'detail', COALESCE(NEW.paciente_nome, 'Paciente') || ' ' || COALESCE(NEW.paciente_sobrenome, '') || '' || COALESCE(NEW.tipo, ''),
'deeplink', '/therapist/agendamentos-recebidos',
'avatar_initials', upper(left(COALESCE(NEW.paciente_nome, '?'), 1) || left(COALESCE(NEW.paciente_sobrenome, ''), 1))
)
);
END IF;
RETURN NEW;
END;
$$;

View File

@@ -0,0 +1,29 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- Seed: login_carousel_slides
-- Descrição: 3 slides iniciais para o carrossel da tela de login.
-- Gerenciados via /saas/login-carousel no painel SaaS admin.
-- ─────────────────────────────────────────────────────────────────────────────
insert into public.login_carousel_slides (title, body, icon, ordem, ativo)
values
(
'<strong>Gestão clínica simplificada</strong>',
'Agendamentos, prontuários e sessões em um único painel. Foco no que importa: <em>seus pacientes</em>.',
'pi-calendar-clock',
0,
true
),
(
'<strong>Múltiplos profissionais, uma só plataforma</strong>',
'Adicione terapeutas, gerencie vínculos por clínica e mantenha equipes organizadas com controle de acesso por papel.',
'pi-users',
1,
true
),
(
'<strong>Seguro, privado e sempre disponível</strong>',
'Dados protegidos com autenticação robusta, controle de acesso por perfil e conformidade com as boas práticas de privacidade.',
'pi-shield',
2,
true
);

View File

@@ -0,0 +1,123 @@
// src/stores/notificationStore.js
import { defineStore } from 'pinia'
import { supabase } from '@/lib/supabase/client'
export const useNotificationStore = defineStore('notifications', {
state: () => ({
items: [],
drawerOpen: false,
_channel: null
}),
getters: {
unreadCount: (state) =>
state.items.filter((n) => !n.read_at && !n.archived).length,
unreadItems: (state) =>
state.items.filter((n) => !n.read_at && !n.archived),
allItems: (state) =>
state.items.filter((n) => !n.archived)
},
actions: {
async load (ownerId) {
const { data, error } = await supabase
.from('notifications')
.select('*')
.eq('owner_id', ownerId)
.eq('archived', false)
.order('created_at', { ascending: false })
.limit(50)
if (error) {
console.error('[notificationStore] load error:', error.message)
return
}
this.items = data || []
},
subscribeRealtime (ownerId) {
if (this._channel) return
const channel = supabase
.channel(`notifications:${ownerId}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'notifications',
filter: `owner_id=eq.${ownerId}`
},
(payload) => {
this.items.unshift(payload.new)
}
)
.subscribe()
this._channel = channel
},
async markRead (id) {
const now = new Date().toISOString()
const { error } = await supabase
.from('notifications')
.update({ read_at: now })
.eq('id', id)
if (error) {
console.error('[notificationStore] markRead error:', error.message)
return
}
const item = this.items.find((n) => n.id === id)
if (item) item.read_at = now
},
async markAllRead () {
const unreadIds = this.items
.filter((n) => !n.read_at && !n.archived)
.map((n) => n.id)
if (!unreadIds.length) return
const now = new Date().toISOString()
const { error } = await supabase
.from('notifications')
.update({ read_at: now })
.in('id', unreadIds)
if (error) {
console.error('[notificationStore] markAllRead error:', error.message)
return
}
this.items.forEach((n) => {
if (unreadIds.includes(n.id)) n.read_at = now
})
},
async archive (id) {
const { error } = await supabase
.from('notifications')
.update({ archived: true })
.eq('id', id)
if (error) {
console.error('[notificationStore] archive error:', error.message)
return
}
this.items = this.items.filter((n) => n.id !== id)
},
unsubscribe () {
if (this._channel) {
supabase.removeChannel(this._channel)
this._channel = null
}
}
}
})

View File

@@ -0,0 +1,20 @@
// src/support/supportGuard.js
import { useSupportDebugStore } from '@/support/supportDebugStore'
/**
* Guard global que lê ?support=TOKEN de qualquer rota e ativa o modo suporte.
* Recebe a instância do Pinia explicitamente para garantir funcionamento
* mesmo quando o guard roda antes de app.use(pinia).
*
* @param {import('vue-router').RouteLocationNormalized} to
* @param {import('pinia').Pinia} pinia
*/
export async function supportGuard (to, pinia) {
const supportToken = to.query?.support
if (!supportToken || typeof supportToken !== 'string') return
const store = useSupportDebugStore(pinia) // instância garantida
if (store.isActive) return // já ativo, não revalida
await store.validateAndActivate(supportToken)
}

File diff suppressed because it is too large Load Diff

View File

@@ -30,7 +30,7 @@ const loadingRecovery = ref(false)
const recoverySent = ref(false)
// carrossel
const slides = [
const SLIDES_FALLBACK = [
{
title: 'Gestão clínica simplificada',
body: 'Agendamentos, prontuários e sessões em um único painel. Foco no que importa: seus pacientes.',
@@ -53,6 +53,8 @@ const slides = [
},
]
const slides = ref(SLIDES_FALLBACK)
const currentSlide = ref(0)
let slideInterval = null
@@ -62,7 +64,7 @@ function goToSlide (i) {
function startCarousel () {
slideInterval = setInterval(() => {
currentSlide.value = (currentSlide.value + 1) % slides.length
currentSlide.value = (currentSlide.value + 1) % slides.value.length
}, 4500)
}
@@ -70,6 +72,21 @@ function stopCarousel () {
if (slideInterval) clearInterval(slideInterval)
}
async function loadCarouselSlides () {
try {
const { data, error } = await supabase
.from('login_carousel_slides')
.select('title, body, icon')
.eq('ativo', true)
.order('ordem', { ascending: true })
if (!error && data && data.length > 0) {
slides.value = data
}
} catch {
// mantém fallback
}
}
const canSubmit = computed(() => {
return !!email.value?.trim() && !!password.value && !loading.value && !loadingRecovery.value
})
@@ -266,7 +283,9 @@ async function sendRecoveryEmail () {
}
}
onMounted(() => {
onMounted(async () => {
await loadCarouselSlides()
const preEmail = sessionStorage.getItem('login_prefill_email')
const prePass = sessionStorage.getItem('login_prefill_password')
@@ -332,12 +351,12 @@ onBeforeUnmount(() => {
</div>
<div class="space-y-4">
<h2 class="text-3xl xl:text-4xl font-bold text-white leading-tight">
{{ slides[currentSlide].title }}
</h2>
<p class="text-base xl:text-lg text-white/70 leading-relaxed max-w-sm">
{{ slides[currentSlide].body }}
</p>
<div class="text-3xl xl:text-4xl font-bold text-white leading-tight prose prose-invert prose-xl max-w-none"
v-html="slides[currentSlide].title"
/>
<div class="text-base xl:text-lg text-white/70 leading-relaxed max-w-sm prose prose-invert max-w-none"
v-html="slides[currentSlide].body"
/>
</div>
</div>
</Transition>

View File

@@ -149,25 +149,36 @@ async function sendResetEmail () {
<Toast />
<!-- Sentinel -->
<div ref="headerSentinelRef" class="sec-sentinel" />
<div ref="headerSentinelRef" class="h-px" />
<!-- Hero sticky -->
<div ref="headerEl" class="sec-hero w-full max-w-2xl mx-auto px-3 md:px-5 mb-4" :class="{ 'sec-hero--stuck': headerStuck }">
<div class="sec-hero__blobs" aria-hidden="true">
<div class="sec-hero__blob sec-hero__blob--1" />
<div class="sec-hero__blob sec-hero__blob--2" />
<div
ref="headerEl"
class="sticky mx-auto max-w-2xl px-3 md:px-5 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
:class="{ 'rounded-tl-none rounded-tr-none': headerStuck }"
>
<!-- Blobs -->
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-12 bg-indigo-500/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-2 -left-20 bg-emerald-500/[0.08]" />
</div>
<div class="sec-hero__row1">
<div class="sec-hero__brand">
<div class="sec-hero__icon"><i class="pi pi-shield text-lg" /></div>
<div class="relative z-10 flex items-center gap-4">
<div class="flex items-center gap-3 flex-1 min-w-0">
<div
class="grid place-items-center w-10 h-10 rounded-[0.875rem] shrink-0"
style="background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent); color: var(--p-primary-500, #6366f1)"
>
<i class="pi pi-shield text-lg" />
</div>
<div class="min-w-0">
<div class="sec-hero__title">Segurança</div>
<div class="sec-hero__sub">Gerencie o acesso e a senha da sua conta</div>
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Segurança</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Gerencie o acesso e a senha da sua conta</div>
</div>
</div>
<span class="hidden xl:inline-flex items-center gap-2 text-xs px-3 py-1.5 rounded-full border border-emerald-200 text-emerald-700 bg-emerald-50 shrink-0">
<span class="hidden xl:inline-flex items-center gap-2 text-[1rem] px-3 py-1.5 rounded-full border border-emerald-200 text-emerald-700 bg-emerald-50 shrink-0">
<span class="h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
Sessão ativa
</span>
@@ -178,18 +189,18 @@ async function sendResetEmail () {
<div class="w-full max-w-2xl space-y-4">
<!-- Card principal -->
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<!-- Seção: Trocar senha -->
<div class="px-6 py-5 border-b border-[var(--surface-border)]">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-sm font-semibold text-[var(--text-color)]">Trocar senha</p>
<p class="text-xs text-[var(--text-color-secondary)] mt-0.5">
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Trocar senha</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">
Confirme sua senha atual e defina uma nova.
</p>
</div>
<span class="hidden sm:inline-flex items-center gap-1.5 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] px-3 py-1 text-xs text-[var(--text-color-secondary)]">
</div>
<span class="hidden sm:inline-flex items-center gap-1.5 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] px-3 py-1 text-[1rem] text-[var(--text-color-secondary)]">
<span class="h-1.5 w-1.5 rounded-full bg-emerald-400" />
sessão ativa
</span>
@@ -202,8 +213,8 @@ async function sendResetEmail () {
<i class="pi pi-check text-emerald-500 text-2xl" />
</div>
<div>
<p class="font-semibold text-[var(--text-color)]">Senha atualizada!</p>
<p class="text-sm text-[var(--text-color-secondary)] mt-1">Redirecionando para o login</p>
<div class="font-semibold text-[var(--text-color)]">Senha atualizada!</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">Redirecionando para o login</div>
</div>
</div>
@@ -212,9 +223,7 @@ async function sendResetEmail () {
<!-- Senha atual -->
<div>
<label class="block text-sm font-semibold text-[var(--text-color)] mb-1.5">
Senha atual
</label>
<div class="text-[1rem] font-semibold text-[var(--text-color)] mb-1.5">Senha atual</div>
<Password
v-model="currentPassword"
placeholder="Digite sua senha atual"
@@ -224,18 +233,16 @@ async function sendResetEmail () {
inputClass="w-full"
:disabled="loading || loadingReset"
/>
<p class="mt-1.5 text-xs text-[var(--text-color-secondary)]">
<div class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)]">
Necessária para confirmar que é você.
</p>
</div>
</div>
<div class="h-px bg-[var(--surface-border)]" />
<!-- Nova senha -->
<div>
<label class="block text-sm font-semibold text-[var(--text-color)] mb-1.5">
Nova senha
</label>
<div class="text-[1rem] font-semibold text-[var(--text-color)] mb-1.5">Nova senha</div>
<Password
v-model="newPassword"
placeholder="Mínimo 8 caracteres"
@@ -256,18 +263,16 @@ async function sendResetEmail () {
:class="i <= strengthScore ? strengthColor : 'bg-[var(--surface-border)]'"
/>
</div>
<span class="text-xs" :class="strengthTextColor">{{ strengthLabel }}</span>
<span class="text-[1rem]" :class="strengthTextColor">{{ strengthLabel }}</span>
</div>
<p v-else class="mt-1.5 text-xs text-[var(--text-color-secondary)]">
<div v-else class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)]">
Critérios: 8+ caracteres, maiúscula, minúscula e número.
</p>
</div>
</div>
<!-- Confirmar senha -->
<div>
<label class="block text-sm font-semibold text-[var(--text-color)] mb-1.5">
Confirmar nova senha
</label>
<div class="text-[1rem] font-semibold text-[var(--text-color)] mb-1.5">Confirmar nova senha</div>
<Password
v-model="confirmPassword"
placeholder="Repita a nova senha"
@@ -277,7 +282,9 @@ async function sendResetEmail () {
inputClass="w-full"
:disabled="loading || loadingReset"
/>
<div v-if="confirmPassword" class="mt-1.5 flex items-center gap-1.5 text-xs"
<div
v-if="confirmPassword"
class="mt-1.5 flex items-center gap-1.5 text-[1rem]"
:class="matchOk ? 'text-emerald-500' : 'text-yellow-500'"
>
<i :class="matchOk ? 'pi pi-check' : 'pi pi-times'" />
@@ -286,11 +293,11 @@ async function sendResetEmail () {
</div>
<!-- Aviso -->
<div class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] px-4 py-3 flex items-start gap-3">
<i class="pi pi-info-circle text-[var(--text-color-secondary)] text-sm mt-0.5 flex-shrink-0" />
<p class="text-xs text-[var(--text-color-secondary)] leading-relaxed">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] px-4 py-3 flex items-start gap-3">
<i class="pi pi-info-circle text-[var(--text-color-secondary)] mt-0.5 flex-shrink-0" />
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">
Ao trocar sua senha, você será desconectado de todos os dispositivos por segurança.
</p>
</div>
</div>
<!-- Ações -->
@@ -317,25 +324,25 @@ async function sendResetEmail () {
</div>
<!-- Card informativo: dicas -->
<div class="mt-4 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] px-6 py-5">
<div class="mt-4 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] px-6 py-5">
<div class="flex items-center gap-2 mb-3">
<i class="pi pi-lightbulb text-sm text-[var(--text-color-secondary)]" />
<span class="text-sm font-semibold text-[var(--text-color)]">Boas práticas</span>
<i class="pi pi-lightbulb text-[var(--text-color-secondary)]" />
<span class="text-[1rem] font-semibold text-[var(--text-color)]">Boas práticas</span>
</div>
<ul class="space-y-2">
<li class="flex items-start gap-2.5 text-xs text-[var(--text-color-secondary)]">
<li class="flex items-start gap-2.5 text-[1rem] text-[var(--text-color-secondary)]">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-indigo-400 flex-shrink-0" />
Use pelo menos 8 caracteres com maiúscula, minúscula e número.
</li>
<li class="flex items-start gap-2.5 text-xs text-[var(--text-color-secondary)]">
<li class="flex items-start gap-2.5 text-[1rem] text-[var(--text-color-secondary)]">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-emerald-400 flex-shrink-0" />
Evite datas, nomes e sequências óbvias (1234, qwerty).
</li>
<li class="flex items-start gap-2.5 text-xs text-[var(--text-color-secondary)]">
<li class="flex items-start gap-2.5 text-[1rem] text-[var(--text-color-secondary)]">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-fuchsia-400 flex-shrink-0" />
Se estiver em computador compartilhado, encerre a sessão depois.
</li>
<li class="flex items-start gap-2.5 text-xs text-[var(--text-color-secondary)]">
<li class="flex items-start gap-2.5 text-[1rem] text-[var(--text-color-secondary)]">
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-amber-400 flex-shrink-0" />
Não reutilize a mesma senha de outros serviços.
</li>
@@ -347,41 +354,4 @@ async function sendResetEmail () {
</template>
<style scoped>
.sec-sentinel { height: 1px; }
.sec-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.25rem 1.5rem;
}
.sec-hero--stuck {
border-top-left-radius: 0; border-top-right-radius: 0;
}
.sec-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.sec-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
.sec-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(99,102,241,0.10); }
.sec-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(16,185,129,0.08); }
.sec-hero__row1 {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1rem;
}
.sec-hero__brand {
display: flex; align-items: center; gap: 0.75rem;
flex: 1; min-width: 0;
}
.sec-hero__icon {
display: grid; place-items: center;
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem; flex-shrink: 0;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
}
.sec-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
.sec-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
</style>

View File

@@ -422,211 +422,268 @@ onMounted(fetchMeuPlanoClinic)
</script>
<template>
<div class="p-4 md:p-6">
<Toast />
<!-- Topbar padrão -->
<div class="mb-4 rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 shadow-sm">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-col">
<div class="text-2xl font-semibold leading-none">Meu plano</div>
<small class="text-color-secondary mt-1">
Plano da clínica (tenant) e recursos habilitados.
</small>
</div>
<!-- Sentinel -->
<div class="h-px" />
<div class="flex items-center gap-2 flex-wrap justify-end">
<Button
label="Alterar plano"
icon="pi pi-arrow-up-right"
:loading="loading"
@click="goUpgradeClinic"
/>
<Button
label="Atualizar"
icon="pi pi-refresh"
severity="secondary"
outlined
:loading="loading"
@click="fetchMeuPlanoClinic"
/>
</div>
</div>
</div>
<!-- Card resumo -->
<Card class="rounded-[2rem] overflow-hidden">
<template #content>
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="min-w-0">
<div class="text-xl md:text-2xl font-semibold leading-tight">
{{ planName }}
</div>
<div class="text-sm text-color-secondary mt-1">
<span v-if="priceLabel">{{ priceLabel }}</span>
<span v-else>Preço não encontrado para este intervalo.</span>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<Tag :value="statusLabelPrettyComputed" :severity="statusSeverity(subscription?.status)" />
<Tag
v-if="subscription?.cancel_at_period_end"
severity="warning"
value="Cancelamento agendado"
rounded
/>
<Tag
v-else-if="subscription"
severity="success"
value="Renovação automática"
rounded
/>
</div>
<div class="mt-3 text-sm text-color-secondary">
<b>Período:</b> {{ periodLabel }}
</div>
<div v-if="cancelHint" class="mt-2 text-sm text-color-secondary">
{{ cancelHint }}
</div>
<div v-if="plan?.description" class="mt-3 text-sm opacity-80 max-w-3xl">
{{ plan.description }}
</div>
</div>
<div v-if="subscription" class="flex flex-col items-end gap-2">
<small class="text-color-secondary">subscription_id</small>
<code class="text-xs opacity-80 break-all">
{{ subscription.id }}
</code>
</div>
</div>
<div v-if="!subscription" class="mt-4 rounded-2xl border border-[var(--surface-border)] p-4 text-sm text-color-secondary">
Nenhuma assinatura encontrada para este tenant.
</div>
</template>
</Card>
<Divider class="my-6" />
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<!-- Features agrupadas -->
<Card class="rounded-[2rem] overflow-hidden">
<template #title>Seu plano inclui</template>
<template #content>
<div v-if="!subscription" class="text-color-secondary">
Sem assinatura.
</div>
<div v-else-if="!features.length" class="text-color-secondary">
Nenhuma feature vinculada a este plano.
</div>
<div v-else class="space-y-5">
<div
v-for="g in groupedFeatures"
:key="g.module"
class="rounded-2xl border border-[var(--surface-border)] overflow-hidden"
<!--
HERO sticky
-->
<section
class="sticky mx-3 md:mx-4 mb-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class="px-4 py-3 bg-[var(--surface-50)] border-b border-[var(--surface-border)] flex items-center justify-between">
<div class="font-semibold">
{{ moduleLabel(g.module) }}
<!-- Blobs -->
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-indigo-500/10" />
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-emerald-400/[0.08]" />
</div>
<Tag :value="`${g.items.length}`" severity="secondary" rounded />
<div class="relative z-[1] flex items-center gap-3">
<!-- Brand -->
<div class="flex items-center gap-2 flex-shrink-0">
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
<i class="pi pi-credit-card text-base" />
</div>
<div class="min-w-0 hidden sm:block">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Meu Plano</div>
<div class="text-[1rem] text-[var(--text-color-secondary)]">Plano da clínica (tenant) e recursos habilitados</div>
</div>
</div>
<!-- Ações desktop -->
<div class="hidden sm:flex items-center gap-1 flex-shrink-0 ml-auto">
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full flex-shrink-0" :loading="loading" title="Atualizar" @click="fetchMeuPlanoClinic" />
<Button label="Alterar plano" icon="pi pi-arrow-up-right" class="rounded-full" @click="goUpgradeClinic" />
</div>
<!-- Ações mobile -->
<div class="flex sm:hidden items-center gap-1 flex-shrink-0 ml-auto">
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full flex-shrink-0" :loading="loading" @click="fetchMeuPlanoClinic" />
<Button label="Upgrade" icon="pi pi-arrow-up-right" size="small" class="rounded-full" @click="goUpgradeClinic" />
</div>
</div>
</section>
<!--
QUICK-STATS
-->
<div class="flex flex-wrap gap-2 px-3 md:px-4 mb-3">
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
<div class="text-[1.1rem] font-bold leading-none text-[var(--text-color)] truncate">{{ planName }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Plano atual</div>
</div>
<div
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] transition-colors duration-150"
:class="subscription?.status === 'active' ? 'border-green-500/25 bg-green-500/5' : 'border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]'"
>
<div
class="text-[1.1rem] font-bold leading-none truncate"
:class="subscription?.status === 'active' ? 'text-green-500' : 'text-[var(--text-color)]'"
>{{ statusLabelPrettyComputed }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Status</div>
</div>
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
<div class="text-[1.35rem] font-bold leading-none text-[var(--text-color)]">{{ features.length }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Recursos</div>
</div>
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
<div class="text-[1.35rem] font-bold leading-none text-[var(--text-color)]">{{ events.length }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Eventos</div>
</div>
</div>
<!--
CONTEÚDO
-->
<div class="px-3 md:px-4 pb-8">
<!-- Loading skeleton -->
<div v-if="loading" class="flex flex-col gap-3">
<div
v-for="n in 3"
:key="n"
class="flex items-center gap-4 p-4 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]"
>
<div class="w-10 h-10 rounded-full flex-shrink-0 bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
<div class="flex flex-col gap-2 flex-1">
<div class="h-3.5 w-3/5 rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
<div class="h-2.5 w-2/5 rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
</div>
</div>
</div>
<!-- Empty state: sem assinatura -->
<div
v-else-if="!subscription"
class="flex flex-col items-center justify-center gap-4 rounded-md border-2 border-dashed border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)] py-16 px-6 text-center"
>
<div class="relative">
<div class="grid place-items-center w-16 h-16 rounded-2xl bg-[var(--surface-card,#fff)] border border-[var(--surface-border,#e2e8f0)] shadow-sm text-[var(--text-color-secondary)]">
<i class="pi pi-credit-card text-3xl opacity-30" />
</div>
<div class="absolute -top-2 -right-2 w-7 h-7 rounded-full bg-[var(--surface-card,#fff)] border border-[var(--surface-border,#e2e8f0)] shadow-sm grid place-items-center">
<i class="pi pi-times text-[0.65rem] text-[var(--text-color-secondary)] opacity-50" />
</div>
</div>
<div>
<div class="font-bold text-[1rem] text-[var(--text-color)] mb-1">Nenhuma assinatura encontrada</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] max-w-xs leading-relaxed">Nenhuma assinatura foi encontrada para este tenant.</div>
</div>
<Button label="Ver planos" icon="pi pi-arrow-up-right" class="rounded-full mt-1" @click="goUpgradeClinic" />
</div>
<!-- Conteúdo com assinatura -->
<div v-else class="flex flex-col gap-3">
<!-- Assinatura atual -->
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
<div class="flex items-center gap-2">
<i class="pi pi-credit-card text-[var(--text-color-secondary)] opacity-60" />
<span class="font-semibold text-[1rem]">Assinatura atual</span>
</div>
<div class="flex items-center gap-2">
<Tag :value="statusLabelPrettyComputed" :severity="statusSeverity(subscription?.status)" />
<Tag v-if="subscription?.cancel_at_period_end" severity="warning" value="Cancelamento agendado" />
<Tag v-else severity="success" value="Renovação automática" />
</div>
</div>
<div class="px-4 py-3 flex flex-wrap gap-x-8 gap-y-3">
<div class="flex flex-col gap-0.5">
<span class="text-[0.68rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-60">Plano</span>
<span class="text-[0.9rem] font-semibold text-[var(--text-color)]">{{ planName }}</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-[0.68rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-60">Valor</span>
<span class="text-[1rem] text-[var(--text-color)]">{{ priceLabel || 'Preço não encontrado para este intervalo.' }}</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-[0.68rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-60">Período</span>
<span class="text-[1rem] text-[var(--text-color)]">{{ periodLabel }}</span>
</div>
<div v-if="cancelHint" class="flex flex-col gap-0.5 w-full">
<span class="text-[0.68rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-60">Atenção</span>
<span class="text-[1rem] text-[var(--text-color-secondary)]">{{ cancelHint }}</span>
</div>
<div v-if="plan?.description" class="flex flex-col gap-0.5 w-full">
<span class="text-[0.68rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-60">Descrição</span>
<span class="text-[1rem] text-[var(--text-color-secondary)]">{{ plan.description }}</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-[0.68rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-60">ID da assinatura</span>
<code class="text-[0.75rem] text-[var(--text-color-secondary)] break-all font-mono select-all">{{ subscription.id }}</code>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<!-- Features agrupadas -->
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
<div class="flex items-center gap-2">
<i class="pi pi-check-circle text-[var(--text-color-secondary)] opacity-60" />
<span class="font-semibold text-[1rem]">Seu plano inclui</span>
</div>
<span
v-if="features.length"
class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1.5 rounded-full bg-[var(--primary-color,#6366f1)] text-white text-[1rem] font-bold"
>{{ features.length }}</span>
</div>
<div class="p-4">
<ul class="m-0 p-0 list-none space-y-3">
<li
<div v-if="!features.length" class="text-[1rem] text-[var(--text-color-secondary)]">
Nenhuma feature vinculada a este plano.
</div>
<div v-else class="flex flex-col gap-5">
<div v-for="g in groupedFeatures" :key="g.module">
<!-- Cabeçalho do módulo -->
<div class="flex items-center gap-2 mb-2">
<span class="text-[0.68rem] font-bold uppercase tracking-[0.07em] text-[var(--text-color-secondary)] opacity-50">{{ moduleLabel(g.module) }}</span>
<div class="flex-1 h-px bg-[var(--surface-border,#e2e8f0)]" />
<span class="text-[1rem] text-[var(--text-color-secondary)]">{{ g.items.length }}</span>
</div>
<!-- Grid de features -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1">
<div
v-for="f in g.items"
:key="f.key"
class="rounded-2xl border border-[var(--surface-border)] p-3"
class="flex items-start gap-2 py-1 px-2 rounded-md hover:bg-[var(--surface-ground,#f8fafc)] transition-colors"
:title="f.description || f.key"
>
<div class="flex items-start gap-3">
<i class="pi pi-check mt-1 text-sm text-color-secondary"></i>
<i class="pi pi-check-circle text-emerald-500 text-[1rem] mt-0.5 flex-shrink-0" />
<div class="min-w-0">
<div class="font-medium break-words">{{ f.key }}</div>
<div class="text-sm text-color-secondary mt-1" v-if="f.description">
{{ f.description }}
<div class="text-[1rem] font-medium truncate text-[var(--text-color)]">{{ f.key }}</div>
<div v-if="f.description" class="text-[1rem] text-[var(--text-color-secondary)] leading-snug truncate">{{ f.description }}</div>
</div>
</div>
</div>
</li>
</ul>
</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-60">
Agrupamento automático por prefixo da key (ex.: <b>agenda.*</b>, <b>patients.*</b>).
</div>
</div>
</div>
</div>
<div class="text-xs text-color-secondary">
Agrupamento automático por prefixo da key (ex.: <b>agenda.*</b>, <b>patients.*</b>, etc.).
<!-- Histórico -->
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
<div class="flex items-center gap-2">
<i class="pi pi-history text-[var(--text-color-secondary)] opacity-60" />
<span class="font-semibold text-[1rem]">Histórico</span>
</div>
</div>
</template>
</Card>
<!-- Histórico auditável -->
<Card class="rounded-[2rem] overflow-hidden">
<template #title>Histórico</template>
<template #content>
<div v-if="!subscription" class="text-color-secondary">
Sem histórico (não assinatura).
<span
v-if="events.length"
class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1.5 rounded-full bg-[var(--primary-color,#6366f1)] text-white text-[1rem] font-bold"
>{{ events.length }}</span>
</div>
<div v-else-if="!events.length" class="text-color-secondary">
Sem eventos registrados.
<div class="p-4">
<div v-if="!events.length" class="py-8 text-center">
<i class="pi pi-history text-2xl text-[var(--text-color-secondary)] opacity-20 mb-2 block" />
<div class="text-[1rem] text-[var(--text-color-secondary)]">Sem eventos registrados.</div>
</div>
<div v-else class="space-y-3">
<div v-else class="flex flex-col gap-2">
<div
v-for="ev in events"
:key="ev.id"
class="rounded-2xl border border-[var(--surface-border)] p-3"
class="rounded-md border border-[var(--surface-border,#e2e8f0)] p-3 hover:bg-[var(--surface-ground,#f8fafc)] transition-colors duration-100"
>
<div class="flex items-start justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<Tag
:value="eventLabel(ev.event_type)"
:severity="eventSeverity(ev.event_type)"
rounded
/>
<span class="text-sm text-color-secondary">
<Tag :value="eventLabel(ev.event_type)" :severity="eventSeverity(ev.event_type)" />
<span class="text-[1rem] text-[var(--text-color-secondary)]">
por <b>{{ displayUser(ev.created_by) }}</b>
</span>
</div>
<!-- De Para (quando existir) -->
<div v-if="ev.old_plan_id || ev.new_plan_id" class="mt-2 text-sm">
<span class="text-color-secondary">Plano:</span>
<span class="font-medium ml-2">{{ planKeyOrName(ev.old_plan_id) }}</span>
<i class="pi pi-arrow-right text-color-secondary mx-2" />
<span class="font-medium">{{ planKeyOrName(ev.new_plan_id) }}</span>
<div v-if="ev.old_plan_id || ev.new_plan_id" class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)] flex items-center gap-1.5 flex-wrap">
<span class="font-medium text-[var(--text-color)]">{{ planKeyOrName(ev.old_plan_id) }}</span>
<i class="pi pi-arrow-right text-[1rem] opacity-50" />
<span class="font-medium text-[var(--text-color)]">{{ planKeyOrName(ev.new_plan_id) }}</span>
</div>
<div v-if="ev.reason" class="mt-2 text-sm opacity-80">
{{ ev.reason }}
<div v-if="ev.reason" class="mt-1 text-[1rem] text-[var(--text-color-secondary)] opacity-70">{{ ev.reason }}</div>
<div v-if="ev.metadata" class="mt-1.5">
<pre class="m-0 text-[1rem] text-[var(--text-color-secondary)] whitespace-pre-wrap break-words opacity-60">{{ prettyMeta(ev.metadata) }}</pre>
</div>
</div>
<div class="text-sm text-color-secondary">
{{ fmtDate(ev.created_at) }}
<div class="text-[1rem] text-[var(--text-color-secondary)] flex-shrink-0">{{ fmtDate(ev.created_at) }}</div>
</div>
</div>
<div class="mt-2 text-xs text-color-secondary" v-if="ev.metadata">
<pre class="m-0 whitespace-pre-wrap break-words">{{ prettyMeta(ev.metadata) }}</pre>
</div>
</div>
</div>
<div class="mt-4 text-xs text-color-secondary">
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-60 mt-1">
Mostrando até 50 eventos (mais recentes).
</div>
</template>
</Card>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -302,118 +302,195 @@ onBeforeUnmount(() => { _observer?.disconnect() })
<Toast />
<!-- Sentinel -->
<div ref="headerSentinelRef" class="mplan-sentinel" />
<div ref="headerSentinelRef" class="h-px" />
<!-- Hero sticky -->
<div ref="headerEl" class="mplan-hero mx-3 md:mx-5 mb-4" :class="{ 'mplan-hero--stuck': headerStuck }">
<div class="mplan-hero__blobs" aria-hidden="true">
<div class="mplan-hero__blob mplan-hero__blob--1" />
<div class="mplan-hero__blob mplan-hero__blob--2" />
<!--
HERO sticky
-->
<section
ref="headerEl"
class="sticky mx-3 md:mx-4 mb-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5 transition-[border-radius] duration-200"
:class="{ 'rounded-tl-none rounded-tr-none': headerStuck }"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<!-- Blobs -->
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-indigo-500/10" />
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-emerald-400/[0.08]" />
</div>
<!-- Row 1 -->
<div class="mplan-hero__row1">
<div class="mplan-hero__brand">
<div class="mplan-hero__icon"><i class="pi pi-credit-card text-lg" /></div>
<div class="min-w-0">
<div class="relative z-[1] flex items-center gap-3">
<!-- Brand -->
<div class="flex items-center gap-2 flex-shrink-0">
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
<i class="pi pi-credit-card text-base" />
</div>
<div class="min-w-0 hidden sm:block">
<div class="flex items-center gap-2 flex-wrap">
<div class="mplan-hero__title">Meu Plano</div>
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Meu Plano</div>
<Tag v-if="subscription" :value="statusLabel(subscription.status)" :severity="statusSeverity(subscription.status)" />
</div>
<div class="mplan-hero__sub">Plano pessoal do terapeuta gerencie sua assinatura</div>
<div class="text-[1rem] text-[var(--text-color-secondary)]">Plano pessoal do terapeuta gerencie sua assinatura</div>
</div>
</div>
<!-- Desktop (1200px) -->
<div class="hidden xl:flex items-center gap-2 shrink-0">
<!-- Ações desktop ( xl) -->
<div class="hidden xl:flex items-center gap-1 flex-shrink-0 ml-auto">
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" title="Atualizar" @click="fetchMeuPlanoTherapist" />
<Button label="Alterar plano" icon="pi pi-arrow-up-right" class="rounded-full" @click="goUpgrade" />
</div>
<!-- Mobile (<1200px) -->
<div class="flex xl:hidden items-center gap-2 shrink-0">
<!-- Ações mobile (< xl) -->
<div class="flex xl:hidden items-center gap-1 flex-shrink-0 ml-auto">
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" @click="fetchMeuPlanoTherapist" />
<Button label="Alterar plano" icon="pi pi-arrow-up-right" size="small" class="rounded-full" @click="goUpgrade" />
</div>
</div>
</section>
<!-- Divider -->
<Divider class="mplan-hero__divider my-2" />
<!-- Row 2: resumo rápido (oculto no mobile) -->
<div class="mplan-hero__row2">
<div v-if="loading" class="flex items-center gap-2 text-sm text-[var(--text-color-secondary)]">
<i class="pi pi-spin pi-spinner text-xs" /> Carregando
<!--
QUICK-STATS
-->
<div class="flex flex-wrap gap-2 px-3 md:px-4 mb-3">
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
<div class="text-[1.1rem] font-bold leading-none text-[var(--text-color)] truncate">{{ planName }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Plano atual</div>
</div>
<template v-else-if="subscription">
<div class="flex flex-wrap items-center gap-3">
<span class="font-semibold text-sm text-[var(--text-color)]">{{ planName }}</span>
<span v-if="priceLabel" class="text-sm text-[var(--text-color-secondary)]">{{ priceLabel }}</span>
<span class="text-xs text-[var(--text-color-secondary)] border border-[var(--surface-border)] rounded-full px-3 py-1">
Período: {{ periodLabel }}
</span>
<div
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] transition-colors duration-150"
:class="subscription?.status === 'active' ? 'border-green-500/25 bg-green-500/5' : 'border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]'"
>
<div
class="text-[1.1rem] font-bold leading-none truncate"
:class="subscription?.status === 'active' ? 'text-green-500' : 'text-[var(--text-color)]'"
>{{ subscription ? statusLabel(subscription.status) : '—' }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Status</div>
</div>
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
<div class="text-[1.35rem] font-bold leading-none text-[var(--text-color)]">{{ features.length }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Recursos</div>
</div>
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
<div class="text-[1.35rem] font-bold leading-none text-[var(--text-color)]">{{ events.length }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Eventos</div>
</div>
</div>
<!--
CONTEÚDO
-->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-3">
<!-- Loading skeleton -->
<div v-if="loading" class="flex flex-col gap-3">
<div
v-for="n in 3"
:key="n"
class="flex items-center gap-4 p-4 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]"
>
<div class="w-10 h-10 rounded-full flex-shrink-0 bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
<div class="flex flex-col gap-2 flex-1">
<div class="h-3.5 w-3/5 rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
<div class="h-2.5 w-2/5 rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
</div>
</div>
</div>
<!-- Sem assinatura -->
<div
v-else-if="!subscription"
class="flex flex-col items-center justify-center gap-4 rounded-md border-2 border-dashed border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)] py-16 px-6 text-center"
>
<div class="relative">
<div class="grid place-items-center w-16 h-16 rounded-2xl bg-[var(--surface-card,#fff)] border border-[var(--surface-border,#e2e8f0)] shadow-sm text-[var(--text-color-secondary)]">
<i class="pi pi-credit-card text-3xl opacity-30" />
</div>
<div class="absolute -top-2 -right-2 w-7 h-7 rounded-full bg-[var(--surface-card,#fff)] border border-[var(--surface-border,#e2e8f0)] shadow-sm grid place-items-center">
<i class="pi pi-times text-[0.65rem] text-[var(--text-color-secondary)] opacity-50" />
</div>
</div>
<div>
<div class="font-bold text-[1rem] text-[var(--text-color)] mb-1">Nenhuma assinatura encontrada</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] max-w-xs leading-relaxed">Escolha um plano para começar a usar todos os recursos.</div>
</div>
<Button label="Ver planos" icon="pi pi-arrow-up-right" class="rounded-full mt-1" @click="goUpgrade" />
</div>
<template v-else>
<!-- Assinatura atual -->
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
<div class="flex items-center gap-2">
<i class="pi pi-credit-card text-[var(--text-color-secondary)] opacity-60" />
<span class="font-semibold text-[1rem]">Assinatura atual</span>
</div>
<div class="flex items-center gap-2">
<Tag :value="statusLabel(subscription.status)" :severity="statusSeverity(subscription.status)" />
<Tag v-if="subscription.cancel_at_period_end" severity="warning" value="Cancelamento agendado" />
<Tag v-else severity="success" value="Renovação automática" />
</div>
</template>
<div v-else class="text-sm text-[var(--text-color-secondary)]">Nenhuma assinatura ativa.</div>
</div>
<div class="px-4 py-3 flex flex-wrap gap-x-8 gap-y-3">
<div class="flex flex-col gap-0.5">
<span class="text-[0.68rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-60">Plano</span>
<span class="text-[0.9rem] font-semibold text-[var(--text-color)]">{{ planName }}</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-[0.68rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-60">Valor</span>
<span class="text-[1rem] text-[var(--text-color)]">{{ priceLabel || 'Preço não encontrado para este intervalo.' }}</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-[0.68rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-60">Período</span>
<span class="text-[1rem] text-[var(--text-color)]">{{ periodLabel }}</span>
</div>
<div v-if="plan?.description" class="flex flex-col gap-0.5 w-full">
<span class="text-[0.68rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-60">Descrição</span>
<span class="text-[1rem] text-[var(--text-color-secondary)]">{{ plan.description }}</span>
</div>
<div class="flex flex-col gap-0.5">
<span class="text-[0.68rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-60">ID da assinatura</span>
<code class="text-[0.75rem] text-[var(--text-color-secondary)] break-all font-mono select-all">{{ subscription.id }}</code>
</div>
</div>
</div>
<!-- Conteúdo -->
<div class="px-3 md:px-5 mb-5 flex flex-col gap-4">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<!-- Sem assinatura -->
<div v-if="!loading && !subscription" class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-10 text-center">
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
<i class="pi pi-credit-card text-xl" />
</div>
<div class="font-semibold">Nenhuma assinatura encontrada</div>
<div class="mt-1 text-sm text-[var(--text-color-secondary)]">Escolha um plano para começar a usar todos os recursos.</div>
<div class="mt-4">
<Button label="Ver planos" icon="pi pi-arrow-up-right" class="rounded-full" @click="goUpgrade" />
<!-- Seu plano inclui -->
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
<div class="flex items-center gap-2">
<i class="pi pi-check-circle text-[var(--text-color-secondary)] opacity-60" />
<span class="font-semibold text-[1rem]">Seu plano inclui</span>
</div>
<span
v-if="features.length"
class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1.5 rounded-full bg-[var(--primary-color,#6366f1)] text-white text-[1rem] font-bold"
>{{ features.length }}</span>
</div>
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="p-4">
<div v-if="!features.length" class="text-[1rem] text-[var(--text-color-secondary)]">Nenhuma feature vinculada a este plano.</div>
<!-- Seu plano inclui: features compactas -->
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<div class="px-5 py-4 border-b border-[var(--surface-border)] flex items-center justify-between gap-3">
<div>
<div class="font-semibold text-[var(--text-color)]">Seu plano inclui</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5">Recursos disponíveis na sua assinatura atual</div>
</div>
<Tag v-if="features.length" :value="`${features.length}`" severity="secondary" />
</div>
<div class="p-5">
<div v-if="!subscription" class="text-sm text-[var(--text-color-secondary)]">Sem assinatura.</div>
<div v-else-if="!features.length" class="text-sm text-[var(--text-color-secondary)]">Nenhuma feature vinculada a este plano.</div>
<div v-else class="space-y-5">
<div v-else class="flex flex-col gap-5">
<div v-for="g in groupedFeatures" :key="g.module">
<!-- Cabeçalho do módulo -->
<div class="flex items-center gap-2 mb-2">
<span class="text-xs font-bold uppercase tracking-wider text-[var(--text-color-secondary)] opacity-50">
{{ moduleLabel(g.module) }}
</span>
<div class="flex-1 h-px bg-[var(--surface-border)]" />
<span class="text-xs text-[var(--text-color-secondary)]">{{ g.items.length }}</span>
<span class="text-[0.68rem] font-bold uppercase tracking-[0.07em] text-[var(--text-color-secondary)] opacity-50">{{ moduleLabel(g.module) }}</span>
<div class="flex-1 h-px bg-[var(--surface-border,#e2e8f0)]" />
<span class="text-[1rem] text-[var(--text-color-secondary)]">{{ g.items.length }}</span>
</div>
<!-- Grid compacto de features -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1">
<div
v-for="f in g.items"
:key="f.key"
class="flex items-start gap-2 py-1 px-2 rounded-lg hover:bg-[var(--surface-ground)] transition-colors"
class="flex items-start gap-2 py-1 px-2 rounded-md hover:bg-[var(--surface-ground,#f8fafc)] transition-colors"
:title="f.description || f.key"
>
<i class="pi pi-check-circle text-emerald-500 text-sm mt-0.5 shrink-0" />
<i class="pi pi-check-circle text-emerald-500 text-[1rem] mt-0.5 flex-shrink-0" />
<div class="min-w-0">
<div class="text-sm font-medium truncate text-[var(--text-color)]">{{ f.key }}</div>
<div v-if="f.description" class="text-xs text-[var(--text-color-secondary)] leading-snug truncate">{{ f.description }}</div>
<div class="text-[1rem] font-medium truncate text-[var(--text-color)]">{{ f.key }}</div>
<div v-if="f.description" class="text-[1rem] text-[var(--text-color-secondary)] leading-snug truncate">{{ f.description }}</div>
</div>
</div>
</div>
@@ -422,116 +499,64 @@ onBeforeUnmount(() => { _observer?.disconnect() })
</div>
</div>
<!-- Histórico -->
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<div class="px-5 py-4 border-b border-[var(--surface-border)] flex items-center justify-between gap-3">
<div>
<div class="font-semibold text-[var(--text-color)]">Histórico</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5">Últimos 50 eventos da assinatura</div>
<!-- Histórico -->
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
<div class="flex items-center gap-2">
<i class="pi pi-history text-[var(--text-color-secondary)] opacity-60" />
<span class="font-semibold text-[1rem]">Histórico</span>
</div>
<Tag v-if="events.length" :value="`${events.length}`" severity="secondary" />
<span
v-if="events.length"
class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1.5 rounded-full bg-[var(--primary-color,#6366f1)] text-white text-[1rem] font-bold"
>{{ events.length }}</span>
</div>
<div class="p-5">
<div v-if="!subscription" class="text-sm text-[var(--text-color-secondary)]">Sem histórico (não assinatura).</div>
<div v-else-if="!events.length" class="py-8 text-center">
<i class="pi pi-history text-2xl text-[var(--text-color-secondary)] opacity-30 mb-2 block" />
<div class="text-sm text-[var(--text-color-secondary)]">Sem eventos registrados.</div>
<div class="p-4">
<div v-if="!events.length" class="py-8 text-center">
<i class="pi pi-history text-2xl text-[var(--text-color-secondary)] opacity-20 mb-2 block" />
<div class="text-[1rem] text-[var(--text-color-secondary)]">Sem eventos registrados.</div>
</div>
<div v-else class="space-y-2">
<div v-else class="flex flex-col gap-2">
<div
v-for="ev in events"
:key="ev.id"
class="rounded-xl border border-[var(--surface-border)] p-3 hover:bg-[var(--surface-ground)] transition-colors"
class="rounded-md border border-[var(--surface-border,#e2e8f0)] p-3 hover:bg-[var(--surface-ground,#f8fafc)] transition-colors duration-100"
>
<div class="flex items-start justify-between gap-3 flex-wrap">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<Tag :value="eventLabel(ev.event_type)" :severity="eventSeverity(ev.event_type)" />
<span class="text-xs text-[var(--text-color-secondary)]">
<span class="text-[1rem] text-[var(--text-color-secondary)]">
por <b>{{ displayUser(ev.created_by) }}</b>
</span>
</div>
<div v-if="ev.old_plan_id || ev.new_plan_id" class="mt-1.5 text-xs text-[var(--text-color-secondary)] flex items-center gap-1.5 flex-wrap">
<div v-if="ev.old_plan_id || ev.new_plan_id" class="mt-1.5 text-[1rem] text-[var(--text-color-secondary)] flex items-center gap-1.5 flex-wrap">
<span class="font-medium text-[var(--text-color)]">{{ planKeyOrName(ev.old_plan_id) }}</span>
<i class="pi pi-arrow-right text-xs opacity-50" />
<i class="pi pi-arrow-right text-[1rem] opacity-50" />
<span class="font-medium text-[var(--text-color)]">{{ planKeyOrName(ev.new_plan_id) }}</span>
</div>
<div v-if="ev.reason" class="mt-1 text-xs text-[var(--text-color-secondary)] opacity-70">{{ ev.reason }}</div>
<div v-if="ev.reason" class="mt-1 text-[1rem] text-[var(--text-color-secondary)] opacity-70">{{ ev.reason }}</div>
<div v-if="ev.metadata" class="mt-1.5">
<pre class="m-0 text-xs text-[var(--text-color-secondary)] whitespace-pre-wrap break-words opacity-60">{{ prettyMeta(ev.metadata) }}</pre>
<pre class="m-0 text-[1rem] text-[var(--text-color-secondary)] whitespace-pre-wrap break-words opacity-60">{{ prettyMeta(ev.metadata) }}</pre>
</div>
</div>
<div class="text-xs text-[var(--text-color-secondary)] shrink-0">{{ fmtDate(ev.created_at) }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] flex-shrink-0">{{ fmtDate(ev.created_at) }}</div>
</div>
</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-60 mt-1">
Mostrando até 50 eventos (mais recentes).
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Rodapé: subscription ID -->
<div v-if="subscription" class="text-xs text-[var(--text-color-secondary)] flex items-center gap-2 flex-wrap">
<span>ID da assinatura:</span>
<code class="font-mono select-all">{{ subscription.id }}</code>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.mplan-sentinel { height: 1px; }
.mplan-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.25rem 1.5rem;
}
.mplan-hero--stuck {
margin-left: 0; margin-right: 0;
border-top-left-radius: 0; border-top-right-radius: 0;
}
.mplan-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.mplan-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
.mplan-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(99,102,241,0.10); }
.mplan-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(16,185,129,0.08); }
.mplan-hero__row1 {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1rem;
}
.mplan-hero__brand {
display: flex; align-items: center; gap: 0.75rem;
flex: 1; min-width: 0;
}
.mplan-hero__icon {
display: grid; place-items: center;
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem; flex-shrink: 0;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
}
.mplan-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
.mplan-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
.mplan-hero__row2 {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap;
}
@media (max-width: 767px) {
.mplan-hero__divider,
.mplan-hero__row2 { display: none; }
}
/* (intencionalmente vazio) */
</style>

View File

@@ -254,169 +254,221 @@ onMounted(loadData)
</script>
<template>
<div class="p-4 md:p-6">
<Toast />
<!-- Topbar padrão -->
<div class="mb-4 rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 shadow-sm">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-col">
<div class="text-2xl font-semibold leading-none">Upgrade do terapeuta</div>
<small class="text-color-secondary mt-1">
Escolha seu plano pessoal (Modelo A).
</small>
<!-- Sentinel -->
<div class="h-px" />
<!--
HERO sticky
-->
<section
class="sticky mx-3 md:mx-4 mb-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<!-- Blobs -->
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-indigo-500/10" />
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-emerald-400/[0.08]" />
</div>
<div class="flex items-center gap-2 flex-wrap justify-end">
<Button
label="Voltar"
icon="pi pi-arrow-left"
severity="secondary"
outlined
:disabled="saving"
@click="goBack"
/>
<Button
label="Atualizar"
icon="pi pi-refresh"
severity="secondary"
outlined
:loading="loading"
:disabled="saving"
@click="loadData"
/>
<div class="relative z-[1] flex flex-col gap-2.5">
<!-- Linha 1: brand + busca + ações -->
<div class="flex items-center gap-3 flex-wrap">
<!-- Brand -->
<div class="flex items-center gap-2 flex-shrink-0">
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
<i class="pi pi-arrow-up-right text-base" />
</div>
<div class="min-w-0 hidden sm:block">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Upgrade do Terapeuta</div>
<div class="text-[1rem] text-[var(--text-color-secondary)]">Escolha seu plano pessoal (Modelo A)</div>
</div>
</div>
<div class="mt-3 flex flex-wrap items-center gap-3">
<Tag
v-if="currentSub"
:value="`Plano atual: ${currentSub.plan_key} • ${intervalLabel(currentSub.interval)} • ${currentSub.status}`"
severity="success"
rounded
/>
<Tag
v-else
value="Você ainda não tem um plano pessoal."
severity="warning"
rounded
/>
<div class="flex items-center gap-2 ml-auto">
<small class="text-color-secondary">Exibição de preço</small>
<SelectButton
v-model="billingInterval"
:options="intervalOptions"
optionLabel="label"
optionValue="value"
:disabled="loading || saving"
/>
</div>
</div>
</div>
<!-- Busca padrão FloatLabel -->
<Card class="rounded-[2rem] overflow-hidden mb-4">
<template #content>
<div class="flex flex-wrap items-center gap-3 justify-between">
<div class="flex flex-col">
<div class="font-semibold">Planos disponíveis</div>
<small class="text-color-secondary">
Filtre por nome/key/descrição e selecione.
</small>
</div>
<div class="w-full md:w-[420px]">
<FloatLabel variant="on" class="w-full">
<!-- Busca desktop -->
<div class="hidden md:flex flex-1 min-w-[180px] max-w-xs">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText v-model="q" id="therapist_upgrade_search" class="w-full pr-10" variant="filled" />
<InputText v-model="q" placeholder="Buscar plano..." class="w-full" autocomplete="off" :disabled="loading || saving" />
</IconField>
<label for="therapist_upgrade_search">Buscar plano...</label>
</FloatLabel>
</div>
<!-- Ações -->
<div class="flex items-center gap-1 flex-shrink-0 ml-auto">
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full flex-shrink-0" :loading="loading" :disabled="saving" title="Atualizar" @click="loadData" />
<Button label="Voltar" icon="pi pi-arrow-left" severity="secondary" outlined class="rounded-full" :disabled="saving" @click="goBack" />
</div>
</div>
</template>
</Card>
<Divider />
<!-- Linha 2: busca mobile + seletor de intervalo -->
<div class="flex flex-wrap items-center gap-2">
<!-- Busca mobile -->
<div class="flex md:hidden flex-1 min-w-[160px]">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText v-model="q" placeholder="Buscar plano..." class="w-full" autocomplete="off" :disabled="loading || saving" />
</IconField>
</div>
<!-- Cards estilo vitrine -->
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 mt-4">
<Card
<!-- Intervalo chips -->
<div class="flex items-center gap-1.5 flex-shrink-0">
<span class="text-[1rem] text-[var(--text-color-secondary)] opacity-70 hidden sm:inline">Preço:</span>
<button
v-for="opt in intervalOptions"
:key="opt.value"
class="inline-flex items-center gap-1.5 px-3.5 py-1 rounded-full text-[1rem] font-semibold border-[1.5px] cursor-pointer transition-all duration-150 select-none"
:class="billingInterval === opt.value
? 'bg-[var(--primary-color,#6366f1)] border-[var(--primary-color,#6366f1)] text-white'
: 'border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)] text-[var(--text-color-secondary)] hover:border-indigo-300 hover:text-[var(--text-color)]'"
:disabled="loading || saving"
@click="billingInterval = opt.value"
>{{ opt.label }}</button>
</div>
<!-- Plano atual -->
<Tag
v-if="currentSub"
:value="`Atual: ${currentSub.plan_key} · ${intervalLabel(currentSub.interval)}`"
severity="success"
/>
<Tag v-else value="Sem plano pessoal" severity="warning" />
</div>
</div>
</section>
<!--
QUICK-STATS
-->
<div class="flex flex-wrap gap-2 px-3 md:px-4 mb-3">
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
<div class="text-[1.35rem] font-bold leading-none text-[var(--text-color)]">{{ filteredPlans.length }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Planos disponíveis</div>
</div>
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
<div class="text-[1.1rem] font-bold leading-none text-[var(--text-color)] truncate">{{ currentSub?.plan_key || '—' }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Plano atual</div>
</div>
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
<div class="text-[1.1rem] font-bold leading-none text-[var(--text-color)]">{{ billingInterval === 'month' ? 'Mensal' : 'Anual' }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Exibição de preço</div>
</div>
</div>
<!--
PLANOS
-->
<div class="px-3 md:px-4 pb-8">
<!-- Loading skeleton -->
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
<div
v-for="n in 3"
:key="n"
class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] p-4"
>
<div class="flex flex-col gap-3">
<div class="h-4 w-2/5 rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
<div class="h-3 w-3/5 rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
<div class="h-8 w-1/3 rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
</div>
</div>
</div>
<!-- Empty state -->
<div
v-else-if="!filteredPlans.length"
class="flex flex-col items-center justify-center gap-4 rounded-md border-2 border-dashed border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)] py-16 px-6 text-center"
>
<div class="relative">
<div class="grid place-items-center w-16 h-16 rounded-2xl bg-[var(--surface-card,#fff)] border border-[var(--surface-border,#e2e8f0)] shadow-sm text-[var(--text-color-secondary)]">
<i class="pi pi-box text-3xl opacity-30" />
</div>
<div class="absolute -top-2 -right-2 w-7 h-7 rounded-full bg-[var(--surface-card,#fff)] border border-[var(--surface-border,#e2e8f0)] shadow-sm grid place-items-center">
<i class="pi pi-times text-[0.65rem] text-[var(--text-color-secondary)] opacity-50" />
</div>
</div>
<div>
<div class="font-bold text-[1rem] text-[var(--text-color)] mb-1">Nenhum plano encontrado</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] max-w-xs leading-relaxed">Tente limpar o filtro de busca.</div>
</div>
<Button label="Limpar busca" icon="pi pi-filter-slash" severity="secondary" outlined class="rounded-full mt-1" @click="q = ''" />
</div>
<!-- Grid de planos -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
<div
v-for="p in filteredPlans"
:key="p.id"
:class="[
'rounded-[2rem] overflow-hidden border border-[var(--surface-border)]',
currentSub?.plan_id === p.id ? 'ring-1 ring-emerald-500/25 md:-translate-y-1 md:scale-[1.01]' : ''
]"
class="rounded-md border bg-[var(--surface-card,#fff)] overflow-hidden flex flex-col transition-shadow duration-150 hover:shadow-[0_4px_18px_rgba(0,0,0,0.07)]"
:class="currentSub?.plan_id === p.id
? 'border-emerald-400/40 ring-1 ring-emerald-500/20'
: 'border-[var(--surface-border,#e2e8f0)]'"
>
<template #title>
<div class="flex items-center justify-between gap-2">
<!-- Cabeçalho do card -->
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
<div class="min-w-0">
<div class="font-semibold truncate">{{ p.name || p.key }}</div>
<small class="text-color-secondary">{{ p.key }}</small>
<div class="font-bold text-[0.9rem] text-[var(--text-color)] truncate">{{ p.name || p.key }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)]">{{ p.key }}</div>
</div>
<Tag v-if="currentSub?.plan_id === p.id" severity="success" value="Atual" />
</div>
<Tag v-if="currentSub?.plan_id === p.id" severity="success" value="Atual" rounded />
</div>
</template>
<!-- Corpo do card -->
<div class="p-4 flex flex-col gap-4 flex-1">
<!-- Descrição -->
<div v-if="p.description" class="text-[1rem] text-[var(--text-color-secondary)]">{{ p.description }}</div>
<template #content>
<div class="text-sm text-color-secondary" v-if="p.description">
{{ p.description }}
<!-- Preço -->
<div>
<div class="text-[2rem] font-bold leading-none text-[var(--text-color)]">{{ priceLabelForCard(p) }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1 opacity-70">Alternar mensal/anual no topo para comparar.</div>
</div>
<div class="mt-4">
<div class="text-4xl font-semibold leading-none">
{{ priceLabelForCard(p) }}
</div>
<div class="text-xs text-color-secondary mt-1">
Alternar mensal/anual no topo para comparar.
</div>
</div>
<div class="mt-5 flex gap-2 flex-wrap">
<!-- Ações -->
<div class="flex flex-col gap-2 mt-auto">
<Button
:label="billingInterval === 'month' ? 'Escolher mensal' : 'Escolher anual'"
icon="pi pi-check"
class="rounded-full w-full"
:loading="saving"
:disabled="loading || saving"
@click="choosePlan(p, billingInterval)"
/>
<div class="flex gap-2">
<Button
label="Mensal"
severity="secondary"
outlined
class="rounded-full flex-1"
:disabled="loading || saving"
@click="choosePlan(p, 'month')"
/>
<Button
label="Anual"
severity="secondary"
outlined
class="rounded-full flex-1"
:disabled="loading || saving"
@click="choosePlan(p, 'year')"
/>
</div>
<div class="mt-3 text-xs text-color-secondary">
<span v-if="priceFor(p.id, billingInterval)">
Preço ativo encontrado para {{ intervalLabel(billingInterval) }}.
</span>
<span v-else>
Sem preço ativo para {{ intervalLabel(billingInterval) }}.
</span>
</div>
</template>
</Card>
</div>
<div v-if="!filteredPlans.length && !loading" class="mt-4 text-sm text-color-secondary">
Nenhum plano encontrado.
<!-- Status do preço -->
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-60">
<span v-if="priceFor(p.id, billingInterval)">Preço ativo encontrado para {{ intervalLabel(billingInterval) }}.</span>
<span v-else>Sem preço ativo para {{ intervalLabel(billingInterval) }}.</span>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* (intencionalmente vazio) */
</style>

View File

@@ -1,4 +1,4 @@
<!-- src/views/pages/upgrade/UpgradePage.vue -->
<!-- src/views/pages/billing/UpgradePage.vue -->
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -311,135 +311,246 @@ watch(() => tenantStore.user?.id, () => { fetchAll() })
<template>
<Toast />
<div class="p-4 md:p-6 lg:p-8">
<!-- HERO -->
<div class="mb-5 overflow-hidden rounded-[2rem] border border-[var(--surface-border)] bg-[var(--surface-card)]">
<div class="relative p-5 md:p-7">
<div class="pointer-events-none absolute inset-0 opacity-80">
<div class="absolute -top-20 -right-24 h-80 w-80 rounded-full bg-indigo-400/10 blur-3xl" />
<div class="absolute top-12 -left-24 h-96 w-96 rounded-full bg-emerald-400/10 blur-3xl" />
<div class="absolute -bottom-20 right-32 h-80 w-80 rounded-full bg-fuchsia-400/10 blur-3xl" />
<!-- Sentinel -->
<div class="h-px" />
<!--
HERO sticky
-->
<section
class="sticky mx-3 md:mx-4 mb-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<!-- Blobs -->
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-indigo-500/10" />
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-emerald-400/[0.08]" />
<div class="absolute w-56 h-56 -bottom-8 right-1/4 rounded-full blur-[55px] bg-fuchsia-400/[0.07]" />
</div>
<div class="relative flex flex-col gap-4">
<div class="flex items-start justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-2xl md:text-3xl font-semibold leading-tight">Atualize seu plano</div>
<div class="mt-1 text-sm md:text-base text-[var(--text-color-secondary)]">
<div class="relative z-[1] flex flex-col gap-2.5">
<!-- Linha 1: brand + busca + ações -->
<div class="flex items-center gap-3 flex-wrap">
<!-- Brand -->
<div class="flex items-center gap-2 flex-shrink-0">
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
<i class="pi pi-sparkles text-base" />
</div>
<div class="min-w-0 hidden sm:block">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Atualize seu plano</div>
<div class="text-[1rem] text-[var(--text-color-secondary)]">
Contexto: <b>{{ isTherapist ? 'Terapeuta' : 'Clínica' }}</b>
<span class="mx-2 opacity-50"></span>
<span class="mx-1.5 opacity-40">·</span>
Plano atual: <b>{{ currentPlanKey || '—' }}</b>
</div>
</div>
</div>
<div class="flex items-center gap-2 flex-wrap">
<Button label="Voltar" icon="pi pi-arrow-left" severity="secondary" outlined :disabled="upgrading" @click="goBack" />
<Button label="Assinatura" icon="pi pi-credit-card" severity="secondary" outlined :disabled="upgrading" @click="goBilling" />
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined :loading="loading" :disabled="upgrading" @click="fetchAll" />
<!-- Busca desktop -->
<div class="hidden md:flex flex-1 min-w-[180px] max-w-xs">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText v-model="q" placeholder="Buscar plano..." class="w-full" autocomplete="off" :disabled="loading || upgrading" />
</IconField>
</div>
<!-- Ações -->
<div class="flex items-center gap-1 flex-shrink-0 ml-auto">
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full flex-shrink-0" :loading="loading" :disabled="upgrading" title="Recarregar" @click="fetchAll" />
<Button label="Assinatura" icon="pi pi-credit-card" severity="secondary" outlined class="rounded-full hidden sm:inline-flex" :disabled="upgrading" @click="goBilling" />
<Button label="Voltar" icon="pi pi-arrow-left" severity="secondary" outlined class="rounded-full" :disabled="upgrading" @click="goBack" />
</div>
</div>
<!-- recurso bloqueado -->
<!-- Linha 2: busca mobile + chips de intervalo -->
<div class="flex flex-wrap items-center gap-2">
<!-- Busca mobile -->
<div class="flex md:hidden flex-1 min-w-[160px]">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText v-model="q" placeholder="Buscar plano..." class="w-full" autocomplete="off" :disabled="loading || upgrading" />
</IconField>
</div>
<!-- Intervalo chips -->
<div class="flex items-center gap-1.5 flex-shrink-0">
<span class="text-[1rem] text-[var(--text-color-secondary)] opacity-70 hidden sm:inline">Preço:</span>
<button
v-for="opt in intervalOptions"
:key="opt.value"
class="inline-flex items-center gap-1.5 px-3.5 py-1 rounded-full text-[1rem] font-semibold border-[1.5px] cursor-pointer transition-all duration-150 select-none"
:class="billingInterval === opt.value
? 'bg-[var(--primary-color,#6366f1)] border-[var(--primary-color,#6366f1)] text-white'
: 'border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)] text-[var(--text-color-secondary)] hover:border-indigo-300 hover:text-[var(--text-color)]'"
:disabled="loading || upgrading"
@click="billingInterval = opt.value"
>{{ opt.label }}</button>
</div>
</div>
</div>
</section>
<!--
BANNER: recurso bloqueado
-->
<Transition name="up-banner">
<div
v-if="requestedFeatureLabel"
class="relative overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4"
v-if="requestedFeatureLabel && !loading"
class="mx-3 md:mx-4 mb-3 flex items-center gap-3 px-4 py-3 rounded-md border border-amber-300/60 bg-amber-50"
>
<div class="relative flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<div class="min-w-0">
<div class="flex items-center gap-2">
<Tag severity="warning" value="Recurso bloqueado" />
<div class="font-semibold truncate">{{ requestedFeatureLabel }}</div>
</div>
<div class="mt-1 text-sm text-[var(--text-color-secondary)]">
Esse recurso depende da feature <b>{{ requestedFeature }}</b>.
<div class="grid place-items-center w-8 h-8 rounded-md bg-amber-400/20 text-amber-600 flex-shrink-0">
<i class="pi pi-lock text-[0.95rem]" />
</div>
<div class="flex-1 min-w-0">
<span class="font-semibold text-[1rem] text-amber-800">Recurso bloqueado: {{ requestedFeatureLabel }}</span>
<span class="hidden sm:inline text-[1rem] text-amber-700 opacity-80 ml-1">Esse recurso depende da feature <b>{{ requestedFeature }}</b>. Escolha um plano que a inclua.</span>
</div>
<Button
label="Ver planos"
icon="pi pi-arrow-down"
severity="secondary"
outlined
size="small"
class="rounded-full flex-shrink-0"
@click="() => document.getElementById('plans-grid')?.scrollIntoView({ behavior: 'smooth', block: 'start' })"
/>
</div>
</div>
</Transition>
<!-- busca + intervalo -->
<div class="flex flex-col md:flex-row md:items-end md:justify-between gap-3">
<div class="w-full md:w-[420px]">
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText v-model="q" id="upgrade_search" class="w-full pr-10" variant="filled" :disabled="loading || upgrading" />
</IconField>
<label for="upgrade_search">Buscar plano...</label>
</FloatLabel>
</div>
<div class="flex flex-col items-start md:items-end gap-2">
<small class="text-[var(--text-color-secondary)]">Exibição de preço</small>
<SelectButton v-model="billingInterval" :options="intervalOptions" optionLabel="label" optionValue="value" :disabled="loading || upgrading" />
<!--
QUICK-STATS
-->
<div class="flex flex-wrap gap-2 px-3 md:px-4 mb-3">
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
<div class="text-[1.35rem] font-bold leading-none text-[var(--text-color)]">{{ sortedPlans.length }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Planos disponíveis</div>
</div>
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
<div class="text-[1.1rem] font-bold leading-none text-[var(--text-color)] truncate">{{ currentPlanKey || '—' }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Plano atual</div>
</div>
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
<div class="text-[1.1rem] font-bold leading-none text-[var(--text-color)]">{{ isTherapist ? 'Terapeuta' : 'Clínica' }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Contexto</div>
</div>
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
<div class="text-[1.35rem] font-bold leading-none text-[var(--text-color)]">{{ features.length }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Features no sistema</div>
</div>
</div>
<!-- PLANOS -->
<div id="plans-grid" class="grid grid-cols-12 gap-4 md:gap-6">
<div v-for="p in sortedPlans" :key="p.id" class="col-span-12 lg:col-span-6">
<!--
PLANOS
-->
<div class="px-3 md:px-4 pb-8">
<!-- Loading skeleton -->
<div v-if="loading" class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<div
:class="String(p.key).toLowerCase() === 'pro'
? 'relative overflow-hidden rounded-[1.75rem] border border-primary/40 bg-[var(--surface-card)]'
: 'relative overflow-hidden rounded-[1.75rem] border border-[var(--surface-border)] bg-[var(--surface-card)]'"
v-for="n in 2"
:key="n"
class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] p-4"
>
<div v-if="String(p.key).toLowerCase() === 'pro'" class="pointer-events-none absolute inset-0 opacity-80">
<div class="absolute -top-24 -right-28 h-96 w-96 rounded-full bg-primary/10 blur-3xl" />
<div class="absolute -bottom-28 left-12 h-96 w-96 rounded-full bg-emerald-400/10 blur-3xl" />
<div class="flex flex-col gap-3">
<div class="h-5 w-2/5 rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
<div class="h-3 w-3/5 rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
<div class="flex flex-col gap-2 mt-2">
<div v-for="i in 4" :key="i" class="h-3 w-4/5 rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
</div>
</div>
</div>
</div>
<Card class="relative border-0">
<template #title>
<div class="flex items-center justify-between gap-2">
<!-- Empty state -->
<div
v-else-if="!sortedPlans.length"
class="flex flex-col items-center justify-center gap-4 rounded-md border-2 border-dashed border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)] py-16 px-6 text-center"
>
<div class="relative">
<div class="grid place-items-center w-16 h-16 rounded-2xl bg-[var(--surface-card,#fff)] border border-[var(--surface-border,#e2e8f0)] shadow-sm text-[var(--text-color-secondary)]">
<i class="pi pi-box text-3xl opacity-30" />
</div>
<div class="absolute -top-2 -right-2 w-7 h-7 rounded-full bg-[var(--surface-card,#fff)] border border-[var(--surface-border,#e2e8f0)] shadow-sm grid place-items-center">
<i class="pi pi-times text-[0.65rem] text-[var(--text-color-secondary)] opacity-50" />
</div>
</div>
<div>
<div class="font-bold text-[1rem] text-[var(--text-color)] mb-1">Nenhum plano encontrado</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] max-w-xs leading-relaxed">Tente limpar o filtro de busca.</div>
</div>
<Button label="Limpar busca" icon="pi pi-filter-slash" severity="secondary" outlined class="rounded-full mt-1" @click="q = ''" />
</div>
<!-- Grid de planos -->
<div v-else id="plans-grid" class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<div
v-for="p in sortedPlans"
:key="p.id"
class="relative overflow-hidden rounded-md border bg-[var(--surface-card,#fff)] flex flex-col"
:class="String(p.key).toLowerCase() === 'pro'
? 'border-[var(--primary-color,#6366f1)]/30'
: 'border-[var(--surface-border,#e2e8f0)]'"
>
<!-- Blobs decorativos (plano PRO) -->
<div v-if="String(p.key).toLowerCase() === 'pro'" class="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden="true">
<div class="absolute -top-20 -right-16 w-72 h-72 rounded-full bg-[var(--primary-color,#6366f1)]/10 blur-[60px]" />
<div class="absolute -bottom-20 left-8 w-72 h-72 rounded-full bg-emerald-400/[0.08] blur-[60px]" />
</div>
<!-- Cabeçalho do card -->
<div class="relative z-[1] flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
<div class="flex items-center gap-2">
<i :class="String(p.key).toLowerCase() === 'pro' ? 'pi pi-sparkles opacity-80' : 'pi pi-leaf opacity-70'" />
<span class="text-xl font-semibold">Plano {{ String(p.key || '').toUpperCase() }}</span>
<i
class="text-[0.9rem]"
:class="String(p.key).toLowerCase() === 'pro' ? 'pi pi-sparkles text-[var(--primary-color,#6366f1)]' : 'pi pi-leaf text-emerald-500 opacity-70'"
/>
<span class="font-bold text-[0.95rem] text-[var(--text-color)]">Plano {{ String(p.key || '').toUpperCase() }}</span>
</div>
<div class="flex items-center gap-2">
<Tag v-if="currentPlanId === p.id" value="Atual" severity="secondary" />
<Tag v-else-if="String(p.key).toLowerCase() === 'pro'" value="Recomendado" severity="success" />
</div>
</div>
</template>
<template #subtitle>
<div class="flex items-center justify-between gap-3 flex-wrap">
<span class="text-[var(--text-color-secondary)]">
<!-- Corpo do card -->
<div class="relative z-[1] p-4 flex flex-col gap-4 flex-1">
<!-- Descrição + preço -->
<div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mb-2">
<template v-if="String(p.key).toLowerCase() === 'free'">O essencial para começar, sem travar seu fluxo.</template>
<template v-else-if="String(p.key).toLowerCase() === 'pro'">Para automatizar, reduzir ruído e ganhar previsibilidade.</template>
<template v-else>Plano: {{ p.key }}</template>
</span>
<span class="text-sm font-semibold">{{ priceLabelForPlan(p.id) }}</span>
<template v-else>{{ p.description || p.key }}</template>
</div>
<div class="text-[1.6rem] font-bold leading-none text-[var(--text-color)]">{{ priceLabelForPlan(p.id) }}</div>
</div>
</template>
<template #content>
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
<ul class="list-none p-0 m-0 flex flex-col gap-3">
<!-- Benefits list -->
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)] p-3">
<ul class="list-none p-0 m-0 flex flex-col gap-2.5">
<li v-for="(b, i) in planBenefits(p.id)" :key="i" class="flex items-start gap-2">
<i :class="b.ok ? 'pi pi-check-circle text-emerald-500' : 'pi pi-times-circle opacity-50'" class="mt-0.5" />
<span :class="b.ok ? '' : 'text-[var(--text-color-secondary)]'">{{ b.text }}</span>
<i
class="text-[1rem] mt-0.5 flex-shrink-0"
:class="b.ok ? 'pi pi-check-circle text-emerald-500' : 'pi pi-times-circle text-[var(--text-color-secondary)] opacity-40'"
/>
<span
class="text-[1rem]"
:class="b.ok ? 'text-[var(--text-color)]' : 'text-[var(--text-color-secondary)]'"
>{{ b.text }}</span>
</li>
</ul>
<Divider class="my-4" />
<div class="h-px bg-[var(--surface-border,#e2e8f0)] my-3" />
<div class="flex flex-col gap-3">
<!-- Ações -->
<div class="flex flex-col gap-2">
<Button
v-if="currentPlanId !== p.id"
:label="`Mudar para ${String(p.key || '').toUpperCase()}`"
icon="pi pi-arrow-up"
size="large"
class="w-full"
class="w-full rounded-full"
:loading="upgrading"
:disabled="upgrading || loading"
@click="changePlan(p.id)"
@@ -450,7 +561,7 @@ watch(() => tenantStore.user?.id, () => { fetchAll() })
icon="pi pi-check"
severity="secondary"
outlined
class="w-full"
class="w-full rounded-full"
disabled
/>
<Button
@@ -459,26 +570,33 @@ watch(() => tenantStore.user?.id, () => { fetchAll() })
icon="pi pi-comments"
severity="secondary"
outlined
class="w-full"
class="w-full rounded-full"
:disabled="upgrading"
@click="contactSupport"
/>
<div class="text-center text-xs text-[var(--text-color-secondary)]">
<div class="text-center text-[1rem] text-[var(--text-color-secondary)] opacity-60">
Cancele quando quiser. Sem burocracia.
</div>
<div v-if="!subscription?.id" class="text-center text-xs text-amber-500">
<div v-if="!subscription?.id" class="text-center text-[1rem] text-amber-500">
Sem assinatura ativa clique em <b>Assinatura</b> para ativar/criar.
</div>
</div>
</div>
</template>
</Card>
</div>
</div>
</div>
<div class="mt-6 text-xs text-[var(--text-color-secondary)]">
<div class="mt-4 text-[1rem] text-[var(--text-color-secondary)] opacity-60">
Alguns recursos PRO podem depender de configuração inicial (ex.: SMS exige provedor).
</div>
</div>
</template>
<style scoped>
.up-banner-enter-active,
.up-banner-leave-active { transition: all 0.25s ease; overflow: hidden; }
.up-banner-enter-from,
.up-banner-leave-to { opacity: 0; max-height: 0; margin-bottom: 0; }
.up-banner-enter-to,
.up-banner-leave-from { opacity: 1; max-height: 80px; }
</style>

View File

@@ -4,6 +4,9 @@ import NotificationsWidget from '@/components/dashboard/NotificationsWidget.vue'
import RecentSalesWidget from '@/components/dashboard/RecentSalesWidget.vue';
import RevenueStreamWidget from '@/components/dashboard/RevenueStreamWidget.vue';
import StatsWidget from '@/components/dashboard/StatsWidget.vue';
import { useRouter } from 'vue-router';
const router = useRouter();
</script>
<template>
@@ -13,8 +16,9 @@ import StatsWidget from '@/components/dashboard/StatsWidget.vue';
<span class="text-primary font-medium">Área</span>
<span class="text-muted-color"> da Clínica</span>
</div>
<div class="flex items-center justify-center bg-blue-100 dark:bg-blue-400/10 rounded-border" style="width: 2.5rem; height: 2.5rem">
<i class="pi pi-user text-blue-500 text-xl!"></i>
<div class="flex items-center gap-2">
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Recarregar" @click="$router.go(0)" />
<Button icon="pi pi-cog" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Configurações" @click="router.push('/configuracoes')" />
</div>
</div>
</div>

View File

@@ -324,268 +324,256 @@ watch(
</script>
<template>
<div class="p-4 md:p-6">
<Toast />
<!-- Header -->
<div class="mb-4 overflow-hidden rounded-[2rem] border border-[var(--surface-border)] bg-[var(--surface-card)]">
<div class="relative p-5 md:p-7">
<!-- blobs sutis -->
<div class="pointer-events-none absolute inset-0 opacity-80">
<div class="absolute -top-16 -right-20 h-72 w-72 rounded-full bg-indigo-400/10 blur-3xl" />
<div class="absolute top-10 -left-24 h-80 w-80 rounded-full bg-emerald-400/10 blur-3xl" />
<div class="absolute -bottom-20 right-24 h-72 w-72 rounded-full bg-fuchsia-400/10 blur-3xl" />
<!-- Sentinel -->
<div class=h-px />
<!-- Hero sticky -->
<div
class=sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5
:style={ top: 'var(--layout-sticky-top, 56px)' }
>
<div class=absolute inset-0 pointer-events-none overflow-hidden aria-hidden=true>
<div class=absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10 />
<div class=absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10 />
<div class=absolute rounded-full blur-[70px] w-72 h-72 -bottom-20 right-24 bg-fuchsia-400/10 />
</div>
<div class="relative flex flex-col gap-2">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<h1 class="text-xl md:text-2xl font-semibold leading-tight">Tipos de Clínica</h1>
<p class="mt-1 text-sm opacity-80">
Ative/desative recursos por clínica. Isso controla menu, rotas (guard) e acesso no banco (RLS).
</p>
<div class=relative z-10 flex flex-col gap-2>
<div class=flex items-center justify-between gap-3 flex-wrap>
<div class=min-w-0>
<div class=text-[1rem] font-bold tracking-tight text-[var(--text-color)]>Tipos de Clínica</div>
<div class=text-[1rem] text-[var(--text-color-secondary)] mt-0.5>
Ative/desative recursos por clínica. Controla menu, rotas e acesso no banco (RLS).
</div>
</div>
<div class="shrink-0 flex items-center gap-2">
<div class=shrink-0 flex items-center gap-2>
<Button
label="Recarregar"
icon="pi pi-refresh"
severity="secondary"
label=Recarregar
icon=pi pi-refresh
severity=secondary
outlined
:loading="loading"
:disabled="applyingPreset || !!savingKey"
@click="reload"
:loading=loading
:disabled=applyingPreset || !!savingKey
@click=reload
/>
</div>
</div>
<div class="mt-2 flex flex-wrap items-center gap-2 text-xs opacity-80">
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1">
<i class="pi pi-building" />
Tenant: <b class="font-mono">{{ tenantId || '—' }}</b>
<div class=flex flex-wrap items-center gap-2>
<span class=inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1 text-[1rem] text-[var(--text-color-secondary)]>
<i class=pi pi-building />
Tenant: <b class=font-mono>{{ tenantId || '—' }}</b>
</span>
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1">
<i class="pi pi-user" />
<span class=inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1 text-[1rem] text-[var(--text-color-secondary)]>
<i class=pi pi-user />
Role: <b>{{ role || '—' }}</b>
</span>
<span
v-if="!tenantReady"
class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1 opacity-70"
v-if=!tenantReady
class=inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1 text-[1rem] text-[var(--text-color-secondary)] opacity-70
>
<i class="pi pi-spin pi-spinner" />
<i class=pi pi-spin pi-spinner />
Carregando contexto
</span>
<span
v-else-if="loading"
class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1 opacity-70"
v-else-if=loading
class=inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1 text-[1rem] text-[var(--text-color-secondary)] opacity-70
>
<i class="pi pi-spin pi-spinner" />
<i class=pi pi-spin pi-spinner />
Atualizando módulos
</span>
</div>
</div>
</div>
</div>
<!-- Banner: acesso somente leitura para terapeutas -->
<div class=px-3 md:px-4 pb-8 flex flex-col gap-4>
<!-- Banner: somente leitura -->
<div
v-if="!isOwner && tenantReady"
class="mb-4 flex items-center gap-3 rounded-[2rem] border border-amber-400/40 bg-amber-400/10 px-5 py-4 text-sm"
v-if=!isOwner && tenantReady
class=flex items-center gap-3 rounded-md border border-amber-400/40 bg-amber-400/10 px-5 py-4 text-[1rem]
>
<i class="pi pi-lock text-amber-400 text-base shrink-0" />
<span class="opacity-90">
<i class=pi pi-lock text-amber-400 shrink-0 />
<span class=text-[1rem] text-[var(--text-color)] opacity-90>
Você está visualizando as configurações da clínica em <b>modo somente leitura</b>.
Apenas o administrador pode ativar ou desativar módulos.
</span>
</div>
<!-- Presets -->
<div class="mb-4 grid grid-cols-1 md:grid-cols-3 gap-3">
<Card class="rounded-[2rem]">
<template #content>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-sm font-semibold">Preset: Coworking</div>
<div class="mt-1 text-xs opacity-80">
<div class=grid grid-cols-1 md:grid-cols-3 gap-3>
<div class=rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5>
<div class=flex items-start justify-between gap-3>
<div class=min-w-0>
<div class=text-[1rem] font-semibold text-[var(--text-color)]>Preset: Coworking</div>
<div class=mt-1 text-[1rem] text-[var(--text-color-secondary)]>
Para aluguel de salas: sem pacientes, com salas.
</div>
</div>
<Button
size="small"
label="Aplicar"
severity="secondary"
size=small
label=Aplicar
severity=secondary
outlined
:loading="applyingPreset"
:disabled="!isOwner || !tenantReady || loading || !!savingKey"
@click="applyPreset('coworking')"
:loading=applyingPreset
:disabled=!isOwner || !tenantReady || loading || !!savingKey
@click=applyPreset('coworking')
/>
</div>
</template>
</Card>
</div>
<Card class="rounded-[2rem]">
<template #content>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-sm font-semibold">Preset: Clínica com recepção</div>
<div class="mt-1 text-xs opacity-80">
<div class=rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5>
<div class=flex items-start justify-between gap-3>
<div class=min-w-0>
<div class=text-[1rem] font-semibold text-[var(--text-color)]>Preset: Clínica com recepção</div>
<div class=mt-1 text-[1rem] text-[var(--text-color-secondary)]>
Para secretária gerenciar agenda (pacientes opcional).
</div>
</div>
<Button
size="small"
label="Aplicar"
severity="secondary"
size=small
label=Aplicar
severity=secondary
outlined
:loading="applyingPreset"
:disabled="!isOwner || !tenantReady || loading || !!savingKey"
@click="applyPreset('reception')"
:loading=applyingPreset
:disabled=!isOwner || !tenantReady || loading || !!savingKey
@click=applyPreset('reception')
/>
</div>
</template>
</Card>
</div>
<Card class="rounded-[2rem]">
<template #content>
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-sm font-semibold">Preset: Clínica completa</div>
<div class="mt-1 text-xs opacity-80">
<div class=rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5>
<div class=flex items-start justify-between gap-3>
<div class=min-w-0>
<div class=text-[1rem] font-semibold text-[var(--text-color)]>Preset: Clínica completa</div>
<div class=mt-1 text-[1rem] text-[var(--text-color-secondary)]>
Pacientes + recepção + salas (se quiser).
</div>
</div>
<Button
size="small"
label="Aplicar"
severity="secondary"
size=small
label=Aplicar
severity=secondary
outlined
:loading="applyingPreset"
:disabled="!isOwner || !tenantReady || loading || !!savingKey"
@click="applyPreset('full')"
:loading=applyingPreset
:disabled=!isOwner || !tenantReady || loading || !!savingKey
@click=applyPreset('full')
/>
</div>
</template>
</Card>
</div>
</div>
<!-- Modules -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<Card class="rounded-[2rem]">
<template #content>
<div class=grid grid-cols-1 lg:grid-cols-2 gap-3>
<div class=rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5>
<ModuleRow
title="Pacientes"
desc="Habilita gestão de pacientes por clínica. Todo paciente tem um responsável (therapist)."
icon="pi pi-users"
:enabled="isOn('patients')"
:loading="savingKey === 'patients'"
:disabled="isLocked('patients')"
@toggle="toggle('patients')"
title=Pacientes
desc=Habilita gestão de pacientes por clínica. Todo paciente tem um responsável (therapist).
icon=pi pi-users
:enabled=isOn('patients')
:loading=savingKey === 'patients'
:disabled=isLocked('patients')
@toggle=toggle('patients')
/>
<div
v-if="planDenied.has('patients')"
class="mt-3 text-xs rounded-2xl border border-[var(--surface-border)] p-3 opacity-90"
v-if=planDenied.has('patients')
class=mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90
>
<i class="pi pi-lock mr-2" />
<i class=pi pi-lock mr-2 />
Este módulo foi bloqueado pelo plano atual do tenant.
</div>
<Divider class="my-4" />
<div class="text-xs opacity-80 leading-relaxed">
<Divider class=my-4 />
<div class=text-[1rem] text-[var(--text-color-secondary)] leading-relaxed>
Quando desligado:
<ul class="mt-2 list-disc pl-5 space-y-1">
<ul class=mt-2 list-disc pl-5 space-y-1>
<li>Menu Pacientes some.</li>
<li>Rotas com <span class="font-mono">meta.tenantFeature = 'patients'</span> redirecionam pra .</li>
<li>Rotas com <span class=font-mono>meta.tenantFeature = 'patients'</span> redirecionam pra .</li>
<li>RLS bloqueia acesso direto no banco.</li>
</ul>
</div>
</template>
</Card>
</div>
<Card class="rounded-[2rem]">
<template #content>
<div class=rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5>
<ModuleRow
title="Recepção / Secretária"
desc="Permite um papel de secretária gerenciar a agenda dos profissionais (sem precisar ver tudo do paciente)."
icon="pi pi-briefcase"
:enabled="isOn('shared_reception')"
:loading="savingKey === 'shared_reception'"
:disabled="isLocked('shared_reception')"
@toggle="toggle('shared_reception')"
title=Recepção / Secretária
desc=Permite um papel de secretária gerenciar a agenda dos profissionais (sem precisar ver tudo do paciente).
icon=pi pi-briefcase
:enabled=isOn('shared_reception')
:loading=savingKey === 'shared_reception'
:disabled=isLocked('shared_reception')
@toggle=toggle('shared_reception')
/>
<div
v-if="planDenied.has('shared_reception')"
class="mt-3 text-xs rounded-2xl border border-[var(--surface-border)] p-3 opacity-90"
v-if=planDenied.has('shared_reception')
class=mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90
>
<i class="pi pi-lock mr-2" />
<i class=pi pi-lock mr-2 />
Este módulo foi bloqueado pelo plano atual do tenant.
</div>
<Divider class="my-4" />
<div class="text-xs opacity-80 leading-relaxed">
<Divider class=my-4 />
<div class=text-[1rem] text-[var(--text-color-secondary)] leading-relaxed>
Observação: este módulo é produto (UX + permissões). A base aqui é o toggle.
Depois a gente cria:
<ul class="mt-2 list-disc pl-5 space-y-1">
<li>role <span class="font-mono">secretary</span> em <span class="font-mono">tenant_members</span></li>
<ul class=mt-2 list-disc pl-5 space-y-1>
<li>role <span class=font-mono>secretary</span> em <span class=font-mono>tenant_members</span></li>
<li>policies e telas para a secretária</li>
<li>nível de visibilidade do paciente na agenda</li>
</ul>
</div>
</template>
</Card>
</div>
<Card class="rounded-[2rem]">
<template #content>
<div class=rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5>
<ModuleRow
title="Salas / Coworking"
desc="Habilita cadastro e reserva de salas/recursos no agendamento."
icon="pi pi-building"
:enabled="isOn('rooms')"
:loading="savingKey === 'rooms'"
:disabled="isLocked('rooms')"
@toggle="toggle('rooms')"
title=Salas / Coworking
desc=Habilita cadastro e reserva de salas/recursos no agendamento.
icon=pi pi-building
:enabled=isOn('rooms')
:loading=savingKey === 'rooms'
:disabled=isLocked('rooms')
@toggle=toggle('rooms')
/>
<div
v-if="planDenied.has('rooms')"
class="mt-3 text-xs rounded-2xl border border-[var(--surface-border)] p-3 opacity-90"
v-if=planDenied.has('rooms')
class=mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90
>
<i class="pi pi-lock mr-2" />
<i class=pi pi-lock mr-2 />
Este módulo foi bloqueado pelo plano atual do tenant.
</div>
<Divider class="my-4" />
<div class="text-xs opacity-80 leading-relaxed">
<Divider class=my-4 />
<div class=text-[1rem] text-[var(--text-color-secondary)] leading-relaxed>
Isso prepara o terreno para a clínica operar como locação de sala, com agenda vinculando sala + profissional.
</div>
</template>
</Card>
</div>
<Card class="rounded-[2rem]">
<template #content>
<div class=rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5>
<ModuleRow
title="Link externo de cadastro"
desc="Libera fluxo público de intake/cadastro externo para a clínica."
icon="pi pi-link"
:enabled="isOn('intake_public')"
:loading="savingKey === 'intake_public'"
:disabled="isLocked('intake_public')"
@toggle="toggle('intake_public')"
title=Link externo de cadastro
desc=Libera fluxo público de intake/cadastro externo para a clínica.
icon=pi pi-link
:enabled=isOn('intake_public')
:loading=savingKey === 'intake_public'
:disabled=isLocked('intake_public')
@toggle=toggle('intake_public')
/>
<div
v-if="planDenied.has('intake_public')"
class="mt-3 text-xs rounded-2xl border border-[var(--surface-border)] p-3 opacity-90"
v-if=planDenied.has('intake_public')
class=mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90
>
<i class="pi pi-lock mr-2" />
<i class=pi pi-lock mr-2 />
Este módulo foi bloqueado pelo plano atual do tenant.
</div>
<Divider class="my-4" />
<div class="text-xs opacity-80 leading-relaxed">
<Divider class=my-4 />
<div class=text-[1rem] text-[var(--text-color-secondary)] leading-relaxed>
Você tem páginas de link externo. Isso vira o controle fino: a clínica decide se usa ou não.
</div>
</template>
</Card>
</div>
</div>
</div>
</template>
<style scoped>
/* (sem estilos adicionais por enquanto) */
</style>

View File

@@ -51,6 +51,12 @@ const loadingHistory = ref(false)
const loadHistoryError = ref('')
const historySearch = ref('')
const HIGHLIGHT_MS = 24 * 60 * 60 * 1000
function isRecent (row) {
if (!row?.created_at) return false
return Date.now() - new Date(row.created_at).getTime() < HIGHLIGHT_MS
}
const filteredHistory = computed(() => {
const q = (historySearch.value || '').trim().toLowerCase()
const base = history.value || []
@@ -96,8 +102,12 @@ const activeTenantKind = ref(null)
const canManage = computed(() => {
const r = (effectiveRole.value || '').toString()
const isAdmin = r === 'clinic_admin' || r === 'tenant_admin'
// só pode gerenciar se for admin E o tenant for uma clínica (não pessoal/saas)
return isAdmin && activeTenantKind.value === 'clinic'
if (!isAdmin) return false
// Aceita qualquer kind de clínica: 'clinic', 'clinic_coworking', 'clinic_reception', 'clinic_full'
// Se activeTenantKind ainda não carregou (null), confia no role já normalizado
const k = String(activeTenantKind.value || '')
if (!k) return true
return k === 'clinic' || k.startsWith('clinic_')
})
const DEV_TEST_EMAILS = [
@@ -733,32 +743,33 @@ onMounted(async () => {
</script>
<template>
<div class="p-4 md:p-6">
<Toast />
<ConfirmDialog />
<!-- Header -->
<div class="mb-4 overflow-hidden rounded-[2rem] border border-[var(--surface-border)] bg-[var(--surface-card)]">
<div class="relative p-5 md:p-7">
<!-- blobs sutis -->
<div class="pointer-events-none absolute inset-0 opacity-80">
<div class="absolute -top-16 -right-20 h-72 w-72 rounded-full bg-indigo-400/10 blur-3xl" />
<div class="absolute top-10 -left-24 h-80 w-80 rounded-full bg-emerald-400/10 blur-3xl" />
<div class="absolute -bottom-20 right-24 h-72 w-72 rounded-full bg-fuchsia-400/10 blur-3xl" />
<!-- Sentinel -->
<div class="h-px" />
<!-- Hero sticky -->
<div
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-72 h-72 -bottom-20 right-24 bg-fuchsia-400/10" />
</div>
<div class="relative flex flex-col gap-2">
<div class="relative z-10 flex flex-col gap-2">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-xl md:text-2xl font-semibold leading-tight">
Profissionais da clínica
</div>
<div class="opacity-70 text-sm">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Profissionais da clínica</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">
Gerencie terapeutas e secretarias vinculados ao seu tenant.
</div>
</div>
<div class="flex items-center gap-2 flex-wrap">
<div class="flex items-center gap-2 flex-wrap shrink-0">
<Button
label="Convidar terapeuta"
icon="pi pi-user-plus"
@@ -776,48 +787,32 @@ onMounted(async () => {
</div>
</div>
<!-- Aviso (regra futura) -->
<div class="mt-3 rounded-2xl border border-[var(--surface-border)] bg-[color-mix(in_srgb,var(--surface-card),transparent_30%)] p-3">
<div class="flex gap-3">
<i class="pi pi-info-circle mt-0.5 opacity-70" />
<div class="text-sm">
<div class="font-semibold">Atenção</div>
<div class="opacity-80 leading-relaxed">
A regra impedir desvincular terapeuta com atendimentos agendados será ativada quando a agenda
registrar o terapeuta no evento (ex.: <span class="font-mono">agenda_eventos.terapeuta_id</span>)
ou quando existir a tabela de sessões/appointments. Por enquanto, a ação de desvincular apenas
desativa o vínculo.
</div>
</div>
</div>
</div>
<!-- Loading leve do tenant -->
<div v-if="!tenantReady" class="mt-2 text-sm opacity-70">
<div v-if="!tenantReady" class="text-[1rem] text-[var(--text-color-secondary)] opacity-70">
Carregando permissões da clínica
</div>
<!-- Aviso de permissão -->
<div v-else-if="!canManage" class="mt-2 text-sm text-orange-600">
<!-- Aviso de permissão (somente se carregou e não tem permissão) -->
<div v-else-if="!canManage" class="text-[1rem] text-orange-600">
Sua conta não tem permissão para gerenciar profissionais (apenas <b>clinic_admin</b>).
</div>
<!-- Debug (opcional) -->
<div v-if="debug" class="mt-2 text-xs opacity-70">
<div v-if="debug" class="text-[1rem] opacity-70">
tenantId={{ tenantId }} | role={{ effectiveRole || '(vazio)' }} | canManage={{ canManage }}
</div>
</div>
</div>
</div>
<!-- 🔎 Aviso sobre logins de teste -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- 🔎 Aviso sobre logins de teste + atalhos de convite -->
<div
class="mb-4 overflow-hidden rounded-[2rem] border border-[var(--surface-border)] bg-[color-mix(in_srgb,var(--surface-card),transparent_18%)]"
class="overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)]"
>
<div class="p-5 md:p-6">
<div class="flex items-start gap-3">
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] grid place-items-center shrink-0">
<div class="h-10 w-10 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] grid place-items-center shrink-0">
<i class="pi pi-info-circle opacity-70" />
</div>
@@ -826,18 +821,18 @@ onMounted(async () => {
Logins de teste (ambiente de desenvolvimento)
</div>
<div class="text-sm opacity-80 mt-1 leading-relaxed">
<div class="text-[1rem] opacity-80 mt-1 leading-relaxed">
As credenciais fixas para testes (clinic, therapist, therapist2, therapist3, secretary, patient e saas)
estão disponíveis na tela inicial do sistema (<span class="font-mono">HomeCards</span>).
</div>
<div class="text-sm opacity-80 mt-2 leading-relaxed">
<div class="text-[1rem] opacity-80 mt-2 leading-relaxed">
Para utilizá-las, basta realizar <b>logout da sessão atual</b> e selecionar o perfil desejado na tela inicial.
</div>
<!-- Atalhos: abrir dialog e preencher o email -->
<div class="mt-4 flex flex-wrap items-center gap-2">
<span class="text-xs opacity-70 mr-1">Atalhos (DEV):</span>
<span class="text-[1rem] opacity-70 mr-1">Atalhos (DEV):</span>
<Button
label="Convidar therapist2"
@@ -872,13 +867,13 @@ onMounted(async () => {
<!-- Links de convite pendentes para testes -->
<div class="mt-3 flex flex-col gap-1">
<span class="text-xs opacity-70 mb-1">Links de convite pendentes (DEV):</span>
<span class="text-[1rem] opacity-70 mb-1">Links de convite pendentes (DEV):</span>
<div
v-for="item in devInviteLinks"
:key="item.email"
class="flex items-center gap-2 flex-wrap"
>
<span class="text-xs font-mono opacity-70 w-56 truncate">{{ item.email }}</span>
<span class="text-[1rem] font-mono opacity-70 w-56 truncate">{{ item.email }}</span>
<Button
label="Copiar link de convite"
icon="pi pi-copy"
@@ -888,7 +883,7 @@ onMounted(async () => {
:disabled="!item.token"
@click="copyInviteLink(item)"
/>
<span v-if="!item.token" class="text-xs opacity-50 italic">sem convite pendente</span>
<span v-if="!item.token" class="text-[1rem] opacity-50 italic">sem convite pendente</span>
</div>
</div>
</div>
@@ -897,16 +892,16 @@ onMounted(async () => {
</div>
<!-- DOCUMENTAÇÃO INTERNA (visível na tela, para QA) -->
<div class="mb-4 overflow-hidden rounded-[2rem] border border-[var(--surface-border)] bg-[color-mix(in_srgb,var(--surface-card),transparent_12%)]">
<div class="overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)]">
<div class="p-5 md:p-6">
<div class="flex items-start gap-3">
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] grid place-items-center shrink-0">
<div class="h-10 w-10 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] grid place-items-center shrink-0">
<i class="pi pi-book opacity-70" />
</div>
<div class="min-w-0">
<div class="font-semibold text-lg leading-tight">Guia rápido Convites (Modelo B) e como testar</div>
<div class="text-sm opacity-80 mt-1 leading-relaxed">
<div class="text-[1rem] opacity-80 mt-1 leading-relaxed">
Esta área existe para facilitar o QA/validação do fluxo de convites no SaaS multi-tenant.
A tela pública de aceite está em:
<span class="font-mono">/accept-invite?token=&lt;uuid&gt;</span>.
@@ -914,9 +909,9 @@ onMounted(async () => {
</div>
<div class="mt-4 grid gap-3">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
<div class="font-semibold text-sm mb-2">Rotas e comportamento esperado</div>
<ul class="text-sm opacity-80 leading-relaxed list-disc pl-5 space-y-1">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
<div class="font-semibold text-[1rem] mb-2">Rotas e comportamento esperado</div>
<ul class="text-[1rem] opacity-80 leading-relaxed list-disc pl-5 space-y-1">
<li><b>Aceite público:</b> <span class="font-mono">/accept-invite?token=&lt;uuid&gt;</span></li>
<li><b>Login:</b> <span class="font-mono">/auth/login</span></li>
<li>
@@ -930,16 +925,16 @@ onMounted(async () => {
<li><b>Erros esperados:</b> token inválido/expirado, convite usado, e-mail diferente (mismatch).</li>
</ul>
<div class="mt-3 text-xs opacity-70 leading-relaxed">
<div class="mt-3 text-[1rem] opacity-70 leading-relaxed">
<b>Nota:</b> o backend foi corrigido para não depender do claim de email no JWT
(erro antigo <span class="font-mono">missing_email_claim</span>). O email é resolvido via
<span class="font-mono">auth.users</span> usando <span class="font-mono">SECURITY DEFINER</span>.
</div>
</div>
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
<div class="font-semibold text-sm mb-2">Como testar (prático)</div>
<ol class="text-sm opacity-80 leading-relaxed list-decimal pl-5 space-y-1">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
<div class="font-semibold text-[1rem] mb-2">Como testar (prático)</div>
<ol class="text-[1rem] opacity-80 leading-relaxed list-decimal pl-5 space-y-1">
<li>Convidar alguém nesta tela (botões acima).</li>
<li>Abrir a aba <b>Convites</b> e copiar o link.</li>
<li>Abrir o link em aba anônima logar com o mesmo email aceitar.</li>
@@ -965,7 +960,7 @@ onMounted(async () => {
/>
</div>
<div class="mt-2 text-xs opacity-70">
<div class="mt-2 text-[1rem] opacity-70">
Dica: use aba anônima para testar o fluxo completo sem interferência de sessão.
</div>
</div>
@@ -974,18 +969,18 @@ onMounted(async () => {
</div>
<!-- Abas -->
<TabView class="rounded-[2rem] overflow-hidden">
<TabView class="rounded-md overflow-hidden">
<!-- =========================
ABA 1: EQUIPE
========================= -->
<TabPanel header="Equipe">
<div class="grid grid-cols-1 gap-4">
<Card class="rounded-[2rem]">
<Card class="rounded-md">
<template #title>
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="font-semibold">Equipe</div>
<div class="text-sm opacity-70">
<div class="text-[1rem] opacity-70">
Membros ativos/inativos do tenant (somente <b>tenant_members</b>).
</div>
</div>
@@ -1015,7 +1010,7 @@ onMounted(async () => {
</template>
<template #content>
<div v-if="loadError" class="mb-3 rounded-2xl border border-red-200 bg-red-50 p-3 text-red-700 text-sm">
<div v-if="loadError" class="mb-3 rounded-md border border-red-200 bg-red-50 p-3 text-red-700 text-[1rem]">
{{ loadError }}
</div>
@@ -1024,14 +1019,15 @@ onMounted(async () => {
:loading="loading"
dataKey="user_id"
responsiveLayout="scroll"
class="p-datatable-sm"
class="p-datatable-sm prof-datatable"
sortField="role_sort"
:sortOrder="1"
:rowClass="(r) => isRecent(r) ? 'row-new-highlight' : ''"
>
<Column header="Pessoa" style="min-width: 18rem">
<template #body="{ data }">
<div class="flex items-center gap-3 min-w-0">
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] grid place-items-center">
<div class="h-10 w-10 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] grid place-items-center">
<i class="pi pi-user opacity-70" />
</div>
@@ -1040,15 +1036,15 @@ onMounted(async () => {
{{ data.full_name || 'Sem nome' }}
</div>
<div class="text-sm opacity-70 truncate">
<div class="text-[1rem] opacity-70 truncate">
{{ data.email || 'Sem email' }}
</div>
<div class="text-xs opacity-60 font-mono truncate">
<div class="text-[1rem] opacity-60 font-mono truncate">
tenant: {{ data.tenant_id }}
</div>
<div class="text-xs opacity-60 font-mono truncate">
<div class="text-[1rem] opacity-60 font-mono truncate">
uid: {{ data.user_id }}
</div>
</div>
@@ -1074,16 +1070,16 @@ onMounted(async () => {
<Column header="Vínculo" style="min-width: 12rem">
<template #body="{ data }">
<span v-if="canManage" class="text-sm opacity-70 italic">vinculado nesta clínica</span>
<span v-if="canManage" class="text-[1rem] opacity-70 italic">vinculado nesta clínica</span>
<template v-else>
<div v-if="myLinks.length > 0" class="flex flex-col gap-1">
<span
v-for="link in myLinks"
:key="link.tenant_id"
class="text-sm"
class="text-[1rem]"
>{{ link.clinic_name }}</span>
</div>
<span v-else class="text-sm opacity-50"></span>
<span v-else class="text-[1rem] opacity-50"></span>
</template>
</template>
</Column>
@@ -1113,7 +1109,7 @@ onMounted(async () => {
/>
</div>
<div v-if="data.is_self" class="text-xs opacity-60 mt-1 text-right">
<div v-if="data.is_self" class="text-[1rem] opacity-60 mt-1 text-right">
Você
</div>
</template>
@@ -1126,26 +1122,26 @@ onMounted(async () => {
</template>
</DataTable>
<small class="block mt-3 opacity-70">
<div class="text-[1rem] block mt-3 opacity-70">
Papel real salvo em <span class="font-mono">tenant_members.role</span>:
<b>tenant_admin</b>, <b>therapist</b>, <b>secretary</b>, <b>patient</b>.
No front, normalizamos <b>tenant_admin clinic_admin</b> (apenas para UI).
</small>
</div>
<small class="block mt-2 opacity-70">
<div class="text-[1rem] block mt-2 opacity-70">
<b>Status:</b> <span class="font-mono">active</span> = acesso liberado.
<span class="font-mono">inactive</span> = vínculo desativado (histórico).
</small>
</div>
</template>
</Card>
<!-- Meus Vínculos (visível apenas para terapeutas e secretárias) -->
<Card v-if="(effectiveRole === 'therapist' || effectiveRole === 'secretary') && (myLinks.length > 0 || loadingMyLinks)" class="rounded-[2rem]">
<Card v-if="(effectiveRole === 'therapist' || effectiveRole === 'secretary') && (myLinks.length > 0 || loadingMyLinks)" class="rounded-md">
<template #title>
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="font-semibold">Meus vínculos</div>
<div class="text-sm opacity-70">
<div class="text-[1rem] opacity-70">
Clínicas às quais sua conta está associada.
</div>
</div>
@@ -1161,7 +1157,7 @@ onMounted(async () => {
</template>
<template #content>
<div v-if="loadMyLinksError" class="mb-3 rounded-2xl border border-red-200 bg-red-50 p-3 text-red-700 text-sm">
<div v-if="loadMyLinksError" class="mb-3 rounded-md border border-red-200 bg-red-50 p-3 text-red-700 text-[1rem]">
{{ loadMyLinksError }}
</div>
@@ -1169,15 +1165,15 @@ onMounted(async () => {
<div
v-for="link in myLinks"
:key="link.tenant_id"
class="flex items-center justify-between gap-4 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4"
class="flex items-center justify-between gap-4 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4"
>
<div class="flex items-center gap-3 min-w-0">
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[color-mix(in_srgb,var(--surface-card),transparent_30%)] grid place-items-center shrink-0">
<div class="h-10 w-10 rounded-md border border-[var(--surface-border)] bg-[color-mix(in_srgb,var(--surface-card),transparent_30%)] grid place-items-center shrink-0">
<i class="pi pi-building opacity-70" />
</div>
<div class="min-w-0">
<div class="font-semibold truncate">{{ link.clinic_name }}</div>
<div class="text-xs font-mono opacity-50 truncate">{{ link.tenant_id }}</div>
<div class="text-[1rem] font-mono opacity-50 truncate">{{ link.tenant_id }}</div>
</div>
</div>
@@ -1192,9 +1188,9 @@ onMounted(async () => {
</div>
</div>
<small class="block mt-3 opacity-70">
<div class="text-[1rem] block mt-3 opacity-70">
Um profissional pode estar vinculado a múltiplas clínicas simultaneamente.
</small>
</div>
</template>
</Card>
</div>
@@ -1202,12 +1198,12 @@ onMounted(async () => {
========================= -->
<TabPanel header="Convites">
<div class="grid grid-cols-1 gap-4">
<Card class="rounded-[2rem]">
<Card class="rounded-md">
<template #title>
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="font-semibold">Convites pendentes</div>
<div class="text-sm opacity-70">
<div class="text-[1rem] opacity-70">
Convites do tenant que ainda não foram aceitos (tabela <b>tenant_invites</b>).
</div>
</div>
@@ -1237,7 +1233,7 @@ onMounted(async () => {
</template>
<template #content>
<div v-if="loadInvitesError" class="mb-3 rounded-2xl border border-red-200 bg-red-50 p-3 text-red-700 text-sm">
<div v-if="loadInvitesError" class="mb-3 rounded-md border border-red-200 bg-red-50 p-3 text-red-700 text-[1rem]">
{{ loadInvitesError }}
</div>
@@ -1246,18 +1242,19 @@ onMounted(async () => {
:loading="loadingInvites"
dataKey="token"
responsiveLayout="scroll"
class="p-datatable-sm"
class="p-datatable-sm invites-datatable"
sortField="created_at"
:sortOrder="-1"
:rowClass="(r) => isRecent(r) ? 'row-new-highlight' : ''"
>
<Column header="Email" style="min-width: 18rem">
<template #body="{ data }">
<div class="min-w-0">
<div class="font-semibold truncate">{{ data.email }}</div>
<div class="text-xs opacity-60 font-mono truncate">
<div class="text-[1rem] opacity-60 font-mono truncate">
token: {{ data.token }}
</div>
<div class="text-xs opacity-60 font-mono truncate">
<div class="text-[1rem] opacity-60 font-mono truncate">
tenant: {{ data.tenant_id }}
</div>
</div>
@@ -1272,13 +1269,13 @@ onMounted(async () => {
<Column header="Expira" style="width: 14rem">
<template #body="{ data }">
<span class="text-sm opacity-80">{{ formatDate(data.expires_at) }}</span>
<span class="text-[1rem] opacity-80">{{ formatDate(data.expires_at) }}</span>
</template>
</Column>
<Column header="Criado em" style="width: 14rem">
<template #body="{ data }">
<span class="text-sm opacity-80">{{ formatDate(data.created_at) }}</span>
<span class="text-[1rem] opacity-80">{{ formatDate(data.created_at) }}</span>
</template>
</Column>
@@ -1325,15 +1322,15 @@ onMounted(async () => {
</template>
</DataTable>
<small class="block mt-3 opacity-70">
<div class="text-[1rem] block mt-3 opacity-70">
<b>Modelo B:</b> convidar não cria membership. O membership aparece na aba <b>Equipe</b> após o aceite em
<span class="font-mono">/accept-invite?token=...</span>.
</small>
</div>
</template>
</Card>
<!-- QA TOOL: Auth Users (TEMPORÁRIO) -->
<Card class="rounded-[2rem] mt-2">
<Card class="rounded-md mt-2">
<template #title>
<div class="flex items-center justify-between">
<div class="font-semibold">Usuários cadastrados (Auth)</div>
@@ -1349,10 +1346,10 @@ onMounted(async () => {
</template>
<template #content>
<div class="mb-4 rounded-2xl border border-orange-200 bg-orange-50 p-4">
<div class="mb-4 rounded-md border border-orange-200 bg-orange-50 p-4">
<div class="flex gap-3">
<i class="pi pi-exclamation-triangle mt-0.5 text-orange-600" />
<div class="text-sm text-orange-800 leading-relaxed">
<div class="text-[1rem] text-orange-800 leading-relaxed">
<div class="font-semibold mb-1">
Aviso técnico View temporária para testes (QA)
</div>
@@ -1372,14 +1369,14 @@ onMounted(async () => {
Remover após validação:
</div>
<div class="mt-2 font-mono text-xs bg-white/60 border border-orange-200 rounded-xl p-2">
<div class="mt-2 font-mono text-[1rem] bg-white/60 border border-orange-200 rounded-md p-2">
drop view if exists public.v_auth_users_public;
</div>
</div>
</div>
</div>
<div v-if="loadUsersError" class="mb-3 rounded-2xl border border-red-200 bg-red-50 p-3 text-red-700 text-sm">
<div v-if="loadUsersError" class="mb-3 rounded-md border border-red-200 bg-red-50 p-3 text-red-700 text-[1rem]">
{{ loadUsersError }}
</div>
@@ -1393,7 +1390,7 @@ onMounted(async () => {
<Column field="email" header="Email" style="min-width: 18rem" />
<Column header="User ID" style="min-width: 22rem">
<template #body="{ data }">
<span class="text-xs font-mono opacity-70 break-all">{{ data.user_id }}</span>
<span class="text-[1rem] font-mono opacity-70 break-all">{{ data.user_id }}</span>
</template>
</Column>
<Column field="created_at" header="Criado em" />
@@ -1409,12 +1406,12 @@ onMounted(async () => {
========================= -->
<TabPanel header="Histórico">
<div class="grid grid-cols-1 gap-4">
<Card class="rounded-[2rem]">
<Card class="rounded-md">
<template #title>
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="font-semibold">Histórico de desvinculados</div>
<div class="text-sm opacity-70">
<div class="text-[1rem] opacity-70">
Membros inativos e convites revogados ou expirados deste tenant.
</div>
</div>
@@ -1444,7 +1441,7 @@ onMounted(async () => {
</template>
<template #content>
<div v-if="loadHistoryError" class="mb-3 rounded-2xl border border-red-200 bg-red-50 p-3 text-red-700 text-sm">
<div v-if="loadHistoryError" class="mb-3 rounded-md border border-red-200 bg-red-50 p-3 text-red-700 text-[1rem]">
{{ loadHistoryError }}
</div>
@@ -1460,13 +1457,13 @@ onMounted(async () => {
<Column header="Pessoa / Email" style="min-width: 18rem">
<template #body="{ data }">
<div class="flex items-center gap-3 min-w-0">
<div class="h-10 w-10 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] grid place-items-center shrink-0">
<div class="h-10 w-10 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] grid place-items-center shrink-0">
<i :class="data.kind === 'member' ? 'pi pi-user opacity-70' : 'pi pi-envelope opacity-70'" />
</div>
<div class="min-w-0">
<div class="font-semibold truncate">{{ data.full_name || data.email || 'Sem identificação' }}</div>
<div v-if="data.full_name" class="text-sm opacity-70 truncate">{{ data.email }}</div>
<div class="text-xs opacity-50 font-mono truncate">
<div v-if="data.full_name" class="text-[1rem] opacity-70 truncate">{{ data.email }}</div>
<div class="text-[1rem] opacity-50 font-mono truncate">
{{ data.kind === 'member' ? 'uid: ' + data.user_id : 'token: ' + data.token }}
</div>
</div>
@@ -1502,10 +1499,10 @@ onMounted(async () => {
<Column header="Data" style="width: 14rem">
<template #body="{ data }">
<div class="text-sm opacity-80">
<div class="text-[1rem] opacity-80">
<div>Criado: {{ formatDate(data.created_at) }}</div>
<div v-if="data.revoked_at" class="text-xs text-red-500 mt-0.5">Revogado: {{ formatDate(data.revoked_at) }}</div>
<div v-else-if="data.expires_at && data.kind !== 'member'" class="text-xs opacity-60 mt-0.5">Expirou: {{ formatDate(data.expires_at) }}</div>
<div v-if="data.revoked_at" class="text-[1rem] text-red-500 mt-0.5">Revogado: {{ formatDate(data.revoked_at) }}</div>
<div v-else-if="data.expires_at && data.kind !== 'member'" class="text-[1rem] opacity-60 mt-0.5">Expirou: {{ formatDate(data.expires_at) }}</div>
</div>
</template>
</Column>
@@ -1532,9 +1529,9 @@ onMounted(async () => {
</template>
</DataTable>
<small class="block mt-3 opacity-70">
<div class="text-[1rem] block mt-3 opacity-70">
Membros inativos podem ser reativados a qualquer momento. Convites revogados/expirados são apenas registro histórico.
</small>
</div>
</template>
</Card>
</div>
@@ -1548,10 +1545,9 @@ onMounted(async () => {
dismissableMask
:style="{ width: 'min(520px, 94vw)' }"
:header="inviteHeader"
class="rounded-[2rem] overflow-hidden"
>
<div class="space-y-4">
<div class="text-sm opacity-80 leading-relaxed">
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">
Informe o email. Este fluxo cria um convite pendente (Modelo B) e ativa o vínculo após o aceite em
<span class="font-mono">/accept-invite</span>.
</div>
@@ -1577,7 +1573,7 @@ onMounted(async () => {
/>
</div>
<div v-if="invite.error" class="rounded-2xl border border-red-200 bg-red-50 p-3 text-red-700 text-sm">
<div v-if="invite.error" class="rounded-md border border-red-200 bg-red-50 p-3 text-[1rem] text-red-700">
{{ invite.error }}
</div>
</div>
@@ -1586,4 +1582,8 @@ onMounted(async () => {
</template>
<style scoped>
.prof-datatable :deep(tr.row-new-highlight td) { background-color: #f0fdf4 !important; }
.invites-datatable :deep(tr.row-new-highlight td) { background-color: #f0fdf4 !important; }
:global(.app-dark) .prof-datatable :deep(tr.row-new-highlight td) { background-color: rgba(16,185,129,0.08) !important; }
:global(.app-dark) .invites-datatable :deep(tr.row-new-highlight td) { background-color: rgba(16,185,129,0.08) !important; }
</style>

View File

@@ -407,35 +407,28 @@ onBeforeUnmount(() => {
<Toast />
<ConfirmDialog />
<!-- Info decorativa (scrolls away naturalmente) -->
<div class="flex items-center gap-3 px-4 pb-3">
<div class="dash-hero__icon shrink-0">
<i class="pi pi-chart-bar text-2xl" />
</div>
<small class="text-color-secondary">
Visão estratégica (receita e distribuição) + saúde de consistência (entitlements).
</small>
</div>
<!-- Sentinel -->
<div ref="sentinelRef" class="h-px" />
<!-- sentinel -->
<div ref="sentinelRef" style="height: 1px; pointer-events: none;" />
<!-- hero -->
<!-- Hero sticky -->
<div
ref="heroRef"
class="dash-hero"
:class="{ 'dash-hero--stuck': heroStuck }"
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class="dash-hero__blob dash-hero__blob--1" />
<div class="dash-hero__blob dash-hero__blob--2" />
<div class="flex items-center gap-3 min-w-0">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-xl font-bold leading-none">Central de Controle do SaaS</span>
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-72 h-72 -bottom-20 right-24 bg-fuchsia-400/10" />
</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Central de Controle do SaaS</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Visão estratégica (receita e distribuição) + saúde de consistência (entitlements).</div>
</div>
<!-- desktop actions -->
<div class="hidden xl:flex items-center gap-2">
<div class="hidden xl:flex items-center gap-2 shrink-0">
<SelectButton
v-model="intervalView"
:options="intervalOptions"
@@ -470,7 +463,7 @@ onBeforeUnmount(() => {
</div>
<!-- mobile -->
<div class="flex xl:hidden">
<div class="flex xl:hidden shrink-0">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
@@ -481,112 +474,90 @@ onBeforeUnmount(() => {
<Menu ref="mobileMenuRef" :model="heroMenuItems" popup />
</div>
</div>
</div>
</div>
</div>
<!-- content -->
<div class="px-4 pb-4">
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- KPIs -->
<div class="grid grid-cols-12 gap-4">
<div class="col-span-12 md:col-span-3">
<Card class="h-full">
<template #title>
<div class="flex items-center justify-between">
<span>Ativas</span>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 h-full">
<div class="flex items-center justify-between mb-3">
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Ativas</div>
<Tag value="active" severity="success" rounded />
</div>
</template>
<template #content>
<div class="text-4xl font-semibold">{{ totalActive }}</div>
<small class="text-color-secondary">assinaturas em status <b>active</b></small>
</template>
</Card>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">assinaturas em status <b>active</b></div>
</div>
</div>
<div class="col-span-12 md:col-span-3">
<Card class="h-full">
<template #title>
<div class="flex items-center justify-between">
<span>Canceladas</span>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 h-full">
<div class="flex items-center justify-between mb-3">
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Canceladas</div>
<Tag value="canceled" severity="danger" rounded />
</div>
</template>
<template #content>
<div class="text-4xl font-semibold">{{ totalCanceled }}</div>
<small class="text-color-secondary">assinaturas em status <b>canceled</b></small>
</template>
</Card>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">assinaturas em status <b>canceled</b></div>
</div>
</div>
<div class="col-span-12 md:col-span-3">
<Card class="h-full">
<template #title>
<div class="flex items-center justify-between">
<span>{{ intervalView === 'year' ? 'ARR' : 'MRR' }}</span>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 h-full">
<div class="flex items-center justify-between mb-3">
<div class="text-[1rem] font-semibold text-[var(--text-color)]">{{ intervalView === 'year' ? 'ARR' : 'MRR' }}</div>
<Tag :value="intervalView === 'year' ? 'anual' : 'mensal'" severity="secondary" rounded />
</div>
</template>
<template #content>
<div class="text-3xl font-semibold">{{ moneyBRLFromCents(revenueCents) }}</div>
<small class="text-color-secondary">normalizado (mensal anual)</small>
</template>
</Card>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">normalizado (mensal anual)</div>
</div>
</div>
<div class="col-span-12 md:col-span-3">
<Card class="h-full">
<template #title>
<div class="flex items-center justify-between">
<span>ARPA</span>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 h-full">
<div class="flex items-center justify-between mb-3">
<div class="text-[1rem] font-semibold text-[var(--text-color)]">ARPA</div>
<Tag value="média" severity="secondary" rounded />
</div>
</template>
<template #content>
<div class="text-3xl font-semibold">{{ moneyBRLFromCents(arpaCents) }}</div>
<small class="text-color-secondary">média por assinatura ativa</small>
</template>
</Card>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">média por assinatura ativa</div>
</div>
</div>
</div>
<!-- Intenções + Health + Chart -->
<div class="grid grid-cols-12 gap-4 mt-4">
<div class="grid grid-cols-12 gap-4">
<!-- Intenções -->
<div class="col-span-12 md:col-span-4">
<Card class="h-full">
<template #title>
<div class="flex items-center justify-between">
<span>Intenções de assinatura</span>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 h-full">
<div class="flex items-center justify-between mb-3">
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Intenções de assinatura</div>
<Tag :value="intentsLoading ? 'carregando' : 'últimas'" severity="secondary" rounded />
</div>
</template>
<template #content>
<div class="grid grid-cols-12 gap-3">
<div class="col-span-4 rounded-xl border border-[var(--surface-border)] p-3">
<div class="text-xs text-color-secondary">Total</div>
<div class="col-span-4 rounded-md border border-[var(--surface-border)] p-3">
<div class="text-[1rem] text-[var(--text-color-secondary)]">Total</div>
<div class="text-2xl font-semibold">{{ totalIntents }}</div>
</div>
<div class="col-span-4 rounded-xl border border-[var(--surface-border)] p-3">
<div class="text-xs text-color-secondary">New</div>
<div class="col-span-4 rounded-md border border-[var(--surface-border)] p-3">
<div class="text-[1rem] text-[var(--text-color-secondary)]">New</div>
<div class="text-2xl font-semibold">{{ totalIntentsNew }}</div>
</div>
<div class="col-span-4 rounded-xl border border-[var(--surface-border)] p-3">
<div class="text-xs text-color-secondary">Paid</div>
<div class="col-span-4 rounded-md border border-[var(--surface-border)] p-3">
<div class="text-[1rem] text-[var(--text-color-secondary)]">Paid</div>
<div class="text-2xl font-semibold">{{ totalIntentsPaid }}</div>
</div>
</div>
<Divider class="my-3" />
<div v-if="intentsLoading" class="text-color-secondary text-sm">
<div v-if="intentsLoading" class="text-[1rem] text-[var(--text-color-secondary)]">
Carregando intenções
</div>
<div v-else>
<div v-if="!intents.length" class="text-color-secondary text-sm">
<div v-if="!intents.length" class="text-[1rem] text-[var(--text-color-secondary)]">
Nenhuma intenção encontrada.
</div>
@@ -594,17 +565,17 @@ onBeforeUnmount(() => {
<div
v-for="(it, idx) in intents"
:key="idx"
class="flex items-start justify-between gap-3 rounded-xl border border-[var(--surface-border)] p-3"
class="flex items-start justify-between gap-3 rounded-md border border-[var(--surface-border)] p-3"
>
<div class="min-w-0">
<div class="font-medium truncate">
{{ maskEmail(it.email) }}
</div>
<div class="text-xs text-color-secondary mt-1">
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">
{{ it.plan_key || '—' }} {{ intervalLabel(it.interval) }}
<span class="font-mono">{{ it.tenant_id ? String(it.tenant_id).slice(0, 8) + '…' : '—' }}</span>
</div>
<div class="text-xs text-color-secondary mt-1">
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">
{{ fmtDate(it.created_at) }}
</div>
</div>
@@ -636,33 +607,29 @@ onBeforeUnmount(() => {
/>
</div>
<div class="text-color-secondary text-xs mt-3">
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-3">
Mostrando {{ intentsLimit }} itens mais recentes de <span class="font-mono">subscription_intents</span>.
</div>
</div>
</template>
</Card>
</div>
</div>
<!-- Health -->
<div class="col-span-12 md:col-span-4">
<Card class="h-full">
<template #title>
<div class="flex items-center justify-between">
<span>Saúde do sistema</span>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 h-full">
<div class="flex items-center justify-between mb-3">
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Saúde do sistema</div>
<Tag :severity="healthSeverity" :value="healthLabel" rounded />
</div>
</template>
<template #content>
<div class="flex items-center justify-between gap-2">
<div class="text-4xl font-semibold">{{ totalMismatches }}</div>
<small class="text-color-secondary text-right">
<div class="text-[1rem] text-[var(--text-color-secondary)] text-right">
divergências entre plano (esperado) e entitlements (atual)
</small>
</div>
</div>
<div class="text-color-secondary text-sm mt-2">
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-2">
{{ healthHint }}
</div>
@@ -687,39 +654,34 @@ onBeforeUnmount(() => {
/>
</div>
<div class="text-color-secondary text-xs mt-3" v-if="lastUpdatedAt">
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-3" v-if="lastUpdatedAt">
Atualizado em {{ fmtDate(lastUpdatedAt) }}
</div>
</template>
</Card>
</div>
</div>
<!-- Chart -->
<div class="col-span-12 md:col-span-4">
<Card class="h-full">
<template #title>{{ intervalView === 'year' ? 'ARR por plano' : 'MRR por plano' }}</template>
<template #content>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 h-full">
<div class="text-[1rem] font-semibold text-[var(--text-color)] mb-3">{{ intervalView === 'year' ? 'ARR por plano' : 'MRR por plano' }}</div>
<div style="height: 260px;">
<Chart type="bar" :data="chartData" :options="chartOptions" />
</div>
</template>
</Card>
</div>
</div>
</div>
<!-- Breakdown table (com ações) -->
<div class="mt-4">
<Card>
<template #title>Distribuição por plano</template>
<template #content>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="text-[1rem] font-semibold text-[var(--text-color)] mb-3">Distribuição por plano</div>
<DataTable :value="breakdown" stripedRows responsiveLayout="scroll" emptyMessage="Sem dados para exibir.">
<Column field="plan_key" header="Plano" style="min-width: 14rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ data.plan_key }}</span>
<small class="text-color-secondary">
<div class="text-[1rem] text-[var(--text-color-secondary)]">
{{ data.plan_active ? 'ativo no catálogo' : 'inativo no catálogo' }}
</small>
</div>
</div>
</template>
</Column>
@@ -776,55 +738,10 @@ onBeforeUnmount(() => {
</Column>
</DataTable>
<div class="text-color-secondary text-sm mt-3">
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-3">
Nota: "Preço (ref.)" e "MRR/ARR" são normalizados usando o preço ativo.
Se existir anual, MRR = anual/12; se existir mensal, ARR = mensal*12.
</div>
</template>
</Card>
</div>
</div>
</template>
<style scoped>
/* Hero */
.dash-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1rem;
margin: 1rem;
padding: 1.25rem 1.5rem;
background: linear-gradient(135deg, var(--surface-card) 0%, var(--surface-section) 100%);
border: 1px solid var(--surface-border);
box-shadow: 0 2px 12px rgba(0, 0, 0, .08);
}
.dash-hero--stuck {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.dash-hero__blob {
position: absolute;
border-radius: 50%;
opacity: .15;
pointer-events: none;
}
.dash-hero__blob--1 {
width: 220px; height: 220px;
top: -60px; right: 80px;
background: radial-gradient(circle, #2dd4bf, transparent 70%);
}
.dash-hero__blob--2 {
width: 160px; height: 160px;
bottom: -40px; right: 260px;
background: radial-gradient(circle, #60a5fa, transparent 70%);
}
.dash-hero__icon {
width: 2.75rem; height: 2.75rem;
display: flex; align-items: center; justify-content: center;
border-radius: .75rem;
background: var(--primary-100, rgba(99,102,241,.1));
color: var(--primary-color, #6366f1);
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -125,41 +125,49 @@ function selecionarCat (cat) {
</script>
<template>
<div class="faq-page">
<!-- Sentinel -->
<div class="h-px" />
<!-- Cabeçalho -->
<div class="faq-header">
<div class="faq-header-inner">
<div class="flex items-center gap-3 mb-3">
<div class="faq-icon-wrap">
<i class="pi pi-comments text-xl" />
<!-- Hero sticky -->
<div
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
</div>
<div class="relative z-10 flex flex-col gap-3">
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] flex items-center justify-center flex-shrink-0">
<i class="pi pi-comments text-xl text-[var(--text-color)]" />
</div>
<div>
<h1 class="faq-title">Central de Ajuda</h1>
<p class="faq-subtitle">Encontre respostas para as dúvidas mais comuns</p>
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Central de Ajuda</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Encontre respostas para as dúvidas mais comuns</div>
</div>
</div>
<!-- Busca -->
<div class="faq-search-wrap">
<div>
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText
v-model="busca"
placeholder="Buscar pergunta…"
class="faq-search-input"
class="w-full"
/>
<InputIcon v-if="busca" class="pi pi-times cursor-pointer" @click="busca = ''" />
</IconField>
<div v-if="totalResultados !== null" class="faq-search-result">
<div v-if="totalResultados !== null" class="text-[1rem] text-[var(--text-color-secondary)] opacity-70 mt-1 ml-1">
{{ totalResultados }} resultado{{ totalResultados !== 1 ? 's' : '' }} encontrado{{ totalResultados !== 1 ? 's' : '' }}
</div>
</div>
</div>
</div>
<!-- Corpo -->
<div class="faq-body">
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-16">
@@ -167,42 +175,43 @@ function selecionarCat (cat) {
</div>
<template v-else>
<div class="flex gap-4 items-start flex-col sm:flex-row">
<!-- Sidebar de categorias -->
<aside v-if="categorias.length" class="faq-sidebar">
<div class="faq-sidebar-title">Categorias</div>
<aside v-if="categorias.length" class="w-full sm:w-48 flex-shrink-0 sticky top-[calc(var(--layout-sticky-top,56px)+8rem)] flex flex-col sm:flex-col flex-row flex-wrap gap-1">
<div class="text-[1rem] font-bold uppercase tracking-wider text-[var(--text-color-secondary)] opacity-60 px-2 mb-1 hidden sm:block">Categorias</div>
<button
class="faq-cat-btn"
:class="{ 'faq-cat-btn--active': !catAtiva }"
class="flex items-center w-full px-2.5 py-1.5 rounded-md text-[1rem] text-[var(--text-color-secondary)] bg-transparent border-none cursor-pointer text-left transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-color)]"
:class="{ 'bg-[color-mix(in_srgb,var(--primary-color)_10%,transparent)] text-[var(--primary-color)] font-semibold': !catAtiva }"
@click="selecionarCat(null)"
>
<i class="pi pi-th-large text-xs mr-2" />
<i class="pi pi-th-large mr-2 opacity-60" />
Todas
<span class="faq-cat-count">{{ faqItens.length }}</span>
<span class="ml-auto opacity-50 text-[1rem]">{{ faqItens.length }}</span>
</button>
<button
v-for="cat in categorias"
:key="cat"
class="faq-cat-btn"
:class="{ 'faq-cat-btn--active': catAtiva === cat }"
class="flex items-center w-full px-2.5 py-1.5 rounded-md text-[1rem] text-[var(--text-color-secondary)] bg-transparent border-none cursor-pointer text-left transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-color)]"
:class="{ 'bg-[color-mix(in_srgb,var(--primary-color)_10%,transparent)] text-[var(--primary-color)] font-semibold': catAtiva === cat }"
@click="selecionarCat(cat)"
>
<i class="pi pi-tag text-xs mr-2 opacity-60" />
<i class="pi pi-tag mr-2 opacity-60" />
{{ cat }}
<span class="faq-cat-count">
<span class="ml-auto opacity-50 text-[1rem]">
{{ faqItens.filter(f => docs.find(d => d.id === f.doc_id && d.categoria === cat)).length }}
</span>
</button>
</aside>
<!-- Conteúdo principal -->
<main class="faq-main">
<div class="flex-1 min-w-0 flex flex-col gap-4">
<!-- Sem resultados -->
<div v-if="docsComResultado.length === 0" class="faq-empty">
<div v-if="docsComResultado.length === 0" class="flex flex-col items-center py-12 text-center">
<i class="pi pi-search text-3xl opacity-20 mb-3" />
<p class="text-[var(--text-color-secondary)]">Nenhuma pergunta encontrada.</p>
<button v-if="busca || catAtiva" class="text-[var(--primary-color)] text-sm mt-2 underline" @click="busca = ''; catAtiva = null; abertos = {}">
<div class="text-[var(--text-color-secondary)] text-[1rem]">Nenhuma pergunta encontrada.</div>
<button v-if="busca || catAtiva" class="text-[var(--primary-color)] text-[1rem] mt-2 underline" @click="busca = ''; catAtiva = null; abertos = {}">
Limpar filtros
</button>
</div>
@@ -211,45 +220,48 @@ function selecionarCat (cat) {
<div
v-for="doc in docsComResultado"
:key="doc.id"
class="faq-group"
class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden"
>
<!-- Cabeçalho do grupo (doc) -->
<div class="faq-group-header">
<div class="faq-group-icon">
<i class="pi pi-file-edit text-sm" />
<div class="group flex items-center gap-3 px-5 py-3.5 border-b border-[var(--surface-border)] bg-[var(--surface-ground)]">
<div class="w-8 h-8 rounded-md bg-[color-mix(in_srgb,var(--primary-color)_10%,transparent)] text-[var(--primary-color)] flex items-center justify-center flex-shrink-0">
<i class="pi pi-file-edit" />
</div>
<div class="flex-1 min-w-0">
<h2 class="faq-group-title">{{ doc.titulo }}</h2>
<span v-if="doc.categoria" class="faq-group-cat">{{ doc.categoria }}</span>
<div class="text-[1rem] font-semibold text-[var(--text-color)] leading-tight">{{ doc.titulo }}</div>
<div v-if="doc.categoria" class="text-[1rem] text-[var(--text-color-secondary)] opacity-60 mt-px">{{ doc.categoria }}</div>
</div>
<button
class="edit-doc-btn"
class="flex items-center justify-center w-7 h-7 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] text-[var(--text-color-secondary)] cursor-pointer flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-[var(--surface-hover)] hover:text-[var(--primary-color)]"
v-tooltip.top="'Editar documento'"
@click="editarDoc(doc.id)"
>
<i class="pi pi-pencil text-xs" />
<i class="pi pi-pencil" />
</button>
</div>
<!-- Itens FAQ do grupo -->
<div class="faq-items">
<div class="flex flex-col">
<div
v-for="item in itensDo(doc.id)"
:key="item.id"
class="faq-item"
:class="{ 'faq-item--open': abertos[item.id] }"
class="border-b border-[var(--surface-border)] last:border-b-0 transition-colors"
:class="abertos[item.id] ? 'bg-[color-mix(in_srgb,var(--primary-color)_3%,transparent)]' : ''"
>
<button class="faq-pergunta" @click="toggle(item.id)">
<span class="faq-pergunta-text">{{ item.pergunta }}</span>
<button
class="w-full flex items-center justify-between gap-4 px-5 py-3.5 bg-transparent border-none cursor-pointer text-left transition-colors hover:bg-[var(--surface-hover)]"
@click="toggle(item.id)"
>
<span class="text-[1rem] font-medium text-[var(--text-color)] leading-snug">{{ item.pergunta }}</span>
<i
class="pi shrink-0 text-sm opacity-40 transition-transform duration-200"
class="pi shrink-0 opacity-40 transition-transform duration-200"
:class="abertos[item.id] ? 'pi-chevron-up' : 'pi-chevron-down'"
/>
</button>
<Transition name="faq-expand">
<div
v-if="abertos[item.id] && item.resposta"
class="faq-resposta ql-content"
class="px-5 pb-4 text-[1rem] text-[var(--text-color-secondary)] leading-relaxed break-words ql-content"
v-html="item.resposta"
/>
</Transition>
@@ -257,270 +269,23 @@ function selecionarCat (cat) {
</div>
</div>
</main>
</template>
</div>
</div>
</template>
</div>
</template>
<style scoped>
/* ── Layout ──────────────────────────────────────────────────── */
.faq-page {
display: flex;
flex-direction: column;
min-height: 100%;
}
/* ── Header ─────────────────────────────────────────────────── */
.faq-header {
background: var(--surface-card);
border-bottom: 1px solid var(--surface-border);
padding: 2rem 1.5rem 1.5rem;
}
.faq-header-inner {
max-width: 720px;
margin: 0 auto;
}
.faq-icon-wrap {
width: 48px;
height: 48px;
border-radius: 14px;
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
color: var(--primary-color);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.faq-title {
font-size: 1.35rem;
font-weight: 700;
color: var(--text-color);
margin: 0;
line-height: 1.2;
}
.faq-subtitle {
font-size: 0.875rem;
color: var(--text-color-secondary);
margin: 2px 0 0;
}
.faq-search-wrap {
position: relative;
}
.faq-search-input {
width: 100%;
border-radius: 0.75rem !important;
font-size: 0.9rem;
}
.faq-search-result {
font-size: 0.75rem;
color: var(--text-color-secondary);
opacity: 0.7;
margin-top: 0.375rem;
margin-left: 0.25rem;
}
/* ── Corpo ──────────────────────────────────────────────────── */
.faq-body {
display: flex;
gap: 1.5rem;
padding: 1.5rem;
flex: 1;
max-width: 1100px;
margin: 0 auto;
width: 100%;
align-items: flex-start;
}
/* ── Sidebar ─────────────────────────────────────────────────── */
.faq-sidebar {
width: 200px;
flex-shrink: 0;
position: sticky;
top: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.faq-sidebar-title {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--text-color-secondary);
opacity: 0.6;
padding: 0 0.5rem;
margin-bottom: 0.25rem;
}
.faq-cat-btn {
display: flex;
align-items: center;
width: 100%;
padding: 0.45rem 0.625rem;
border-radius: 0.5rem;
font-size: 0.82rem;
color: var(--text-color-secondary);
background: transparent;
border: none;
cursor: pointer;
text-align: left;
transition: background 0.15s, color 0.15s;
}
.faq-cat-btn:hover {
background: var(--surface-hover);
color: var(--text-color);
}
.faq-cat-btn--active {
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
color: var(--primary-color);
font-weight: 600;
}
.faq-cat-count {
margin-left: auto;
font-size: 0.7rem;
opacity: 0.5;
font-weight: 500;
}
/* ── Main ─────────────────────────────────────────────────────── */
.faq-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.faq-empty {
display: flex;
flex-direction: column;
align-items: center;
padding: 3rem 1rem;
text-align: center;
}
/* ── Grupo (doc) ─────────────────────────────────────────────── */
.faq-group {
border: 1px solid var(--surface-border);
border-radius: 1rem;
overflow: hidden;
background: var(--surface-card);
}
.faq-group-header {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1.25rem;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-ground);
}
.faq-group-icon {
width: 32px;
height: 32px;
border-radius: 8px;
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
color: var(--primary-color);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.faq-group-title {
font-size: 0.9rem;
font-weight: 600;
color: var(--text-color);
margin: 0;
line-height: 1.3;
}
.faq-group-cat {
font-size: 0.7rem;
color: var(--text-color-secondary);
opacity: 0.6;
display: block;
margin-top: 1px;
}
.edit-doc-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 6px;
border: 1px solid var(--surface-border);
background: var(--surface-card);
color: var(--text-color-secondary);
cursor: pointer;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s, background 0.15s, color 0.15s;
}
.faq-group-header:hover .edit-doc-btn {
opacity: 1;
}
.edit-doc-btn:hover {
background: var(--surface-hover);
color: var(--primary-color);
border-color: color-mix(in srgb, var(--primary-color) 30%, transparent);
}
/* ── Itens FAQ ───────────────────────────────────────────────── */
.faq-items {
display: flex;
flex-direction: column;
}
.faq-item {
border-bottom: 1px solid var(--surface-border);
transition: background 0.15s;
}
.faq-item:last-child { border-bottom: none; }
.faq-item--open { background: color-mix(in srgb, var(--primary-color) 3%, transparent); }
.faq-pergunta {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.875rem 1.25rem;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
transition: background 0.15s;
}
.faq-pergunta:hover { background: var(--surface-hover); }
.faq-pergunta-text {
font-size: 0.9rem;
font-weight: 500;
color: var(--text-color);
line-height: 1.4;
}
.faq-resposta {
padding: 0 1.25rem 1rem;
font-size: 0.875rem;
color: var(--text-color-secondary);
line-height: 1.65;
word-break: break-word;
}
/* Quill content */
.faq-resposta.ql-content :deep(p) { margin: 0 0 0.5rem; }
.faq-resposta.ql-content :deep(p:last-child) { margin-bottom: 0; }
.faq-resposta.ql-content :deep(strong) { font-weight: 600; color: var(--text-color); }
.faq-resposta.ql-content :deep(em) { font-style: italic; }
.faq-resposta.ql-content :deep(ul),
.faq-resposta.ql-content :deep(ol) { padding-left: 1.25rem; margin: 0.4rem 0; }
.faq-resposta.ql-content :deep(li) { margin-bottom: 0.2rem; }
.faq-resposta.ql-content :deep(a) { color: var(--primary-color); text-decoration: underline; }
.faq-resposta.ql-content :deep(blockquote) {
.ql-content :deep(p) { margin: 0 0 0.5rem; }
.ql-content :deep(p:last-child) { margin-bottom: 0; }
.ql-content :deep(strong) { font-weight: 600; color: var(--text-color); }
.ql-content :deep(em) { font-style: italic; }
.ql-content :deep(ul),
.ql-content :deep(ol) { padding-left: 1.25rem; margin: 0.4rem 0; }
.ql-content :deep(li) { margin-bottom: 0.2rem; }
.ql-content :deep(a) { color: var(--primary-color); text-decoration: underline; }
.ql-content :deep(blockquote) {
border-left: 3px solid var(--surface-border);
margin: 0.5rem 0;
padding: 0.25rem 0.75rem;
@@ -539,12 +304,4 @@ function selecionarCat (cat) {
opacity: 0;
max-height: 0;
}
/* ── Responsivo ─────────────────────────────────────────────── */
@media (max-width: 640px) {
.faq-body { flex-direction: column; padding: 1rem; }
.faq-sidebar { width: 100%; position: static; flex-direction: row; flex-wrap: wrap; gap: 0.375rem; }
.faq-sidebar-title { display: none; }
.faq-cat-btn { width: auto; padding: 0.3rem 0.625rem; font-size: 0.75rem; }
}
</style>

View File

@@ -233,40 +233,34 @@ onBeforeUnmount(() => {
<Toast />
<ConfirmDialog />
<div class="features-root">
<!-- Sentinel -->
<div ref="heroSentinelRef" class="h-px" />
<!-- Info decorativa (scrolls away naturalmente) -->
<div class="flex items-start gap-4 px-4 pb-3">
<div class="features-hero__icon-wrap">
<i class="pi pi-bolt features-hero__icon" />
</div>
<div class="features-hero__sub">
Cadastre os recursos (features) que os planos podem habilitar.
A <b>key</b> é o identificador técnico; o <b>nome</b> é exibido para o usuário.
</div>
<!-- Hero sticky -->
<div
ref="heroEl"
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-fuchsia-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-indigo-400/10" />
</div>
<!-- HERO -->
<div ref="heroSentinelRef" class="features-hero-sentinel" />
<div ref="heroEl" class="features-hero mb-4" :class="{ 'features-hero--stuck': heroStuck }">
<div class="features-hero__blobs" aria-hidden="true">
<div class="features-hero__blob features-hero__blob--1" />
<div class="features-hero__blob features-hero__blob--2" />
</div>
<div class="features-hero__inner">
<div class="features-hero__info min-w-0">
<div class="features-hero__title">Recursos do Sistema</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Recursos do Sistema</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Cadastre os recursos (features) que os planos podem habilitar.</div>
</div>
<!-- Ações desktop ( 1200px) -->
<div class="features-hero__actions features-hero__actions--desktop">
<div class="hidden xl:flex items-center gap-2 flex-wrap">
<Button label="Atualizar" icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loading" :disabled="saving" @click="fetchAll" />
<Button label="Adicionar recurso" icon="pi pi-plus" size="small" :disabled="saving" @click="openCreate" />
</div>
<!-- Ações mobile (< 1200px) -->
<div class="features-hero__actions--mobile">
<div class="flex xl:hidden">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
@@ -281,8 +275,11 @@ onBeforeUnmount(() => {
</div>
</div>
<!-- Search sempre visível, fora do hero sticky -->
<div class="px-4 mb-4">
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Search -->
<div>
<FloatLabel variant="on" class="w-full md:w-[380px]">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
@@ -292,7 +289,6 @@ onBeforeUnmount(() => {
</FloatLabel>
</div>
<div class="px-4 pb-4">
<DataTable :value="filteredRows" dataKey="id" :loading="loading" stripedRows responsiveLayout="scroll">
<Column header="Domínio" style="width: 9rem">
<template #body="{ data }">
@@ -303,8 +299,8 @@ onBeforeUnmount(() => {
<Column field="key" header="Key" sortable style="min-width: 18rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium font-mono text-sm">{{ data.key }}</span>
<small class="text-color-secondary">ID: {{ data.id }}</small>
<span class="font-medium font-mono text-[1rem]">{{ data.key }}</span>
<div class="text-[1rem] text-[var(--text-color-secondary)]">ID: {{ data.id }}</div>
</div>
</template>
</Column>
@@ -317,7 +313,7 @@ onBeforeUnmount(() => {
<Column field="descricao" header="Descrição" sortable style="min-width: 22rem">
<template #body="{ data }">
<div class="max-w-[600px] whitespace-nowrap overflow-hidden text-ellipsis text-color-secondary" :title="data.descricao || ''">
<div class="max-w-[600px] whitespace-nowrap overflow-hidden text-ellipsis text-[var(--text-color-secondary)]" :title="data.descricao || ''">
{{ data.descricao || '—' }}
</div>
</template>
@@ -365,10 +361,10 @@ onBeforeUnmount(() => {
</IconField>
<label for="cr-key">Key *</label>
</FloatLabel>
<small class="text-color-secondary block mt-1">
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">
Ex.: <span class="font-mono">agenda.view</span> ou <span class="font-mono">online_scheduling.manage</span>.
Espaços e acentos são normalizados automaticamente.
</small>
</div>
</div>
<!-- Nome -->
@@ -388,9 +384,9 @@ onBeforeUnmount(() => {
</IconField>
<label for="cr-name">Nome *</label>
</FloatLabel>
<small class="text-color-secondary block mt-1">
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">
Nome exibido para o usuário na página de upgrade e nas listagens.
</small>
</div>
</div>
<!-- Descrição PT-BR -->
@@ -406,9 +402,9 @@ onBeforeUnmount(() => {
/>
<label for="cr-desc-pt">Descrição</label>
</FloatLabel>
<small class="text-color-secondary block mt-1">
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">
Explique o que o recurso habilita e para quem se aplica.
</small>
</div>
</div>
</div>
@@ -417,68 +413,4 @@ onBeforeUnmount(() => {
<Button :label="isEdit ? 'Salvar' : 'Criar'" icon="pi pi-check" :loading="saving" @click="save" />
</template>
</Dialog>
</div>
</template>
<style scoped>
.features-root { padding: 1rem; }
@media (min-width: 768px) { .features-root { padding: 1.5rem; } }
.features-hero-sentinel { height: 1px; }
.features-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.5rem;
}
.features-hero--stuck {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.features-hero__blobs {
position: absolute; inset: 0; pointer-events: none; overflow: hidden;
}
.features-hero__blob {
position: absolute; border-radius: 50%; filter: blur(70px);
}
.features-hero__blob--1 { width: 20rem; height: 20rem; top: -5rem; right: -4rem; background: rgba(217,70,239,0.10); }
.features-hero__blob--2 { width: 18rem; height: 18rem; top: 1rem; left: -5rem; background: rgba(99,102,241,0.10); }
.features-hero__inner {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1.25rem; flex-wrap: wrap;
}
.features-hero__icon-wrap {
flex-shrink: 0;
width: 4rem; height: 4rem; border-radius: 1.125rem;
border: 2px solid var(--surface-border);
background: var(--surface-ground);
display: grid; place-items: center;
}
.features-hero__icon { font-size: 1.5rem; color: var(--text-color); }
.features-hero__info { flex: 1; min-width: 0; }
.features-hero__title {
font-size: 1.25rem; font-weight: 700; letter-spacing: -0.025em;
color: var(--text-color); line-height: 1.2;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.features-hero__sub {
font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 4px; line-height: 1.5;
}
.features-hero__actions--desktop {
display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
}
.features-hero__actions--mobile { display: none; }
@media (max-width: 1199px) {
.features-hero__actions--desktop { display: none; }
.features-hero__actions--mobile { display: flex; }
}
</style>

View File

@@ -177,46 +177,59 @@ async function excluir (id) {
<template>
<Toast />
<div class="flex flex-col gap-4 p-4">
<!-- Sentinel -->
<div class="h-px" />
<!-- Header -->
<div class="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] px-5 py-4">
<!-- Hero sticky -->
<div
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-amber-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
</div>
<div class="relative z-10 flex flex-wrap items-center justify-between gap-3">
<div>
<div class="font-bold text-lg flex items-center gap-2">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)] flex items-center gap-2">
<i class="pi pi-star text-amber-500" />
Feriados Municipais
</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">
Feriados cadastrados pelos tenants alimentam o banco central de feriados do SAAS.
</div>
</div>
<div class="flex items-center gap-2">
<Button icon="pi pi-chevron-left" text rounded severity="secondary" @click="anoAnterior" />
<span class="font-bold text-lg w-14 text-center">{{ ano }}</span>
<span class="font-bold text-[1rem] w-14 text-center">{{ ano }}</span>
<Button icon="pi pi-chevron-right" text rounded severity="secondary" @click="anoProximo" />
<Button icon="pi pi-refresh" severity="secondary" outlined rounded :loading="loading" @click="load" />
<Button icon="pi pi-plus" label="Cadastrar feriado" class="rounded-full" @click="abrirDialog" />
<Button icon="pi pi-plus" label="Cadastrar feriado" @click="abrirDialog" />
</div>
</div>
</div>
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Stats -->
<div class="grid grid-cols-3 gap-3">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 text-center">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 text-center">
<div class="text-2xl font-bold text-amber-500">{{ totalFeriados }}</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Total de feriados</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">Total de feriados</div>
</div>
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 text-center">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 text-center">
<div class="text-2xl font-bold text-blue-500">{{ totalTenants }}</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Tenants contribuintes</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">Tenants contribuintes</div>
</div>
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 text-center">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 text-center">
<div class="text-2xl font-bold text-green-500">{{ totalMunicipios }}</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Municípios</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">Municípios</div>
</div>
</div>
<!-- Filtros -->
<div class="flex flex-wrap gap-3 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] px-4 py-3">
<div class="flex flex-wrap gap-3 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] px-4 py-3">
<div class="flex-1 min-w-[160px]">
<IconField>
<InputIcon class="pi pi-search" />
@@ -247,34 +260,42 @@ async function excluir (id) {
<template v-else>
<div v-if="!agrupados.length" class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-8 text-center text-[var(--text-color-secondary)]">
<div v-if="!agrupados.length" class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-8 text-center text-[var(--text-color-secondary)]">
Nenhum feriado municipal cadastrado para {{ ano }}.
</div>
<!-- Lista agrupada por data -->
<div v-for="[data, lista] in agrupados" :key="data" class="blk-group">
<div class="blk-group__head">
<span class="font-mono text-sm">{{ fmtDate(data) }}</span>
<span class="blk-group__count">{{ lista.length }}</span>
<div
v-for="[data, lista] in agrupados"
:key="data"
class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden"
>
<div class="flex items-center gap-2 px-5 py-3 border-b border-[var(--surface-border)] font-semibold bg-[var(--surface-ground)]">
<span class="font-mono text-[1rem]">{{ fmtDate(data) }}</span>
<span class="text-[1rem] bg-[var(--surface-card)] border border-[var(--surface-border)] rounded-full px-2 py-px text-[var(--text-color-secondary)]">{{ lista.length }}</span>
</div>
<div class="blk-list">
<div v-for="f in lista" :key="f.id" class="blk-item">
<div class="blk-item__name">{{ f.nome }}</div>
<div class="flex flex-col">
<div
v-for="f in lista"
:key="f.id"
class="flex items-center gap-3 px-5 py-2.5 border-b border-[var(--surface-border)] last:border-b-0 flex-wrap hover:bg-[var(--surface-hover)]"
>
<div class="font-medium text-[1rem] flex-1 min-w-[180px]">{{ f.nome }}</div>
<div class="flex items-center gap-2 flex-wrap">
<Tag v-if="f.cidade" :value="f.cidade" severity="secondary" class="text-xs" />
<Tag v-if="f.estado" :value="f.estado" severity="info" class="text-xs" />
<Tag v-if="f.bloqueia_sessoes" value="Bloqueia" severity="danger" class="text-xs" />
<Tag v-if="f.cidade" :value="f.cidade" severity="secondary" />
<Tag v-if="f.estado" :value="f.estado" severity="info" />
<Tag v-if="f.bloqueia_sessoes" value="Bloqueia" severity="danger" />
</div>
<div v-if="f.tenants?.name" class="blk-item__tenant">
<i class="pi pi-building text-xs" /> {{ f.tenants.name }}
<div v-if="f.tenants?.name" class="text-[1rem] text-[var(--text-color-secondary)] w-full flex items-center gap-1">
<i class="pi pi-building" /> {{ f.tenants.name }}
</div>
<div v-if="f.observacao" class="blk-item__obs">{{ f.observacao }}</div>
<div v-if="f.observacao" class="text-[1rem] text-[var(--text-color-secondary)] w-full italic">{{ f.observacao }}</div>
<div class="blk-item__actions">
<div class="ml-auto">
<Button icon="pi pi-trash" text rounded size="small" severity="danger" @click="excluir(f.id)" />
</div>
</div>
@@ -295,12 +316,12 @@ async function excluir (id) {
<div class="flex flex-col gap-4 pt-1">
<div>
<label class="dlg-label">Nome do feriado *</label>
<label class="text-[1rem] text-[var(--text-color-secondary)] font-medium">Nome do feriado *</label>
<InputText v-model="form.nome" class="w-full mt-1" placeholder="Ex.: Padroeiro Municipal, Aniversário da cidade…" />
</div>
<div>
<label class="dlg-label">Data *</label>
<label class="text-[1rem] text-[var(--text-color-secondary)] font-medium">Data *</label>
<DatePicker
v-model="form.data"
showIcon fluid iconDisplay="input"
@@ -314,17 +335,17 @@ async function excluir (id) {
<div class="flex gap-3">
<div class="flex-1">
<label class="dlg-label">Cidade</label>
<label class="text-[1rem] text-[var(--text-color-secondary)] font-medium">Cidade</label>
<InputText v-model="form.cidade" class="w-full mt-1" placeholder="Ex.: São Paulo" />
</div>
<div class="w-24">
<label class="dlg-label">Estado (UF)</label>
<label class="text-[1rem] text-[var(--text-color-secondary)] font-medium">Estado (UF)</label>
<InputText v-model="form.estado" class="w-full mt-1" placeholder="SP" maxlength="2" />
</div>
</div>
<div>
<label class="dlg-label">Vincular a um tenant <span class="opacity-60">(opcional)</span></label>
<label class="text-[1rem] text-[var(--text-color-secondary)] font-medium">Vincular a um tenant <span class="opacity-60">(opcional)</span></label>
<Select
v-model="form.tenant_id"
:options="tenantOptions"
@@ -336,13 +357,13 @@ async function excluir (id) {
</div>
<div>
<label class="dlg-label">Observação <span class="opacity-60">(opcional)</span></label>
<label class="text-[1rem] text-[var(--text-color-secondary)] font-medium">Observação <span class="opacity-60">(opcional)</span></label>
<Textarea v-model="form.observacao" class="w-full mt-1" rows="2" autoResize placeholder="Nota interna…" />
</div>
<div class="flex items-center gap-2">
<Checkbox v-model="form.bloqueia_sessoes" :binary="true" inputId="bloqueia" />
<label for="bloqueia" class="text-sm cursor-pointer">Bloqueia sessões neste dia</label>
<label for="bloqueia" class="text-[1rem] cursor-pointer">Bloqueia sessões neste dia</label>
</div>
</div>
@@ -359,67 +380,3 @@ async function excluir (id) {
</template>
</Dialog>
</template>
<style scoped>
.blk-group {
border-radius: 1.25rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
overflow: hidden;
}
.blk-group__head {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
border-bottom: 1px solid var(--surface-border);
font-weight: 600;
background: var(--surface-ground);
}
.blk-group__count {
font-size: 0.75rem;
background: var(--surface-card);
border: 1px solid var(--surface-border);
border-radius: 999px;
padding: 1px 8px;
color: var(--text-color-secondary);
}
.blk-list { display: flex; flex-direction: column; }
.blk-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.625rem 1.25rem;
border-bottom: 1px solid var(--surface-border);
flex-wrap: wrap;
}
.blk-item:last-child { border-bottom: none; }
.blk-item:hover { background: var(--surface-hover); }
.blk-item__name {
font-weight: 500;
font-size: 0.875rem;
flex: 1;
min-width: 180px;
}
.blk-item__tenant {
font-size: 0.75rem;
color: var(--text-color-secondary);
width: 100%;
display: flex;
align-items: center;
gap: 0.25rem;
}
.blk-item__obs {
font-size: 0.75rem;
color: var(--text-color-secondary);
width: 100%;
font-style: italic;
}
.blk-item__actions { margin-left: auto; }
.dlg-label {
font-size: 0.75rem;
color: var(--text-color-secondary);
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,564 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import Editor from 'primevue/editor'
const toast = useToast()
const confirm = useConfirm()
// ─── Estado ───────────────────────────────────────────────────────────────────
const slides = ref([])
const loading = ref(false)
const saving = ref(false)
const previewIdx = ref(0)
const dialogOpen = ref(false)
const editingSlide = ref(null) // null = novo
const form = ref({ title: '', body: '', icon: '', ordem: 0, ativo: true })
// ─── Ícones disponíveis (subset PrimeIcons relevantes) ────────────────────────
const ICONS = [
{ value: 'pi-calendar-clock', label: 'Agenda' },
{ value: 'pi-users', label: 'Equipe' },
{ value: 'pi-globe', label: 'Online' },
{ value: 'pi-shield', label: 'Segurança' },
{ value: 'pi-heart-fill', label: 'Saúde' },
{ value: 'pi-chart-line', label: 'Estatísticas' },
{ value: 'pi-bell', label: 'Notificações' },
{ value: 'pi-lock', label: 'Privacidade' },
{ value: 'pi-mobile', label: 'Mobile' },
{ value: 'pi-sync', label: 'Sincronização' },
{ value: 'pi-star', label: 'Destaque' },
{ value: 'pi-check-circle', label: 'Aprovação' },
{ value: 'pi-comments', label: 'Comunicação' },
{ value: 'pi-file-edit', label: 'Prontuário' },
{ value: 'pi-briefcase', label: 'Profissional' },
{ value: 'pi-bolt', label: 'Performance' },
]
// ─── Computed ─────────────────────────────────────────────────────────────────
const slidesAtivos = computed(() => slides.value.filter(s => s.ativo).sort((a, b) => a.ordem - b.ordem))
const previewSlide = computed(() => slidesAtivos.value[previewIdx.value] ?? slidesAtivos.value[0] ?? null)
// ─── Supabase ─────────────────────────────────────────────────────────────────
async function load () {
loading.value = true
try {
const { data, error } = await supabase
.from('login_carousel_slides')
.select('*')
.order('ordem', { ascending: true })
if (error) throw error
slides.value = data || []
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar slides.', life: 4000 })
} finally {
loading.value = false
}
}
function stripHtml (s) {
return String(s || '').replace(/<[^>]+>/g, '').trim()
}
async function saveSlide () {
if (!stripHtml(form.value.title) || !stripHtml(form.value.body)) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Título e conteúdo são obrigatórios.', life: 3000 })
return
}
saving.value = true
try {
const payload = {
title: form.value.title,
body: form.value.body,
icon: form.value.icon || 'pi-star',
ordem: form.value.ordem,
ativo: form.value.ativo,
}
if (editingSlide.value) {
const { error } = await supabase
.from('login_carousel_slides')
.update(payload)
.eq('id', editingSlide.value.id)
if (error) throw error
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Slide atualizado com sucesso.', life: 3000 })
} else {
const maxOrdem = slides.value.length ? Math.max(...slides.value.map(s => s.ordem)) + 1 : 0
payload.ordem = maxOrdem
const { error } = await supabase
.from('login_carousel_slides')
.insert(payload)
if (error) throw error
toast.add({ severity: 'success', summary: 'Criado', detail: 'Slide adicionado com sucesso.', life: 3000 })
}
dialogOpen.value = false
await load()
previewIdx.value = 0
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao salvar.', life: 4000 })
} finally {
saving.value = false
}
}
async function toggleAtivo (slide) {
try {
const { error } = await supabase
.from('login_carousel_slides')
.update({ ativo: !slide.ativo })
.eq('id', slide.id)
if (error) throw error
slide.ativo = !slide.ativo
toast.add({ severity: 'info', summary: slide.ativo ? 'Ativado' : 'Desativado', detail: `"${slide.title}"`, life: 2500 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3000 })
}
}
async function deleteSlide (slide) {
confirm.require({
message: `Remover o slide "${slide.title}"? Esta ação não pode ser desfeita.`,
header: 'Confirmar remoção',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
acceptLabel: 'Remover',
rejectLabel: 'Cancelar',
accept: async () => {
try {
const { error } = await supabase.from('login_carousel_slides').delete().eq('id', slide.id)
if (error) throw error
toast.add({ severity: 'success', summary: 'Removido', detail: `Slide removido.`, life: 2500 })
await load()
previewIdx.value = 0
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3000 })
}
},
})
}
async function moveSlide (slide, dir) {
const sorted = [...slides.value].sort((a, b) => a.ordem - b.ordem)
const idx = sorted.findIndex(s => s.id === slide.id)
const swapIdx = idx + dir
if (swapIdx < 0 || swapIdx >= sorted.length) return
const a = sorted[idx]
const b = sorted[swapIdx]
const tempOrdem = a.ordem
try {
await supabase.from('login_carousel_slides').update({ ordem: b.ordem }).eq('id', a.id)
await supabase.from('login_carousel_slides').update({ ordem: tempOrdem }).eq('id', b.id)
await load()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3000 })
}
}
// ─── Dialog helpers ───────────────────────────────────────────────────────────
function openNew () {
editingSlide.value = null
form.value = { title: '', body: '', icon: 'pi-calendar-clock', ordem: 0, ativo: true }
dialogOpen.value = true
}
function openEdit (slide) {
editingSlide.value = slide
form.value = { title: slide.title, body: slide.body, icon: slide.icon || 'pi-star', ordem: slide.ordem, ativo: slide.ativo }
dialogOpen.value = true
}
onMounted(load)
</script>
<template>
<Toast />
<ConfirmDialog />
<!-- Sentinel -->
<div class="h-px" />
<!-- Hero sticky -->
<div
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-72 h-72 -bottom-20 right-24 bg-fuchsia-400/10" />
</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)] flex items-center gap-2">
<i class="pi pi-images text-indigo-500" />
Carrossel do Login
</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">
Gerencie os slides exibidos na tela de login do sistema
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<Button
icon="pi pi-refresh"
severity="secondary"
outlined
title="Recarregar"
:loading="loading"
@click="load"
/>
<Button
icon="pi pi-plus"
label="Novo slide"
@click="openNew"
/>
</div>
</div>
</div>
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<div class="grid grid-cols-1 xl:grid-cols-[1fr_340px] gap-6 items-start">
<!-- Tabela de slides -->
<div class="bg-[var(--surface-card,#fff)] rounded-md border border-[var(--surface-border)] overflow-hidden">
<!-- Loading skeleton -->
<div v-if="loading" class="flex flex-col divide-y divide-[var(--surface-border)]">
<div v-for="i in 4" :key="i" class="flex items-center gap-4 px-5 py-4 animate-pulse">
<div class="w-10 h-10 rounded-md bg-[var(--surface-ground)] flex-shrink-0" />
<div class="flex-1 space-y-2">
<div class="h-3.5 w-40 rounded bg-[var(--surface-ground)]" />
<div class="h-3 w-64 rounded bg-[var(--surface-ground)]" />
</div>
</div>
</div>
<!-- Lista vazia -->
<div v-else-if="!slides.length" class="flex flex-col items-center justify-center py-16 gap-3 text-[var(--text-color-secondary)]">
<i class="pi pi-images text-4xl opacity-30" />
<span class="text-[1rem]">Nenhum slide cadastrado ainda.</span>
<Button label="Criar primeiro slide" size="small" @click="openNew" />
</div>
<!-- Rows -->
<div v-else class="divide-y divide-[var(--surface-border)]">
<!-- Header -->
<div class="grid grid-cols-[auto_1fr_auto_auto] items-center gap-4 px-5 py-2.5 bg-[var(--surface-ground)] text-xs font-bold uppercase tracking-widest text-[var(--text-color-secondary)]">
<span class="w-10" />
<span>Slide</span>
<span class="text-center w-[60px]">Status</span>
<span class="w-[96px]" />
</div>
<div
v-for="(slide, i) in [...slides].sort((a,b) => a.ordem - b.ordem)"
:key="slide.id"
class="grid grid-cols-[auto_1fr_auto_auto] items-center gap-4 px-5 py-3.5 transition-colors duration-100 hover:bg-[var(--surface-ground,#f8fafc)] group"
>
<!-- Ícone + ordem -->
<div class="relative flex-shrink-0">
<div
class="w-10 h-10 rounded-md flex items-center justify-center text-lg"
:class="slide.ativo ? 'bg-indigo-500/10 text-indigo-500' : 'bg-[var(--surface-ground)] text-[var(--text-color-secondary)]'"
>
<i :class="['pi', slide.icon || 'pi-star']" />
</div>
<span class="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-[var(--surface-border)] text-[0.58rem] font-bold flex items-center justify-center text-[var(--text-color-secondary)]">
{{ slide.ordem + 1 }}
</span>
</div>
<!-- Conteúdo -->
<div class="min-w-0">
<div class="text-[1rem] font-semibold text-[var(--text-color)] truncate [&_*]:inline" :class="!slide.ativo && 'opacity-40 line-through'" v-html="slide.title" />
<div class="text-[1rem] text-[var(--text-color-secondary)] truncate mt-0.5 [&_*]:inline" :class="!slide.ativo && 'opacity-40'" v-html="slide.body" />
</div>
<!-- Toggle ativo -->
<div class="flex justify-center w-[60px]">
<InputSwitch
:modelValue="slide.ativo"
@update:modelValue="() => toggleAtivo(slide)"
/>
</div>
<!-- Ações -->
<div class="flex items-center gap-1 w-[96px] justify-end opacity-0 group-hover:opacity-100 transition-opacity duration-150">
<button
class="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-color-secondary)] hover:bg-[var(--surface-border)] hover:text-[var(--text-color)] transition-colors duration-100 disabled:opacity-30"
:disabled="i === 0"
title="Mover para cima"
@click="moveSlide(slide, -1)"
>
<i class="pi pi-chevron-up text-xs" />
</button>
<button
class="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-color-secondary)] hover:bg-[var(--surface-border)] hover:text-[var(--text-color)] transition-colors duration-100 disabled:opacity-30"
:disabled="i === slides.length - 1"
title="Mover para baixo"
@click="moveSlide(slide, 1)"
>
<i class="pi pi-chevron-down text-xs" />
</button>
<button
class="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-color-secondary)] hover:bg-indigo-50 hover:text-indigo-600 transition-colors duration-100"
title="Editar"
@click="openEdit(slide)"
>
<i class="pi pi-pencil text-xs" />
</button>
<button
class="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-color-secondary)] hover:bg-red-50 hover:text-red-500 transition-colors duration-100"
title="Remover"
@click="deleteSlide(slide)"
>
<i class="pi pi-trash text-xs" />
</button>
</div>
</div>
</div>
</div>
<!-- Preview -->
<div class="sticky top-6 flex flex-col gap-3">
<div class="text-xs font-bold uppercase tracking-widest text-[var(--text-color-secondary)] flex items-center gap-1.5 px-1">
<i class="pi pi-eye" /> Pré-visualização
</div>
<!-- Mock da tela de login lado esquerdo -->
<div class="relative overflow-hidden rounded-md aspect-[9/16] max-h-[480px] w-full select-none shadow-xl">
<div class="absolute inset-0 bg-gradient-to-br from-indigo-600 via-violet-600 to-purple-700" />
<!-- Grade decorativa -->
<div
class="absolute inset-0 opacity-[0.08]"
style="background-image: linear-gradient(to right, white 1px, transparent 1px), linear-gradient(to bottom, white 1px, transparent 1px); background-size: 32px 32px;"
/>
<!-- Orbs -->
<div class="absolute -top-20 -left-20 h-64 w-64 rounded-full bg-white/10 blur-3xl pointer-events-none" />
<div class="absolute bottom-0 right-0 h-48 w-48 rounded-full bg-violet-300/20 blur-3xl pointer-events-none" />
<div class="relative z-10 flex flex-col h-full p-6">
<!-- Brand mock -->
<div class="flex items-center gap-2">
<div class="grid h-7 w-7 place-items-center rounded-lg bg-white/20 border border-white/20">
<i class="pi pi-heart-fill text-white text-[0.6rem]" />
</div>
<span class="text-white/90 font-bold text-xs tracking-tight">Agência PSI</span>
</div>
<!-- Slide content -->
<div class="flex-1 flex flex-col justify-center gap-4">
<Transition name="prev-fade" mode="out-in">
<div v-if="previewSlide" :key="previewSlide.id ?? previewIdx" class="space-y-4">
<div class="grid h-11 w-11 place-items-center rounded-md bg-white/15 border border-white/20 shadow-lg">
<i :class="['pi', previewSlide.icon || 'pi-star', 'text-white text-lg']" />
</div>
<div class="space-y-2">
<div class="text-xl font-bold text-white leading-tight prose prose-invert prose-sm max-w-none" v-html="previewSlide.title" />
<div class="text-[1rem] text-white/70 leading-relaxed prose prose-invert prose-sm max-w-none" v-html="previewSlide.body" />
</div>
</div>
<div v-else class="flex flex-col items-center justify-center gap-2 text-white/30 text-xs">
<i class="pi pi-ban text-2xl" />
Nenhum slide ativo
</div>
</Transition>
</div>
<!-- Dots -->
<div class="flex items-center gap-1.5">
<button
v-for="(s, i) in slidesAtivos"
:key="s.id"
class="transition-all duration-300 rounded-full"
:class="i === previewIdx ? 'w-5 h-1.5 bg-white shadow' : 'w-1.5 h-1.5 bg-white/35 hover:bg-white/60'"
@click="previewIdx = i"
/>
<span v-if="slidesAtivos.length" class="ml-2 text-[0.6rem] text-white/40 tabular-nums">
{{ previewIdx + 1 }}/{{ slidesAtivos.length }}
</span>
</div>
</div>
</div>
<!-- Info -->
<div class="rounded-lg border border-[var(--surface-border)] bg-[var(--surface-card)] px-4 py-3 text-xs text-[var(--text-color-secondary)] flex items-start gap-2">
<i class="pi pi-info-circle text-indigo-500 mt-px flex-shrink-0" />
<span>Clique nos pontos para navegar entre os slides ativos. A ordem e visibilidade refletem o que o usuário verá no login.</span>
</div>
</div>
</div>
<!-- SQL Helper -->
<div class="bg-[var(--surface-card)] rounded-md border border-[var(--surface-border)] px-5 py-4">
<div class="flex items-center gap-2 mb-3">
<i class="pi pi-database text-amber-500 text-[1rem]" />
<span class="text-xs font-bold text-[var(--text-color)] uppercase tracking-widest">SQL de referência</span>
<span class="ml-auto text-xs text-[var(--text-color-secondary)]">Execute no Supabase caso a tabela não exista</span>
</div>
<pre class="text-[0.7rem] bg-[var(--surface-ground)] rounded-lg p-3.5 overflow-x-auto text-[var(--text-color-secondary)] leading-relaxed whitespace-pre-wrap"><code>create table if not exists public.login_carousel_slides (
id uuid primary key default gen_random_uuid(),
title text not null,
body text not null,
icon text not null default 'pi-star',
ordem integer not null default 0,
ativo boolean not null default true,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
-- RLS: apenas saas_admin pode gerenciar
alter table public.login_carousel_slides enable row level security;
create policy "saas_admin_full" on public.login_carousel_slides
for all using (
exists (
select 1 from public.profiles
where id = auth.uid() and role = 'saas_admin'
)
);
-- Leitura pública (login não tem usuário autenticado)
create policy "public_read" on public.login_carousel_slides
for select using (ativo = true);</code></pre>
</div>
</div>
<!-- /px-3 content wrapper -->
<!-- Dialog: Criar / Editar slide -->
<Dialog
v-model:visible="dialogOpen"
modal
:header="editingSlide ? 'Editar slide' : 'Novo slide'"
:draggable="false"
:style="{ width: '46rem', maxWidth: '96vw' }"
>
<div class="flex flex-col gap-4 pt-1">
<!-- Título -->
<div class="flex flex-col gap-1.5">
<label class="text-[1rem] font-semibold text-[var(--text-color)]">Título <span class="text-red-500">*</span></label>
<Editor
v-model="form.title"
:pt="{ toolbar: { style: 'display:none' } }"
style="height: 72px"
editorStyle="font-size: 1rem; font-weight: 600;"
placeholder="Ex: Gestão clínica simplificada"
>
<template #toolbar>
<span class="ql-formats">
<button class="ql-bold" />
<button class="ql-italic" />
<button class="ql-underline" />
</span>
</template>
</Editor>
</div>
<!-- Conteúdo -->
<div class="flex flex-col gap-1.5">
<label class="text-[1rem] font-semibold text-[var(--text-color)]">Conteúdo <span class="text-red-500">*</span></label>
<Editor
v-model="form.body"
style="height: 160px"
editorStyle="font-size: 1rem;"
>
<template #toolbar>
<span class="ql-formats">
<button class="ql-bold" />
<button class="ql-italic" />
<button class="ql-underline" />
</span>
<span class="ql-formats">
<button class="ql-list" value="ordered" />
<button class="ql-list" value="bullet" />
</span>
<span class="ql-formats">
<button class="ql-link" />
<button class="ql-clean" />
</span>
</template>
</Editor>
</div>
<!-- Ícone -->
<div class="flex flex-col gap-1.5">
<label class="text-[1rem] font-semibold text-[var(--text-color)]">Ícone</label>
<div class="grid grid-cols-4 sm:grid-cols-8 gap-1.5">
<button
v-for="ic in ICONS"
:key="ic.value"
type="button"
class="flex flex-col items-center justify-center gap-1 py-2 rounded-lg border text-xs transition-all duration-100"
:class="form.icon === ic.value
? 'border-indigo-500 bg-indigo-50 text-indigo-600 shadow-sm'
: 'border-[var(--surface-border)] bg-[var(--surface-ground)] text-[var(--text-color-secondary)] hover:border-indigo-300 hover:text-indigo-500'"
:title="ic.label"
@click="form.icon = ic.value"
>
<i :class="['pi', ic.value, 'text-base']" />
<span class="text-[0.6rem] leading-none">{{ ic.label }}</span>
</button>
</div>
</div>
<!-- Ativo -->
<div class="flex items-center gap-3">
<InputSwitch v-model="form.ativo" inputId="slide-ativo" />
<label for="slide-ativo" class="text-[1rem] text-[var(--text-color)] cursor-pointer select-none">
Slide ativo (visível no carrossel)
</label>
</div>
<!-- Mini preview -->
<div
class="relative overflow-hidden rounded-md p-5 flex items-center gap-4"
style="background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%)"
>
<div class="grid h-12 w-12 flex-shrink-0 place-items-center rounded-md bg-white/15 border border-white/20 shadow">
<i :class="['pi', form.icon || 'pi-star', 'text-white text-xl']" />
</div>
<div class="min-w-0 overflow-hidden">
<div class="text-[1rem] font-bold text-white line-clamp-2 prose prose-invert prose-sm max-w-none" v-html="stripHtml(form.title) ? form.title : 'Título do slide'" />
<div class="text-[1rem] text-white/70 mt-0.5 line-clamp-3 prose prose-invert prose-sm max-w-none" v-html="stripHtml(form.body) ? form.body : 'Conteúdo descritivo...'" />
</div>
</div>
<!-- Ações -->
<div class="flex justify-end gap-2 pt-1">
<Button label="Cancelar" severity="secondary" outlined :disabled="saving" @click="dialogOpen = false" />
<Button
:label="editingSlide ? 'Salvar alterações' : 'Criar slide'"
icon="pi pi-check"
:loading="saving"
@click="saveSlide"
/>
</div>
</div>
</Dialog>
</template>
<style scoped>
.prev-fade-enter-active,
.prev-fade-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.prev-fade-enter-from {
opacity: 0;
transform: translateY(12px);
}
.prev-fade-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>

View File

@@ -1,8 +1,25 @@
<template>
<div class="p-4">
<div class="text-xl font-semibold">Em construção</div>
<div class="text-color-secondary mt-2">
Esta área do Admin SaaS ainda será implementada.
<!-- Sentinel -->
<div class="h-px" />
<!-- Hero sticky -->
<div
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-72 h-72 -bottom-20 right-24 bg-fuchsia-400/10" />
</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Em construção</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Esta área do Admin SaaS ainda será implementada.</div>
</div>
</div>
</div>
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- conteúdo futuro -->
</div>
</template>

View File

@@ -398,33 +398,28 @@ onBeforeUnmount(() => {
<Toast />
<ConfirmDialog />
<div class="matrix-root">
<!-- Sentinel -->
<div ref="heroSentinelRef" class="h-px" />
<!-- Info decorativa (scrolls away naturalmente) -->
<div class="flex items-start gap-4 px-4 pb-3">
<div class="matrix-hero__icon-wrap">
<i class="pi pi-th-large matrix-hero__icon" />
</div>
<div class="matrix-hero__sub">
Defina quais recursos cada plano habilita. As mudanças ficam <b>pendentes</b> até clicar em <b>Salvar alterações</b>.
</div>
<!-- Hero sticky -->
<div
ref="heroEl"
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-indigo-400/10" />
</div>
<!-- HERO -->
<div ref="heroSentinelRef" class="matrix-hero-sentinel" />
<div ref="heroEl" class="matrix-hero mb-4" :class="{ 'matrix-hero--stuck': heroStuck }">
<div class="matrix-hero__blobs" aria-hidden="true">
<div class="matrix-hero__blob matrix-hero__blob--1" />
<div class="matrix-hero__blob matrix-hero__blob--2" />
</div>
<div class="matrix-hero__inner">
<div class="matrix-hero__info min-w-0">
<div class="matrix-hero__title">Controle de Recursos</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Controle de Recursos</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Defina quais recursos cada plano habilita. Mudanças ficam pendentes até salvar.</div>
</div>
<!-- Ações desktop ( 1200px) -->
<div class="matrix-hero__actions matrix-hero__actions--desktop">
<div class="hidden xl:flex items-center gap-2 flex-wrap">
<SelectButton v-model="targetFilter" :options="targetOptions" optionLabel="label" optionValue="value" size="small" :disabled="loading || saving" />
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loading" :disabled="saving || hasPending" v-tooltip.top="hasPending ? 'Salve ou descarte antes de recarregar.' : ''" @click="fetchAll" />
<Button label="Descartar" icon="pi pi-undo" severity="secondary" outlined size="small" :disabled="loading || saving || !hasPending" @click="confirmReset" />
@@ -432,7 +427,7 @@ onBeforeUnmount(() => {
</div>
<!-- Ações mobile (< 1200px) -->
<div class="matrix-hero__actions--mobile">
<div class="flex xl:hidden">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
@@ -447,8 +442,11 @@ onBeforeUnmount(() => {
</div>
</div>
<!-- Search sempre visível, fora do hero sticky -->
<div class="px-4 mb-4">
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Search -->
<div>
<FloatLabel variant="on" class="w-full md:w-[340px]">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
@@ -458,8 +456,7 @@ onBeforeUnmount(() => {
</FloatLabel>
</div>
<div class="px-4 pb-4">
<div class="mb-3 surface-100 border-round p-3">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<div class="flex gap-2 items-center flex-wrap">
<Tag :value="`Planos: ${filteredPlans.length}`" severity="info" icon="pi pi-list" rounded />
@@ -467,13 +464,13 @@ onBeforeUnmount(() => {
<Tag v-if="hasPending" value="Alterações pendentes" severity="warn" icon="pi pi-clock" rounded />
</div>
<div class="text-color-secondary text-sm">
<div class="text-[1rem] text-[var(--text-color-secondary)]">
Dica: use a busca para reduzir a lista e aplique ações em massa com confirmação.
</div>
</div>
</div>
<Divider class="my-4" />
<Divider class="my-0" />
<DataTable
:value="filteredFeatures"
@@ -488,9 +485,9 @@ onBeforeUnmount(() => {
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ data.key }}</span>
<small class="text-color-secondary leading-snug mt-1">
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-snug mt-1">
{{ data.descricao || data.description || '—' }}
</small>
</div>
</div>
</template>
</Column>
@@ -506,7 +503,7 @@ onBeforeUnmount(() => {
{{ planTitle(p) }}
</div>
<div class="flex items-center justify-center gap-1 flex-wrap">
<small class="text-color-secondary truncate" :title="p.key">{{ p.key }}</small>
<div class="text-[1rem] text-[var(--text-color-secondary)] truncate" :title="p.key">{{ p.key }}</div>
<Tag :value="targetLabel(p.target)" :severity="targetSeverity(p.target)" rounded />
</div>
<div class="flex gap-2 justify-center">
@@ -545,69 +542,5 @@ onBeforeUnmount(() => {
</template>
</Column>
</DataTable>
</div><!-- /px-4 pb-4 -->
</div>
</template>
<style scoped>
.matrix-root { padding: 1rem; }
@media (min-width: 768px) { .matrix-root { padding: 1.5rem; } }
.matrix-hero-sentinel { height: 1px; }
.matrix-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.5rem;
}
.matrix-hero--stuck {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.matrix-hero__blobs {
position: absolute; inset: 0; pointer-events: none; overflow: hidden;
}
.matrix-hero__blob {
position: absolute; border-radius: 50%; filter: blur(70px);
}
.matrix-hero__blob--1 { width: 20rem; height: 20rem; top: -5rem; right: -4rem; background: rgba(52,211,153,0.12); }
.matrix-hero__blob--2 { width: 18rem; height: 18rem; top: 1rem; left: -5rem; background: rgba(99,102,241,0.09); }
.matrix-hero__inner {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1.25rem; flex-wrap: wrap;
}
.matrix-hero__icon-wrap {
flex-shrink: 0;
width: 4rem; height: 4rem; border-radius: 1.125rem;
border: 2px solid var(--surface-border);
background: var(--surface-ground);
display: grid; place-items: center;
}
.matrix-hero__icon { font-size: 1.5rem; color: var(--text-color); }
.matrix-hero__info { flex: 1; min-width: 0; }
.matrix-hero__title {
font-size: 1.25rem; font-weight: 700; letter-spacing: -0.025em;
color: var(--text-color); line-height: 1.2;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.matrix-hero__sub {
font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 4px; line-height: 1.5;
}
.matrix-hero__actions--desktop {
display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
}
.matrix-hero__actions--mobile { display: none; }
@media (max-width: 1199px) {
.matrix-hero__actions--desktop { display: none; }
.matrix-hero__actions--mobile { display: flex; }
}
</style>

View File

@@ -329,40 +329,34 @@ onBeforeUnmount(() => {
<Toast />
<ConfirmDialog />
<div class="limits-root">
<!-- Sentinel -->
<div ref="heroSentinelRef" class="h-px" />
<!-- Info decorativa (scrolls away naturalmente) -->
<div class="flex items-start gap-4 px-4 pb-3">
<div class="limits-hero__icon-wrap">
<i class="pi pi-sliders-h limits-hero__icon" />
</div>
<div class="limits-hero__sub">
Configure os limites reais de cada feature por plano (ex: max_patients, max_sessions_per_month).
Esses valores são lidos pelo sistema para bloquear ações quando o limite é atingido.
</div>
<!-- Hero sticky -->
<div
ref="heroEl"
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-orange-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-indigo-400/10" />
</div>
<!-- HERO -->
<div ref="heroSentinelRef" class="limits-hero-sentinel" />
<div ref="heroEl" class="limits-hero mb-4" :class="{ 'limits-hero--stuck': heroStuck }">
<div class="limits-hero__blobs" aria-hidden="true">
<div class="limits-hero__blob limits-hero__blob--1" />
<div class="limits-hero__blob limits-hero__blob--2" />
</div>
<div class="limits-hero__inner">
<div class="limits-hero__info min-w-0">
<div class="limits-hero__title">Limites por Plano</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Limites por Plano</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Configure os limites reais de cada feature por plano.</div>
</div>
<!-- Ações desktop ( 1200px) -->
<div class="limits-hero__actions limits-hero__actions--desktop">
<div class="hidden xl:flex items-center gap-2 flex-wrap">
<SelectButton v-model="targetFilter" :options="targetOptions" optionLabel="label" optionValue="value" size="small" :disabled="loading || saving" />
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loading" :disabled="saving" @click="fetchAll" />
</div>
<!-- Ações mobile (< 1200px) -->
<div class="limits-hero__actions--mobile">
<div class="flex xl:hidden">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
@@ -377,8 +371,11 @@ onBeforeUnmount(() => {
</div>
</div>
<!-- Search sempre visível, fora do hero sticky -->
<div class="px-4 mb-4">
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Search -->
<div>
<FloatLabel variant="on" class="w-full md:w-80">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
@@ -388,19 +385,18 @@ onBeforeUnmount(() => {
</FloatLabel>
</div>
<div class="px-4 pb-4">
<!-- Legenda rápida -->
<div class="surface-100 border-round p-3 mb-4">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-3">
<div class="flex flex-wrap gap-4 items-center">
<div class="flex items-center gap-2 text-sm text-color-secondary">
<div class="flex items-center gap-2 text-[1rem] text-[var(--text-color-secondary)]">
<i class="pi pi-info-circle text-blue-400" />
<span><strong>Sem limites</strong> = acesso habilitado sem restrição de quantidade</span>
</div>
<div class="flex items-center gap-2 text-sm text-color-secondary">
<div class="flex items-center gap-2 text-[1rem] text-[var(--text-color-secondary)]">
<i class="pi pi-info-circle text-orange-400" />
<span><strong>-1</strong> = ilimitado (explícito no JSON, útil para planos PRO)</span>
</div>
<div class="flex items-center gap-2 text-sm text-color-secondary">
<div class="flex items-center gap-2 text-[1rem] text-[var(--text-color-secondary)]">
<i class="pi pi-info-circle text-red-400" />
<span><strong>0 ou N</strong> = limite máximo que o sistema vai verificar</span>
</div>
@@ -431,11 +427,11 @@ onBeforeUnmount(() => {
:severity="domainSeverity(featureDomain(data.feature.key))"
rounded
/>
<span class="font-medium font-mono text-sm">{{ data.feature.key }}</span>
<span class="font-medium font-mono text-[1rem]">{{ data.feature.key }}</span>
</div>
<small class="text-color-secondary mt-1 leading-snug">
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1 leading-snug">
{{ data.feature.descricao || '—' }}
</small>
</div>
</div>
</template>
</Column>
@@ -452,7 +448,7 @@ onBeforeUnmount(() => {
<span class="font-semibold truncate" :title="plan.name">{{ plan.name || plan.key }}</span>
<Tag :value="targetLabel(plan.target)" :severity="targetSeverity(plan.target)" rounded />
</div>
<small class="text-color-secondary font-mono">{{ plan.key }}</small>
<div class="text-[1rem] text-[var(--text-color-secondary)] font-mono">{{ plan.key }}</div>
</div>
</template>
@@ -472,7 +468,7 @@ onBeforeUnmount(() => {
<!-- Limites atuais -->
<div
v-if="data.planCols[plan.id].limits"
class="text-xs text-color-secondary leading-relaxed bg-surface-100 border-round p-2"
class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-2"
>
<div
v-for="(val, key) in data.planCols[plan.id].limits"
@@ -483,7 +479,7 @@ onBeforeUnmount(() => {
<span>{{ limitValueDisplay(val) }}</span>
</div>
</div>
<div v-else-if="data.planCols[plan.id].hasRecord" class="text-xs text-color-secondary">
<div v-else-if="data.planCols[plan.id].hasRecord" class="text-[1rem] text-[var(--text-color-secondary)]">
Sem limites definidos
</div>
@@ -508,7 +504,7 @@ onBeforeUnmount(() => {
@click="askClearLimits(plan, data.feature)"
/>
</div>
<div v-else class="text-xs text-color-secondary italic">
<div v-else class="text-[1rem] text-[var(--text-color-secondary)] italic">
Feature não vinculada a este plano.<br/>
Configure em <strong>Recursos por Plano</strong>.
</div>
@@ -516,8 +512,7 @@ onBeforeUnmount(() => {
</template>
</Column>
</DataTable>
</div><!-- /px-4 pb-4 -->
</div>
<!-- Dialog: editar limites de plan_features -->
<Dialog
@@ -530,7 +525,7 @@ onBeforeUnmount(() => {
>
<template #header>
<div class="flex flex-col gap-1">
<div class="text-lg font-semibold">Limites {{ dlgFeature?.key }}</div>
<div class="text-[1rem] font-semibold">Limites {{ dlgFeature?.key }}</div>
<div class="flex items-center gap-2 flex-wrap">
<Tag :value="dlgPlan?.name || dlgPlan?.key" severity="secondary" />
<Tag :value="targetLabel(dlgPlan?.target)" :severity="targetSeverity(dlgPlan?.target)" rounded />
@@ -547,12 +542,12 @@ onBeforeUnmount(() => {
<div
v-for="(field, idx) in limitFields"
:key="idx"
class="flex items-center gap-3 surface-100 border-round p-3"
class="flex items-center gap-3 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-3"
>
<!-- Key (não editável) -->
<div class="flex-1 min-w-0">
<div class="font-mono font-medium text-sm">{{ field.key }}</div>
<small class="text-color-secondary">{{ field.type }}</small>
<div class="font-mono font-medium text-[1rem]">{{ field.key }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)]">{{ field.type }}</div>
</div>
<!-- Valor -->
@@ -606,7 +601,7 @@ onBeforeUnmount(() => {
</div>
</div>
<div v-else class="text-sm text-color-secondary surface-100 border-round p-3 text-center">
<div v-else class="text-[1rem] text-[var(--text-color-secondary)] rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-3 text-center">
Nenhum limite configurado. Adicione abaixo.
</div>
@@ -619,7 +614,7 @@ onBeforeUnmount(() => {
<div class="flex flex-col gap-3">
<!-- Nome -->
<div>
<label class="text-xs font-semibold text-color-secondary uppercase tracking-wide block mb-1">
<label class="text-[1rem] font-semibold text-[var(--text-color-secondary)] uppercase tracking-wide block mb-1">
Nome do campo *
</label>
<InputText
@@ -632,15 +627,15 @@ onBeforeUnmount(() => {
placeholder="ex: max_patients"
@keydown.enter.prevent="addLimitField"
/>
<small class="text-color-secondary mt-1 block">
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">
Ex: <span class="font-mono">max_patients</span>, <span class="font-mono">max_sessions_per_month</span>
</small>
</div>
</div>
<!-- Tipo + Valor + Botão -->
<div class="flex items-end gap-2 flex-wrap">
<div>
<label class="text-xs font-semibold text-color-secondary uppercase tracking-wide block mb-1">Tipo</label>
<label class="text-[1rem] font-semibold text-[var(--text-color-secondary)] uppercase tracking-wide block mb-1">Tipo</label>
<SelectButton
v-model="newLimitType"
:options="limitTypeOptions"
@@ -651,7 +646,7 @@ onBeforeUnmount(() => {
</div>
<div class="flex-1" style="min-width: 8rem;">
<label class="text-xs font-semibold text-color-secondary uppercase tracking-wide block mb-1">Valor inicial</label>
<label class="text-[1rem] font-semibold text-[var(--text-color-secondary)] uppercase tracking-wide block mb-1">Valor inicial</label>
<InputNumber
v-if="newLimitType === 'number'"
v-model="newLimitValue"
@@ -690,7 +685,7 @@ onBeforeUnmount(() => {
</div>
<!-- Dica de boas práticas -->
<div class="surface-100 border-round p-3 text-xs text-color-secondary leading-relaxed">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-3 text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">
<div class="font-semibold mb-1">Convenções recomendadas</div>
<div class="grid grid-cols-2 gap-x-4 gap-y-1">
<div><span class="font-mono">max_patients</span> número máximo de pacientes</div>
@@ -708,68 +703,4 @@ onBeforeUnmount(() => {
<Button label="Salvar limites" icon="pi pi-check" :loading="saving" @click="saveLimits" />
</template>
</Dialog>
</div>
</template>
<style scoped>
.limits-root { padding: 1rem; }
@media (min-width: 768px) { .limits-root { padding: 1.5rem; } }
.limits-hero-sentinel { height: 1px; }
.limits-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.5rem;
}
.limits-hero--stuck {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.limits-hero__blobs {
position: absolute; inset: 0; pointer-events: none; overflow: hidden;
}
.limits-hero__blob {
position: absolute; border-radius: 50%; filter: blur(70px);
}
.limits-hero__blob--1 { width: 20rem; height: 20rem; top: -5rem; right: -4rem; background: rgba(251,146,60,0.12); }
.limits-hero__blob--2 { width: 18rem; height: 18rem; top: 1rem; left: -5rem; background: rgba(99,102,241,0.09); }
.limits-hero__inner {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1.25rem; flex-wrap: wrap;
}
.limits-hero__icon-wrap {
flex-shrink: 0;
width: 4rem; height: 4rem; border-radius: 1.125rem;
border: 2px solid var(--surface-border);
background: var(--surface-ground);
display: grid; place-items: center;
}
.limits-hero__icon { font-size: 1.5rem; color: var(--text-color); }
.limits-hero__info { flex: 1; min-width: 0; }
.limits-hero__title {
font-size: 1.25rem; font-weight: 700; letter-spacing: -0.025em;
color: var(--text-color); line-height: 1.2;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.limits-hero__sub {
font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 4px; line-height: 1.5;
}
.limits-hero__actions--desktop {
display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
}
.limits-hero__actions--mobile { display: none; }
@media (max-width: 1199px) {
.limits-hero__actions--desktop { display: none; }
.limits-hero__actions--mobile { display: flex; }
}
</style>

View File

@@ -432,98 +432,92 @@ onBeforeUnmount(() => {
<Toast />
<ConfirmDialog />
<div class="plans-root">
<!-- Sentinel -->
<div ref=heroSentinelRef class=h-px />
<!-- Info decorativa (scrolls away naturalmente) -->
<div class="flex items-start gap-4 px-4 pb-3">
<div class="plans-hero__icon-wrap">
<i class="pi pi-list plans-hero__icon" />
</div>
<div class="plans-hero__sub">
Catálogo de planos do SaaS. A <b>key</b> é a referência técnica estável.
O <b>público</b> indica se o plano é para <b>Clínica</b> ou <b>Terapeuta</b>.
</div>
<!-- Hero sticky -->
<div
ref=heroEl
class=sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5
:style={ top: 'var(--layout-sticky-top, 56px)' }
>
<div class=absolute inset-0 pointer-events-none overflow-hidden aria-hidden=true>
<div class=absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10 />
<div class=absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10 />
</div>
<!-- HERO -->
<div ref="heroSentinelRef" class="plans-hero-sentinel" />
<div ref="heroEl" class="plans-hero mb-5" :class="{ 'plans-hero--stuck': heroStuck }">
<div class="plans-hero__blobs" aria-hidden="true">
<div class="plans-hero__blob plans-hero__blob--1" />
<div class="plans-hero__blob plans-hero__blob--2" />
</div>
<div class="plans-hero__inner">
<!-- Título -->
<div class="plans-hero__info min-w-0">
<div class="plans-hero__title">Planos e preços</div>
<div class=relative z-10 flex items-center justify-between gap-3 flex-wrap>
<div class=min-w-0>
<div class=text-[1rem] font-bold tracking-tight text-[var(--text-color)]>Planos e preços</div>
<div class=text-[1rem] text-[var(--text-color-secondary)] mt-0.5>Catálogo de planos do SaaS.</div>
</div>
<!-- Ações desktop ( 1200px) -->
<div class="plans-hero__actions plans-hero__actions--desktop">
<div class=hidden xl:flex items-center gap-2 flex-wrap>
<SelectButton
v-model="targetFilter"
:options="targetFilterOptions"
optionLabel="label"
optionValue="value"
size="small"
v-model=targetFilter
:options=targetFilterOptions
optionLabel=label
optionValue=value
size=small
/>
<Button label="Atualizar" icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loading" :disabled="saving" @click="fetchAll" />
<Button label="Adicionar plano" icon="pi pi-plus" size="small" :disabled="saving" @click="openCreate" />
<Button label=Atualizar icon=pi pi-refresh severity=secondary outlined size=small :loading=loading :disabled=saving @click=fetchAll />
<Button label=Adicionar plano icon=pi pi-plus size=small :disabled=saving @click=openCreate />
</div>
<!-- Ações mobile (< 1200px) -->
<div class="plans-hero__actions--mobile">
<div class=flex xl:hidden>
<Button
label="Ações"
icon="pi pi-ellipsis-v"
severity="warn"
size="small"
aria-haspopup="true"
aria-controls="plans_hero_menu"
@click="(e) => heroMenuRef.toggle(e)"
label=Ações
icon=pi pi-ellipsis-v
severity=warn
size=small
aria-haspopup=true
aria-controls=plans_hero_menu
@click=(e) => heroMenuRef.toggle(e)
/>
<Menu ref="heroMenuRef" id="plans_hero_menu" :model="heroMenuItems" :popup="true" />
<Menu ref=heroMenuRef id=plans_hero_menu :model=heroMenuItems :popup=true />
</div>
</div>
</div>
<div class="px-4 pb-4">
<DataTable :value="filteredRows" dataKey="id" :loading="loading" stripedRows responsiveLayout="scroll">
<Column field="name" header="Nome" sortable style="min-width: 14rem" />
<Column field="key" header="Key" sortable />
<!-- content -->
<div class=px-3 md:px-4 pb-8 flex flex-col gap-4>
<DataTable :value=filteredRows dataKey=id :loading=loading stripedRows responsiveLayout=scroll>
<Column field=name header=Nome sortable style=min-width: 14rem />
<Column field=key header=Key sortable />
<Column field="target" header="Público" sortable style="width: 10rem">
<template #body="{ data }">
<span class="font-medium">{{ formatTargetLabel(data.target) }}</span>
<Column field=target header=Público sortable style=width: 10rem>
<template #body={ data }>
<span class=font-medium>{{ formatTargetLabel(data.target) }}</span>
</template>
</Column>
<Column header="Mensal" sortable style="width: 12rem">
<template #body="{ data }">
<span class="font-medium">{{ formatBRLFromCents(data.monthly_cents) }}</span>
<Column header=Mensal sortable style=width: 12rem>
<template #body={ data }>
<span class=font-medium>{{ formatBRLFromCents(data.monthly_cents) }}</span>
</template>
</Column>
<Column header="Anual" sortable style="width: 12rem">
<template #body="{ data }">
<span class="font-medium">{{ formatBRLFromCents(data.yearly_cents) }}</span>
<Column header=Anual sortable style=width: 12rem>
<template #body={ data }>
<span class=font-medium>{{ formatBRLFromCents(data.yearly_cents) }}</span>
</template>
</Column>
<Column v-if="hasCreatedAt" field="created_at" header="Criado em" sortable />
<Column v-if=hasCreatedAt field=created_at header=Criado em sortable />
<Column header="Ações" style="width: 12rem">
<template #body="{ data }">
<div class="flex gap-2">
<Button icon="pi pi-pencil" severity="secondary" outlined @click="openEdit(data)" />
<Column header=Ações style=width: 12rem>
<template #body={ data }>
<div class=flex gap-2>
<Button icon=pi pi-pencil severity=secondary outlined @click=openEdit(data) />
<Button
icon="pi pi-trash"
severity="danger"
icon=pi pi-trash
severity=danger
outlined
:disabled="isDeleteLockedRow(data)"
:title="isDeleteLockedRow(data) ? 'Plano padrão do sistema não pode ser removido.' : 'Excluir plano'"
@click="askDelete(data)"
:disabled=isDeleteLockedRow(data)
:title=isDeleteLockedRow(data) ? 'Plano padrão do sistema não pode ser removido.' : 'Excluir plano'
@click=askDelete(data)
/>
</div>
</template>
@@ -532,221 +526,137 @@ onBeforeUnmount(() => {
</div>
<Dialog
v-model:visible="showDlg"
v-model:visible=showDlg
modal
:draggable="false"
:header="isEdit ? 'Editar plano' : 'Novo plano'"
:style="{ width: '620px' }"
class="plans-dialog"
:draggable=false
:header=isEdit ? 'Editar plano' : 'Novo plano'
:style={ width: '620px' }
>
<div class="flex flex-col gap-4">
<div class=flex flex-col gap-4>
<div>
<label class="block mb-2">Público do plano</label>
<label class=block mb-2>Público do plano</label>
<SelectButton
v-model="form.target"
:options="targetOptions"
optionLabel="label"
optionValue="value"
class="w-full"
:disabled="isTargetLocked || saving"
v-model=form.target
:options=targetOptions
optionLabel=label
optionValue=value
class=w-full
:disabled=isTargetLocked || saving
/>
<small class="text-color-secondary">
<div class=text-[1rem] text-[var(--text-color-secondary)] mt-1>
Planos existentes não mudam de público. Isso evita inconsistência no catálogo.
</small>
</div>
</div>
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-tag" />
<FloatLabel variant=on class=w-full>
<IconField class=w-full>
<InputIcon class=pi pi-tag />
<InputText
v-model="form.key"
id="plan_key"
class="w-full pr-10"
variant="filled"
placeholder="ex.: clinic_pro"
:disabled="(isCorePlanEditing || saving)"
@blur="form.key = slugifyKey(form.key)"
v-model=form.key
id=plan_key
class=w-full pr-10
variant=filled
placeholder=ex.: clinic_pro
:disabled=(isCorePlanEditing || saving)
@blur=form.key = slugifyKey(form.key)
/>
</IconField>
<label for="plan_key">Key</label>
<label for=plan_key>Key</label>
</FloatLabel>
<small class="text-color-secondary -mt-3">
<div class=text-[1rem] text-[var(--text-color-secondary)] -mt-3>
Key é técnica e estável (slug). Planos padrão do sistema têm a key protegida.
</small>
</div>
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-bookmark" />
<FloatLabel variant=on class=w-full>
<IconField class=w-full>
<InputIcon class=pi pi-bookmark />
<InputText
v-model="form.name"
id="plan_name"
class="w-full pr-10"
variant="filled"
placeholder="ex.: Clínica PRO"
:disabled="saving"
v-model=form.name
id=plan_name
class=w-full pr-10
variant=filled
placeholder=ex.: Clínica PRO
:disabled=saving
/>
</IconField>
<label for="plan_name">Nome</label>
<label for=plan_name>Nome</label>
</FloatLabel>
<small class="text-color-secondary -mt-3">
<div class=text-[1rem] text-[var(--text-color-secondary)] -mt-3>
Nome interno para administração. (Nome público vem de <b>plan_public</b>.)
</small>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class=grid grid-cols-1 md:grid-cols-2 gap-4>
<div>
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-money-bill" />
<FloatLabel variant=on class=w-full>
<IconField class=w-full>
<InputIcon class=pi pi-money-bill />
<InputNumber
v-model="form.price_monthly"
inputId="price_monthly"
class="w-full"
inputClass="w-full pr-10"
variant="filled"
mode="decimal"
:minFractionDigits="2"
:maxFractionDigits="2"
placeholder="ex.: 49,90"
:disabled="saving"
v-model=form.price_monthly
inputId=price_monthly
class=w-full
inputClass=w-full pr-10
variant=filled
mode=decimal
:minFractionDigits=2
:maxFractionDigits=2
placeholder=ex.: 49,90
:disabled=saving
/>
</IconField>
<label for="price_monthly">Preço mensal (R$)</label>
<label for=price_monthly>Preço mensal (R$)</label>
</FloatLabel>
<small class="text-color-secondary">Deixe vazio para sem preço definido.</small>
<div class=text-[1rem] text-[var(--text-color-secondary)] mt-1>Deixe vazio para sem preço definido.</div>
</div>
<div>
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-calendar" />
<FloatLabel variant=on class=w-full>
<IconField class=w-full>
<InputIcon class=pi pi-calendar />
<InputNumber
v-model="form.price_yearly"
inputId="price_yearly"
class="w-full"
inputClass="w-full pr-10"
variant="filled"
mode="decimal"
:minFractionDigits="2"
:maxFractionDigits="2"
placeholder="ex.: 490,00"
:disabled="saving"
v-model=form.price_yearly
inputId=price_yearly
class=w-full
inputClass=w-full pr-10
variant=filled
mode=decimal
:minFractionDigits=2
:maxFractionDigits=2
placeholder=ex.: 490,00
:disabled=saving
/>
</IconField>
<label for="price_yearly">Preço anual (R$)</label>
<label for=price_yearly>Preço anual (R$)</label>
</FloatLabel>
<small class="text-color-secondary">Deixe vazio para sem preço definido.</small>
<div class=text-[1rem] text-[var(--text-color-secondary)] mt-1>Deixe vazio para sem preço definido.</div>
</div>
</div>
<!-- max_supervisees: para planos de supervisor -->
<div v-if="form.target === 'supervisor'">
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-users" />
<div v-if=form.target === 'supervisor'>
<FloatLabel variant=on class=w-full>
<IconField class=w-full>
<InputIcon class=pi pi-users />
<InputNumber
v-model="form.max_supervisees"
inputId="max_supervisees"
class="w-full"
inputClass="w-full pr-10"
variant="filled"
:useGrouping="false"
:min="1"
placeholder="ex.: 3"
:disabled="saving"
v-model=form.max_supervisees
inputId=max_supervisees
class=w-full
inputClass=w-full pr-10
variant=filled
:useGrouping=false
:min=1
placeholder=ex.: 3
:disabled=saving
/>
</IconField>
<label for="max_supervisees">Limite de supervisionados</label>
<label for=max_supervisees>Limite de supervisionados</label>
</FloatLabel>
<small class="text-color-secondary">Número máximo de terapeutas que podem ser supervisionados neste plano.</small>
<div class=text-[1rem] text-[var(--text-color-secondary)] mt-1>Número máximo de terapeutas que podem ser supervisionados neste plano.</div>
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined @click="showDlg = false" :disabled="saving" />
<Button :label="isEdit ? 'Salvar' : 'Criar'" icon="pi pi-check" :loading="saving" @click="save" />
<Button label=Cancelar severity=secondary outlined @click=showDlg = false :disabled=saving />
<Button :label=isEdit ? 'Salvar' : 'Criar' icon=pi pi-check :loading=saving @click=save />
</template>
</Dialog>
</div>
</template>
<style scoped>
/* ─── Root ──────────────────────────────────────────────── */
.plans-root { padding: 1rem; }
@media (min-width: 768px) { .plans-root { padding: 1.5rem; } }
/* ─── Hero ──────────────────────────────────────────────── */
.plans-hero-sentinel { height: 1px; }
.plans-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.5rem;
}
.plans-hero--stuck {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.plans-hero__blobs {
position: absolute; inset: 0; pointer-events: none; overflow: hidden;
}
.plans-hero__blob {
position: absolute; border-radius: 50%; filter: blur(70px);
}
.plans-hero__blob--1 { width: 20rem; height: 20rem; top: -5rem; right: -4rem; background: rgba(99,102,241,0.12); }
.plans-hero__blob--2 { width: 18rem; height: 18rem; top: 1rem; left: -5rem; background: rgba(52,211,153,0.09); }
.plans-hero__inner {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1.25rem; flex-wrap: wrap;
}
/* Ícone */
.plans-hero__icon-wrap {
flex-shrink: 0;
width: 4rem; height: 4rem; border-radius: 1.125rem;
border: 2px solid var(--surface-border);
background: var(--surface-ground);
display: grid; place-items: center;
}
.plans-hero__icon { font-size: 1.5rem; color: var(--text-color); }
/* Info */
.plans-hero__info { flex: 1; min-width: 0; }
.plans-hero__title {
font-size: 1.25rem; font-weight: 700; letter-spacing: -0.025em;
color: var(--text-color); line-height: 1.2;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.plans-hero__sub {
font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 4px; line-height: 1.5;
}
/* Ações */
.plans-hero__actions--desktop {
display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
}
.plans-hero__actions--mobile { display: none; }
@media (max-width: 1199px) {
.plans-hero__actions--desktop { display: none; }
.plans-hero__actions--mobile { display: flex; }
}
/* ─── Dialog: linhas divisórias no header e footer */
:deep(.plans-dialog .p-dialog-header) {
border-bottom: 1px solid var(--surface-border);
}
:deep(.plans-dialog .p-dialog-footer) {
border-top: 1px solid var(--surface-border);
}
/* Pequena melhoria de leitura */
small.text-color-secondary {
line-height: 1.35rem;
}
</style>

View File

@@ -449,152 +449,149 @@ onBeforeUnmount(() => {
<Toast />
<ConfirmDialog />
<div class="showcase-root">
<!-- Sentinel -->
<div ref=heroSentinelRef class=h-px />
<!-- Info decorativa (scrolls away naturalmente) -->
<div class="flex items-start gap-4 px-4 pb-3">
<div class="showcase-hero__icon-wrap">
<i class="pi pi-megaphone showcase-hero__icon" />
</div>
<div class="showcase-hero__sub">
Configure como os planos aparecem na página pública nome, descrição, badge, ordem e benefícios.
</div>
<!-- Hero sticky -->
<div
ref=heroEl
class=sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5
:style={ top: 'var(--layout-sticky-top, 56px)' }
>
<div class=absolute inset-0 pointer-events-none overflow-hidden aria-hidden=true>
<div class=absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-emerald-400/10 />
<div class=absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-indigo-400/10 />
</div>
<!-- HERO -->
<div ref="heroSentinelRef" class="showcase-hero-sentinel" />
<div ref="heroEl" class="showcase-hero mb-4" :class="{ 'showcase-hero--stuck': heroStuck }">
<div class="showcase-hero__blobs" aria-hidden="true">
<div class="showcase-hero__blob showcase-hero__blob--1" />
<div class="showcase-hero__blob showcase-hero__blob--2" />
</div>
<div class="showcase-hero__inner">
<div class="showcase-hero__info min-w-0">
<div class="showcase-hero__title">Vitrine de Planos</div>
<div class=relative z-10 flex items-center justify-between gap-3 flex-wrap>
<div class=min-w-0>
<div class=text-[1rem] font-bold tracking-tight text-[var(--text-color)]>Vitrine de Planos</div>
<div class=text-[1rem] text-[var(--text-color-secondary)] mt-0.5>Configure como os planos aparecem na página pública.</div>
</div>
<!-- Ações desktop ( 1200px) -->
<div class="showcase-hero__actions showcase-hero__actions--desktop">
<SelectButton v-model="targetFilter" :options="targetOptions" optionLabel="label" optionValue="value" size="small" :disabled="loading || saving || bulletSaving" />
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loading" :disabled="saving || bulletSaving" @click="fetchAll" />
<div class=hidden xl:flex items-center gap-2 flex-wrap>
<SelectButton v-model=targetFilter :options=targetOptions optionLabel=label optionValue=value size=small :disabled=loading || saving || bulletSaving />
<Button label=Recarregar icon=pi pi-refresh severity=secondary outlined size=small :loading=loading :disabled=saving || bulletSaving @click=fetchAll />
</div>
<!-- Ações mobile (< 1200px) -->
<div class="showcase-hero__actions--mobile">
<div class=flex xl:hidden>
<Button
label="Ações"
icon="pi pi-ellipsis-v"
severity="warn"
size="small"
aria-haspopup="true"
aria-controls="showcase_hero_menu"
@click="(e) => heroMenuRef.toggle(e)"
label=Ações
icon=pi pi-ellipsis-v
severity=warn
size=small
aria-haspopup=true
aria-controls=showcase_hero_menu
@click=(e) => heroMenuRef.toggle(e)
/>
<Menu ref="heroMenuRef" id="showcase_hero_menu" :model="heroMenuItems" :popup="true" />
<Menu ref=heroMenuRef id=showcase_hero_menu :model=heroMenuItems :popup=true />
</div>
</div>
</div>
<!-- Search sempre visível, fora do hero sticky -->
<div class="px-4 mb-4">
<FloatLabel variant="on" class="w-full md:w-80">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText v-model="q" id="plans_public_search" class="w-full pr-10" variant="filled" :disabled="loading || saving || bulletSaving" />
<!-- content -->
<div class=px-3 md:px-4 pb-8 flex flex-col gap-4>
<!-- Search -->
<div>
<FloatLabel variant=on class=w-full md:w-80>
<IconField class=w-full>
<InputIcon class=pi pi-search />
<InputText v-model=q id=plans_public_search class=w-full pr-10 variant=filled :disabled=loading || saving || bulletSaving />
</IconField>
<label for="plans_public_search">Buscar plano</label>
<label for=plans_public_search>Buscar plano</label>
</FloatLabel>
</div>
<!-- Popover global (reutilizado) -->
<Popover ref="bulletsPop">
<div class="w-[340px] max-w-[80vw]">
<div class="text-sm font-semibold mb-2">{{ popPlanTitle }}</div>
<Popover ref=bulletsPop>
<div class=w-[340px] max-w-[80vw]>
<div class=text-[1rem] font-semibold mb-2>{{ popPlanTitle }}</div>
<div v-if="!popBullets?.length" class="text-sm text-color-secondary">
<div v-if=!popBullets?.length class=text-[1rem] text-[var(--text-color-secondary)]>
Nenhum benefício configurado.
</div>
<ul v-else class="m-0 pl-4 space-y-2">
<li v-for="b in popBullets" :key="b.id" class="text-sm leading-snug">
<span :class="b.highlight ? 'font-semibold' : ''">
<ul v-else class=m-0 pl-4 space-y-2>
<li v-for=b in popBullets :key=b.id class=text-[1rem] leading-snug>
<span :class=b.highlight ? 'font-semibold' : ''>
{{ b.text }}
</span>
<small v-if="b.highlight" class="ml-2 text-color-secondary">(destaque)</small>
<div v-if=b.highlight class=inline ml-2 text-[1rem] text-[var(--text-color-secondary)]>(destaque)</div>
</li>
</ul>
</div>
</Popover>
<div class="px-4 pb-4">
<DataTable :value="tableRows" dataKey="plan_id" :loading="loading" stripedRows responsiveLayout="scroll">
<Column header="Plano" style="min-width: 18rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-semibold">{{ data.public_name || data.plan_name || data.plan_key }}</span>
<small class="text-color-secondary">
<DataTable :value=tableRows dataKey=plan_id :loading=loading stripedRows responsiveLayout=scroll>
<Column header=Plano style=min-width: 18rem>
<template #body={ data }>
<div class=flex flex-col>
<span class=font-semibold>{{ data.public_name || data.plan_name || data.plan_key }}</span>
<div class=text-[1rem] text-[var(--text-color-secondary)]>
{{ data.plan_key }} {{ data.plan_name || '—' }}
</small>
</div>
</div>
</template>
</Column>
<Column header="Público" style="width: 10rem">
<template #body="{ data }">
<Tag :value="targetLabel(normalizeTarget(data))" :severity="targetSeverity(normalizeTarget(data))" rounded />
<Column header=Público style=width: 10rem>
<template #body={ data }>
<Tag :value=targetLabel(normalizeTarget(data)) :severity=targetSeverity(normalizeTarget(data)) rounded />
</template>
</Column>
<Column header="Mensal" style="width: 12rem">
<template #body="{ data }">
<span class="font-medium">{{ formatBRLFromCents(data.monthly_cents) }}</span>
<Column header=Mensal style=width: 12rem>
<template #body={ data }>
<span class=font-medium>{{ formatBRLFromCents(data.monthly_cents) }}</span>
</template>
</Column>
<Column header="Anual" style="width: 12rem">
<template #body="{ data }">
<span class="font-medium">{{ formatBRLFromCents(data.yearly_cents) }}</span>
<Column header=Anual style=width: 12rem>
<template #body={ data }>
<span class=font-medium>{{ formatBRLFromCents(data.yearly_cents) }}</span>
</template>
</Column>
<Column field="badge" header="Badge" style="min-width: 12rem" />
<Column field=badge header=Badge style=min-width: 12rem />
<Column header="Visível" style="width: 8rem">
<template #body="{ data }">
<Column header=Visível style=width: 8rem>
<template #body={ data }>
<span>{{ data.is_visible ? 'Sim' : 'Não' }}</span>
</template>
</Column>
<Column header="Destaque" style="width: 9rem">
<template #body="{ data }">
<Column header=Destaque style=width: 9rem>
<template #body={ data }>
<span>{{ data.is_featured ? 'Sim' : 'Não' }}</span>
</template>
</Column>
<Column field="sort_order" header="Ordem" style="width: 8rem" />
<Column field=sort_order header=Ordem style=width: 8rem />
<Column header="Ações" style="width: 14rem">
<template #body="{ data }">
<div class="flex gap-2 justify-end">
<Column header=Ações style=width: 14rem>
<template #body={ data }>
<div class=flex gap-2 justify-end>
<Button
severity="secondary"
severity=secondary
outlined
size="small"
:disabled="loading || saving || bulletSaving"
@click="(e) => openBulletsPopover(e, data)"
size=small
:disabled=loading || saving || bulletSaving
@click=(e) => openBulletsPopover(e, data)
>
<i class="pi pi-list mr-2" />
<span class="font-medium">{{ data.bullets?.length || 0 }}</span>
<i class=pi pi-list mr-2 />
<span class=font-medium>{{ data.bullets?.length || 0 }}</span>
</Button>
<Button
icon="pi pi-pencil"
severity="secondary"
icon=pi pi-pencil
severity=secondary
outlined
size="small"
:disabled="loading || saving || bulletSaving"
@click="openEdit(data)"
size=small
:disabled=loading || saving || bulletSaving
@click=openEdit(data)
/>
</div>
</template>
@@ -602,54 +599,53 @@ onBeforeUnmount(() => {
</DataTable>
<!-- PREVIEW PÚBLICO (conceitual) -->
<div class="mt-10">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<div class=rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden>
<!-- Hero -->
<div class="relative p-6 md:p-10">
<div class="absolute inset-0 opacity-40 pointer-events-none bg-[radial-gradient(ellipse_at_top,rgba(16,185,129,0.18),transparent_55%)]" />
<div class="relative">
<div class="flex flex-col md:flex-row md:items-end md:justify-between gap-6">
<div class="max-w-2xl">
<div class="flex items-center gap-2 mb-3 flex-wrap">
<div class=relative p-6 md:p-10>
<div class=absolute inset-0 opacity-40 pointer-events-none bg-[radial-gradient(ellipse_at_top,rgba(16,185,129,0.18),transparent_55%)] />
<div class=relative>
<div class=flex flex-col md:flex-row md:items-end md:justify-between gap-6>
<div class=max-w-2xl>
<div class=flex items-center gap-2 mb-3 flex-wrap>
<Tag
:value="targetFilter === 'all' ? 'Vitrine (Todos)' : `Vitrine (${targetLabel(targetFilter)})`"
:severity="targetFilter === 'therapist' ? 'success' : (targetFilter === 'clinic' ? 'info' : 'secondary')"
:value=targetFilter === 'all' ? 'Vitrine (Todos)' : `Vitrine (${targetLabel(targetFilter)})`
:severity=targetFilter === 'therapist' ? 'success' : (targetFilter === 'clinic' ? 'info' : 'secondary')
rounded
/>
<span class="text-sm text-color-secondary">
<div class=text-[1rem] text-[var(--text-color-secondary)]>
Ajuste nomes, descrições, badges e benefícios e veja o resultado aqui.
</span>
</div>
</div>
<h2 class="text-3xl md:text-5xl font-semibold leading-tight">
<div class=text-3xl md:text-5xl font-semibold leading-tight>
Um plano não é preço.<br />
É promessa organizada.
</h2>
<p class="text-color-secondary mt-3">
A vitrine é o lugar onde o produto deixa de ser tabela e vira escolha.
Clareza, contraste e uma hierarquia que guia o olhar sem ruído.
</p>
</div>
<div class="flex flex-col items-start md:items-end gap-4">
<div class="flex flex-col gap-2">
<div class="text-sm text-color-secondary">Cobrança</div>
<div class=text-[var(--text-color-secondary)] mt-3>
A vitrine é o lugar onde o produto deixa de ser tabela e vira escolha.
Clareza, contraste e uma hierarquia que guia o olhar sem ruído.
</div>
</div>
<div class=flex flex-col items-start md:items-end gap-4>
<div class=flex flex-col gap-2>
<div class=text-[1rem] text-[var(--text-color-secondary)]>Cobrança</div>
<SelectButton
v-model="billingInterval"
:options="intervalOptions"
optionLabel="label"
optionValue="value"
v-model=billingInterval
:options=intervalOptions
optionLabel=label
optionValue=value
/>
</div>
<div class="flex flex-col gap-2">
<div class="text-sm text-color-secondary">Planos sem preço</div>
<div class=flex flex-col gap-2>
<div class=text-[1rem] text-[var(--text-color-secondary)]>Planos sem preço</div>
<SelectButton
v-model="previewPricePolicy"
:options="previewPolicyOptions"
optionLabel="label"
optionValue="value"
v-model=previewPricePolicy
:options=previewPolicyOptions
optionLabel=label
optionValue=value
/>
</div>
</div>
@@ -658,194 +654,193 @@ onBeforeUnmount(() => {
</div>
<!-- Cards -->
<div class="p-6 md:p-10 pt-0">
<div v-if="!previewPlans.length" class="text-sm text-color-secondary">
<div class=p-6 md:p-10 pt-0>
<div v-if=!previewPlans.length class=text-[1rem] text-[var(--text-color-secondary)]>
Nenhum plano visível para este filtro.
</div>
<div v-else class="mt-6 grid grid-cols-1 md:grid-cols-3 gap-6">
<div v-else class=mt-6 grid grid-cols-1 md:grid-cols-3 gap-6>
<div
v-for="p in previewPlans"
:key="p.plan_id"
:class="[
'relative rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden',
v-for=p in previewPlans
:key=p.plan_id
:class=[
'relative rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden',
'shadow-sm transition-transform',
p.is_featured ? 'md:-translate-y-2 md:scale-[1.02] ring-1 ring-emerald-500/25' : ''
]"
]
>
<div class="h-2 w-full opacity-50 bg-[var(--surface-100)]" />
<div class=h-2 w-full opacity-50 bg-[var(--surface-100)] />
<div class="p-6">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-2 flex-wrap">
<Tag :value="targetLabel(normalizeTarget(p))" :severity="targetSeverity(normalizeTarget(p))" rounded />
<div class=p-6>
<div class=flex items-center justify-between gap-3>
<div class=flex items-center gap-2 flex-wrap>
<Tag :value=targetLabel(normalizeTarget(p)) :severity=targetSeverity(normalizeTarget(p)) rounded />
<Tag
v-if="p.badge || p.is_featured"
:value="p.badge || 'Destaque'"
:severity="p.is_featured ? 'success' : 'secondary'"
v-if=p.badge || p.is_featured
:value=p.badge || 'Destaque'
:severity=p.is_featured ? 'success' : 'secondary'
rounded
/>
</div>
<span class="text-xs text-color-secondary">{{ p.plan_key }}</span>
<div class=text-[1rem] text-[var(--text-color-secondary)]>{{ p.plan_key }}</div>
</div>
<div class="mt-4">
<template v-if="priceDisplayForPreview(p).kind === 'paid'">
<div class="text-4xl font-semibold leading-none">
<div class=mt-4>
<template v-if=priceDisplayForPreview(p).kind === 'paid'>
<div class=text-4xl font-semibold leading-none>
{{ priceDisplayForPreview(p).main }}
</div>
<div class="text-sm text-color-secondary mt-1">
<div class=text-[1rem] text-[var(--text-color-secondary)] mt-1>
{{ priceDisplayForPreview(p).sub }}
</div>
</template>
<template v-else-if="priceDisplayForPreview(p).kind === 'free'">
<div class="text-4xl font-semibold leading-none">
<template v-else-if=priceDisplayForPreview(p).kind === 'free'>
<div class=text-4xl font-semibold leading-none>
{{ priceDisplayForPreview(p).main }}
</div>
<div class="text-sm text-color-secondary mt-1">
<div class=text-[1rem] text-[var(--text-color-secondary)] mt-1>
{{ billingInterval === 'year' ? 'no anual' : 'no mensal' }}
</div>
</template>
<template v-else>
<div class="text-2xl font-semibold leading-none">
<div class=text-2xl font-semibold leading-none>
{{ priceDisplayForPreview(p).main }}
</div>
<div class="text-sm text-color-secondary mt-1">
<div class=text-[1rem] text-[var(--text-color-secondary)] mt-1>
Fale com a equipe para montar o plano ideal.
</div>
</template>
</div>
<p class="text-color-secondary mt-3 min-h-[44px]">
<div class=text-[var(--text-color-secondary)] mt-3 min-h-[44px]>
{{ p.public_description || '—' }}
</p>
<Button
class="mt-5 w-full"
:label="p.is_featured ? 'Começar agora' : 'Selecionar plano'"
:severity="p.is_featured ? 'success' : 'secondary'"
:outlined="!p.is_featured"
/>
<div class="mt-6">
<div class="border-t border-dashed border-[var(--surface-border)]" />
</div>
<ul v-if="p.bullets?.length" class="mt-4 space-y-2">
<li v-for="b in p.bullets" :key="b.id" class="flex items-start gap-2">
<i class="pi pi-check mt-1 text-sm text-color-secondary"></i>
<span :class="['text-sm leading-snug', b.highlight ? 'font-semibold' : '']">
<Button
class=mt-5 w-full
:label=p.is_featured ? 'Começar agora' : 'Selecionar plano'
:severity=p.is_featured ? 'success' : 'secondary'
:outlined=!p.is_featured
/>
<div class=mt-6>
<div class=border-t border-dashed border-[var(--surface-border)] />
</div>
<ul v-if=p.bullets?.length class=mt-4 space-y-2>
<li v-for=b in p.bullets :key=b.id class=flex items-start gap-2>
<i class=pi pi-check mt-1 text-[1rem] text-[var(--text-color-secondary)]></i>
<span :class=['text-[1rem] leading-snug', b.highlight ? 'font-semibold' : '']>
{{ b.text }}
</span>
</li>
</ul>
<div v-else class="mt-4 text-sm text-color-secondary">
<div v-else class=mt-4 text-[1rem] text-[var(--text-color-secondary)]>
Nenhum benefício configurado.
</div>
</div>
</div>
</div>
<div v-if="previewPricePolicy === 'hide'" class="mt-6 text-xs text-color-secondary">
<div v-if=previewPricePolicy === 'hide' class=mt-6 text-[1rem] text-[var(--text-color-secondary)]>
Observação: planos sem preço não aparecem no preview (política atual).
Para exibir como Sob consulta, mude acima.
</div>
</div>
</div>
</div>
</div><!-- /px-4 pb-4 -->
</div><!-- /content -->
<!-- Dialog principal ( sem drag: removemos draggable) -->
<Dialog
v-model:visible="showDlg"
v-model:visible=showDlg
modal
header="Editar vitrine"
:style="{ width: '820px' }"
:closable="!saving"
:dismissableMask="!saving"
:draggable="false"
header=Editar vitrine
:style={ width: '820px' }
:closable=!saving
:dismissableMask=!saving
:draggable=false
>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="flex flex-col gap-4">
<div class=grid grid-cols-1 md:grid-cols-2 gap-6>
<div class=flex flex-col gap-4>
<!-- Nome público (FloatLabel + Icon) -->
<FloatLabel variant="on">
<FloatLabel variant=on>
<IconField>
<InputIcon class="pi pi-tag" />
<InputIcon class=pi pi-tag />
<InputText
id="pp-public-name"
v-model.trim="form.public_name"
class="w-full"
variant="filled"
:disabled="saving"
autocomplete="off"
id=pp-public-name
v-model.trim=form.public_name
class=w-full
variant=filled
:disabled=saving
autocomplete=off
autofocus
@keydown.enter.prevent="save"
@keydown.enter.prevent=save
/>
</IconField>
<label for="pp-public-name">Nome público *</label>
<label for=pp-public-name>Nome público *</label>
</FloatLabel>
<!-- Descrição pública -->
<FloatLabel variant="on">
<FloatLabel variant=on>
<IconField>
<InputIcon class="pi pi-align-left" />
<InputIcon class=pi pi-align-left />
<Textarea
id="pp-public-desc"
v-model.trim="form.public_description"
class="w-full"
rows="3"
id=pp-public-desc
v-model.trim=form.public_description
class=w-full
rows=3
autoResize
:disabled="saving"
:disabled=saving
/>
</IconField>
<label for="pp-public-desc">Descrição pública</label>
<label for=pp-public-desc>Descrição pública</label>
</FloatLabel>
<!-- Badge -->
<FloatLabel variant="on">
<FloatLabel variant=on>
<IconField>
<InputIcon class="pi pi-bookmark" />
<InputIcon class=pi pi-bookmark />
<InputText
id="pp-badge"
v-model.trim="form.badge"
class="w-full"
variant="filled"
:disabled="saving"
autocomplete="off"
@keydown.enter.prevent="save"
id=pp-badge
v-model.trim=form.badge
class=w-full
variant=filled
:disabled=saving
autocomplete=off
@keydown.enter.prevent=save
/>
</IconField>
<label for="pp-badge">Badge (opcional)</label>
<label for=pp-badge>Badge (opcional)</label>
</FloatLabel>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class=grid grid-cols-1 md:grid-cols-2 gap-4>
<!-- Ordem -->
<FloatLabel variant="on">
<FloatLabel variant=on>
<IconField>
<InputIcon class="pi pi-sort-amount-up-alt" />
<InputIcon class=pi pi-sort-amount-up-alt />
<InputNumber
id="pp-sort"
v-model="form.sort_order"
class="w-full"
inputClass="w-full"
:disabled="saving"
id=pp-sort
v-model=form.sort_order
class=w-full
inputClass=w-full
:disabled=saving
/>
</IconField>
<label for="pp-sort">Ordem</label>
<label for=pp-sort>Ordem</label>
</FloatLabel>
<div class="flex flex-col gap-3 pt-2">
<div class="flex items-center gap-2">
<Checkbox v-model="form.is_visible" :binary="true" :disabled="saving" />
<div class=flex flex-col gap-3 pt-2>
<div class=flex items-center gap-2>
<Checkbox v-model=form.is_visible :binary=true :disabled=saving />
<label>Visível no público</label>
</div>
<div class="flex items-center gap-2">
<Checkbox v-model="form.is_featured" :binary="true" :disabled="saving" />
<div class=flex items-center gap-2>
<Checkbox v-model=form.is_featured :binary=true :disabled=saving />
<label>Destaque</label>
</div>
</div>
@@ -854,24 +849,24 @@ onBeforeUnmount(() => {
<!-- bullets -->
<div>
<div class="flex items-center justify-between mb-3">
<div class="font-semibold">Benefícios (bullets)</div>
<Button label="Adicionar" icon="pi pi-plus" size="small" :disabled="saving || bulletSaving" @click="openBulletCreate" />
<div class=flex items-center justify-between mb-3>
<div class=font-semibold>Benefícios (bullets)</div>
<Button label=Adicionar icon=pi pi-plus size=small :disabled=saving || bulletSaving @click=openBulletCreate />
</div>
<DataTable :value="bullets" dataKey="id" stripedRows responsiveLayout="scroll">
<Column field="text" header="Texto" />
<Column field="sort_order" header="Ordem" style="width: 7rem" />
<Column header="Destaque" style="width: 8rem">
<template #body="{ data }">
<DataTable :value=bullets dataKey=id stripedRows responsiveLayout=scroll>
<Column field=text header=Texto />
<Column field=sort_order header=Ordem style=width: 7rem />
<Column header=Destaque style=width: 8rem>
<template #body={ data }>
<span>{{ data.highlight ? 'Sim' : 'Não' }}</span>
</template>
</Column>
<Column header="Ações" style="width: 9rem">
<template #body="{ data }">
<div class="flex gap-2">
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" :disabled="saving || bulletSaving" @click="openBulletEdit(data)" />
<Button icon="pi pi-trash" severity="danger" outlined size="small" :disabled="saving || bulletSaving" @click="askDeleteBullet(data)" />
<Column header=Ações style=width: 9rem>
<template #body={ data }>
<div class=flex gap-2>
<Button icon=pi pi-pencil severity=secondary outlined size=small :disabled=saving || bulletSaving @click=openBulletEdit(data) />
<Button icon=pi pi-trash severity=danger outlined size=small :disabled=saving || bulletSaving @click=askDeleteBullet(data) />
</div>
</template>
</Column>
@@ -880,129 +875,62 @@ onBeforeUnmount(() => {
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined :disabled="saving" @click="showDlg = false" />
<Button label="Salvar" icon="pi pi-check" :loading="saving" @click="save" />
<Button label=Cancelar severity=secondary outlined :disabled=saving @click=showDlg = false />
<Button label=Salvar icon=pi pi-check :loading=saving @click=save />
</template>
</Dialog>
<!-- Dialog bullet ( sem drag + inputs padronizados) -->
<Dialog
v-model:visible="showBulletDlg"
v-model:visible=showBulletDlg
modal
:header="bulletIsEdit ? 'Editar benefício' : 'Novo benefício'"
:style="{ width: '560px' }"
:closable="!bulletSaving"
:dismissableMask="!bulletSaving"
:draggable="false"
:header=bulletIsEdit ? 'Editar benefício' : 'Novo benefício'
:style={ width: '560px' }
:closable=!bulletSaving
:dismissableMask=!bulletSaving
:draggable=false
>
<div class="flex flex-col gap-4">
<FloatLabel variant="on">
<div class=flex flex-col gap-4>
<FloatLabel variant=on>
<IconField>
<InputIcon class="pi pi-list" />
<InputIcon class=pi pi-list />
<Textarea
id="pp-bullet-text"
v-model.trim="bulletForm.text"
class="w-full"
rows="3"
id=pp-bullet-text
v-model.trim=bulletForm.text
class=w-full
rows=3
autoResize
:disabled="bulletSaving"
:disabled=bulletSaving
/>
</IconField>
<label for="pp-bullet-text">Texto *</label>
<label for=pp-bullet-text>Texto *</label>
</FloatLabel>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<FloatLabel variant="on">
<div class=grid grid-cols-1 md:grid-cols-2 gap-4>
<FloatLabel variant=on>
<IconField>
<InputIcon class="pi pi-sort-numeric-up" />
<InputIcon class=pi pi-sort-numeric-up />
<InputNumber
id="pp-bullet-order"
v-model="bulletForm.sort_order"
class="w-full"
inputClass="w-full"
:disabled="bulletSaving"
id=pp-bullet-order
v-model=bulletForm.sort_order
class=w-full
inputClass=w-full
:disabled=bulletSaving
/>
</IconField>
<label for="pp-bullet-order">Ordem</label>
<label for=pp-bullet-order>Ordem</label>
</FloatLabel>
<div class="flex items-center gap-2 pt-7">
<Checkbox v-model="bulletForm.highlight" :binary="true" :disabled="bulletSaving" />
<div class=flex items-center gap-2 pt-7>
<Checkbox v-model=bulletForm.highlight :binary=true :disabled=bulletSaving />
<label>Destaque</label>
</div>
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined :disabled="bulletSaving" @click="showBulletDlg = false" />
<Button :label="bulletIsEdit ? 'Salvar' : 'Criar'" icon="pi pi-check" :loading="bulletSaving" @click="saveBullet" />
<Button label=Cancelar severity=secondary outlined :disabled=bulletSaving @click=showBulletDlg = false />
<Button :label=bulletIsEdit ? 'Salvar' : 'Criar' icon=pi pi-check :loading=bulletSaving @click=saveBullet />
</template>
</Dialog>
</div>
</template>
<style scoped>
/* ─── Root ──────────────────────────────────────────────── */
.showcase-root { padding: 1rem; }
@media (min-width: 768px) { .showcase-root { padding: 1.5rem; } }
/* ─── Hero ──────────────────────────────────────────────── */
.showcase-hero-sentinel { height: 1px; }
.showcase-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.5rem;
}
.showcase-hero--stuck {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.showcase-hero__blobs {
position: absolute; inset: 0; pointer-events: none; overflow: hidden;
}
.showcase-hero__blob {
position: absolute; border-radius: 50%; filter: blur(70px);
}
.showcase-hero__blob--1 { width: 20rem; height: 20rem; top: -5rem; right: -4rem; background: rgba(16,185,129,0.12); }
.showcase-hero__blob--2 { width: 18rem; height: 18rem; top: 1rem; left: -5rem; background: rgba(99,102,241,0.09); }
.showcase-hero__inner {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1.25rem; flex-wrap: wrap;
}
.showcase-hero__icon-wrap {
flex-shrink: 0;
width: 4rem; height: 4rem; border-radius: 1.125rem;
border: 2px solid var(--surface-border);
background: var(--surface-ground);
display: grid; place-items: center;
}
.showcase-hero__icon { font-size: 1.5rem; color: var(--text-color); }
.showcase-hero__info { flex: 1; min-width: 0; }
.showcase-hero__title {
font-size: 1.25rem; font-weight: 700; letter-spacing: -0.025em;
color: var(--text-color); line-height: 1.2;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.showcase-hero__sub {
font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 4px; line-height: 1.5;
}
.showcase-hero__actions--desktop {
display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
}
.showcase-hero__actions--mobile { display: none; }
@media (max-width: 1199px) {
.showcase-hero__actions--desktop { display: none; }
.showcase-hero__actions--mobile { display: flex; }
}
</style>

View File

@@ -316,41 +316,28 @@ onBeforeUnmount(() => {
<template>
<Toast />
<!-- Info decorativa (scrolls away naturalmente) -->
<div class="flex items-start gap-4 px-4 pb-3">
<div class="events-hero__icon-wrap">
<i class="pi pi-history events-hero__icon" />
</div>
<div class="events-hero__sub">
Auditoria read-only das mudanças de plano e status. Exibe até 500 eventos mais recentes.
<template v-if="!loading">
{{ totalCount }} evento(s) {{ changedCount }} troca(s) de plano
</template>
</div>
</div>
<!-- Sentinel -->
<div ref="sentinelRef" class="h-px" />
<!-- sentinel -->
<div ref="sentinelRef" style="height: 1px; pointer-events: none;" />
<!-- hero -->
<!-- Hero sticky -->
<div
ref="heroRef"
class="events-hero"
:class="{ 'events-hero--stuck': heroStuck }"
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class="events-hero__blobs" aria-hidden="true">
<div class="events-hero__blob events-hero__blob--1" />
<div class="events-hero__blob events-hero__blob--2" />
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-amber-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-orange-400/10" />
</div>
<div class="events-hero__inner">
<!-- Título -->
<div class="events-hero__info min-w-0">
<div class="events-hero__title">Histórico de assinaturas</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Histórico de assinaturas</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Auditoria read-only das mudanças de plano e status.</div>
</div>
<!-- Ações desktop ( 1200px) -->
<div class="events-hero__actions events-hero__actions--desktop">
<div class="hidden xl:flex items-center gap-2 flex-wrap">
<Button
label="Voltar para assinaturas"
icon="pi pi-arrow-left"
@@ -380,7 +367,7 @@ onBeforeUnmount(() => {
</div>
<!-- Ações mobile (< 1200px) -->
<div class="events-hero__actions--mobile">
<div class="flex xl:hidden">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
@@ -394,20 +381,20 @@ onBeforeUnmount(() => {
</div>
<!-- content -->
<div class="px-4 pb-4">
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Card foco -->
<div
v-if="isFocused"
class="mb-3 overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] shadow-sm"
class="overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)]"
>
<div class="flex flex-wrap items-center justify-between gap-3 p-4 md:p-5">
<div class="flex flex-wrap items-center justify-between gap-3 p-5">
<div class="min-w-0">
<div class="text-lg font-semibold leading-none">Eventos em foco</div>
<div class="text-[1rem] font-semibold leading-none text-[var(--text-color)]">Eventos em foco</div>
<div class="mt-1 flex flex-wrap items-center gap-2">
<Tag value="Filtro ativo" severity="warning" rounded />
<small class="text-color-secondary break-all">
<div class="text-[1rem] text-[var(--text-color-secondary)] break-all">
{{ route.query.q }}
</small>
</div>
</div>
</div>
@@ -424,7 +411,7 @@ onBeforeUnmount(() => {
</div>
<!-- busca -->
<div class="mb-4">
<div>
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
@@ -446,7 +433,6 @@ onBeforeUnmount(() => {
:loading="loading"
stripedRows
responsiveLayout="scroll"
class="events-table"
:rowHover="true"
paginator
:rows="15"
@@ -475,9 +461,9 @@ onBeforeUnmount(() => {
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ ownerKeyFromEvent(data) }}</span>
<small class="text-color-secondary">
<div class="text-[1rem] text-[var(--text-color-secondary)]">
{{ displayOwner(data) }}
</small>
</div>
</div>
</template>
</Column>
@@ -501,7 +487,7 @@ onBeforeUnmount(() => {
<Column field="subscription_id" header="Subscription" style="min-width: 22rem">
<template #body="{ data }">
<span class="font-mono text-sm">{{ data.subscription_id }}</span>
<span class="font-mono text-[1rem]">{{ data.subscription_id }}</span>
</template>
</Column>
@@ -527,87 +513,14 @@ onBeforeUnmount(() => {
</Column>
<template #empty>
<div class="p-4 text-color-secondary">
<div class="p-4 text-[var(--text-color-secondary)]">
Nenhum evento encontrado com os filtros atuais.
</div>
</template>
</DataTable>
<div class="text-color-secondary mt-3 text-sm">
<div class="text-[1rem] text-[var(--text-color-secondary)]">
Mostrando até 500 eventos mais recentes.
</div>
</div>
</template>
<style scoped>
.events-table :deep(.p-paginator) {
border-top: 1px solid var(--surface-border);
}
.events-table :deep(.p-datatable-tbody > tr > td) {
vertical-align: top;
}
.font-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
/* Hero */
.events-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.5rem;
margin: 1rem;
}
.events-hero--stuck {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.events-hero__blobs {
position: absolute; inset: 0; pointer-events: none; overflow: hidden;
}
.events-hero__blob {
position: absolute; border-radius: 50%; filter: blur(70px);
}
.events-hero__blob--1 { width: 20rem; height: 20rem; top: -5rem; right: -4rem; background: rgba(251,191,36,0.12); }
.events-hero__blob--2 { width: 18rem; height: 18rem; top: 1rem; left: -5rem; background: rgba(249,115,22,0.09); }
.events-hero__inner {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1.25rem; flex-wrap: wrap;
}
.events-hero__icon-wrap {
flex-shrink: 0;
width: 4rem; height: 4rem; border-radius: 1.125rem;
border: 2px solid var(--surface-border);
background: var(--surface-ground);
display: grid; place-items: center;
}
.events-hero__icon { font-size: 1.5rem; color: var(--text-color); }
.events-hero__info { flex: 1; min-width: 0; }
.events-hero__title {
font-size: 1.25rem; font-weight: 700; letter-spacing: -0.025em;
color: var(--text-color); line-height: 1.2;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.events-hero__sub {
font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 4px; line-height: 1.5;
}
.events-hero__actions--desktop {
display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
}
.events-hero__actions--mobile { display: none; }
@media (max-width: 1199px) {
.events-hero__actions--desktop { display: none; }
.events-hero__actions--mobile { display: flex; }
}
</style>

View File

@@ -401,39 +401,28 @@ onBeforeUnmount(() => {
<Toast />
<ConfirmDialog />
<!-- Info decorativa (scrolls away naturalmente) -->
<div class="flex items-start gap-4 px-4 pb-3">
<div class="health-hero__icon-wrap">
<i class="pi pi-shield health-hero__icon" />
</div>
<div class="health-hero__sub">
Terapeutas: divergências entre plano (esperado) e entitlements (atual).
Clínicas: exceções comerciais (features liberadas manualmente fora do plano).
</div>
</div>
<!-- Sentinel -->
<div ref="sentinelRef" class="h-px" />
<!-- sentinel -->
<div ref="sentinelRef" style="height: 1px; pointer-events: none;" />
<!-- hero -->
<!-- Hero sticky -->
<div
ref="heroRef"
class="health-hero"
:class="{ 'health-hero--stuck': heroStuck }"
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class="health-hero__blobs" aria-hidden="true">
<div class="health-hero__blob health-hero__blob--1" />
<div class="health-hero__blob health-hero__blob--2" />
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-72 h-72 -bottom-20 right-24 bg-fuchsia-400/10" />
</div>
<div class="health-hero__inner">
<!-- Título -->
<div class="health-hero__info min-w-0">
<div class="health-hero__title">Saúde das Assinaturas</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Saúde das Assinaturas</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Terapeutas: divergências entre plano (esperado) e entitlements (atual). Clínicas: exceções comerciais (features liberadas manualmente fora do plano).</div>
</div>
<!-- Ações desktop ( 1200px) -->
<div class="health-hero__actions health-hero__actions--desktop">
<div class="hidden xl:flex items-center gap-2 shrink-0">
<Button
label="Recarregar"
icon="pi pi-refresh"
@@ -467,7 +456,7 @@ onBeforeUnmount(() => {
</div>
<!-- Ações mobile (< 1200px) -->
<div class="health-hero__actions--mobile">
<div class="flex xl:hidden shrink-0">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
@@ -481,9 +470,9 @@ onBeforeUnmount(() => {
</div>
<!-- content -->
<div class="px-4 pb-4">
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- busca -->
<div class="mb-4">
<div>
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
@@ -505,7 +494,7 @@ onBeforeUnmount(() => {
<!-- Terapeutas (Personal) -->
<!-- ===================================================== -->
<TabPanel header="Terapeutas (Pessoal)">
<div class="surface-100 border-round p-3 mb-4">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-3 mb-4">
<div class="flex flex-wrap gap-2 items-center justify-content-between">
<div class="flex gap-2 items-center flex-wrap">
<Tag :value="`Divergências: ${totalPersonal}`" severity="secondary" />
@@ -514,7 +503,7 @@ onBeforeUnmount(() => {
<Tag v-if="totalPersonalWithoutOwner > 0" :value="`Sem owner: ${totalPersonalWithoutOwner}`" severity="warn" />
</div>
<div class="text-color-secondary text-sm">
<div class="text-[1rem] text-[var(--text-color-secondary)]">
<span class="font-medium">Faltando</span>: o plano exige, mas não está ativo ·
<span class="font-medium">Inesperado</span>: está ativo sem constar no plano
</div>
@@ -545,9 +534,9 @@ onBeforeUnmount(() => {
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ data.feature_key }}</span>
<small class="text-color-secondary">
<div class="text-[1rem] text-[var(--text-color-secondary)]">
{{ helpForMismatch(data.mismatch_type) || '—' }}
</small>
</div>
</div>
</template>
</Column>
@@ -588,12 +577,12 @@ onBeforeUnmount(() => {
<Divider class="my-5" />
<Message severity="info" class="mt-4">
<div class="text-sm line-height-3">
<p class="mb-0">
<div class="text-[1rem] line-height-3">
<div class="mb-0">
<span class="font-semibold">Dica:</span>
Se você alterar o plano e o acesso não refletir imediatamente, esta aba exibirá as divergências entre o plano ativo e os entitlements atuais.
A ação <span class="font-medium">Corrigir</span> reconstrói os entitlements do owner com base no plano vigente e elimina inconsistências.
</p>
</div>
</div>
</Message>
</TabPanel>
@@ -602,13 +591,13 @@ onBeforeUnmount(() => {
<!-- Clínicas (Tenant) -->
<!-- ===================================================== -->
<TabPanel header="Clínicas (Exceções)">
<div class="surface-100 border-round p-3 mb-4">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-3 mb-4">
<div class="flex flex-wrap gap-2 items-center justify-content-between">
<div class="flex gap-2 items-center flex-wrap">
<Tag :value="`Exceções ativas: ${totalClinic}`" severity="info" />
</div>
<div class="text-color-secondary text-sm">
<div class="text-[1rem] text-[var(--text-color-secondary)]">
Exceções comerciais: features liberadas manualmente fora do plano. Útil para testes, suporte e acordos.
</div>
</div>
@@ -627,7 +616,7 @@ onBeforeUnmount(() => {
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ data.tenant_name || data.tenant_id }}</span>
<small class="text-color-secondary">{{ data.tenant_name ? data.tenant_id : '—' }}</small>
<div class="text-[1rem] text-[var(--text-color-secondary)]">{{ data.tenant_name ? data.tenant_id : '—' }}</div>
</div>
</template>
</Column>
@@ -642,9 +631,9 @@ onBeforeUnmount(() => {
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ data.feature_key }}</span>
<small class="text-color-secondary">
<div class="text-[1rem] text-[var(--text-color-secondary)]">
{{ helpForException() }}
</small>
</div>
</div>
</template>
</Column>
@@ -684,83 +673,22 @@ onBeforeUnmount(() => {
<Divider class="my-5" />
<Message severity="info" class="mt-4">
<div class="text-sm line-height-3">
<p class="mb-2">
<div class="text-[1rem] line-height-3">
<div class="mb-2">
<span class="font-semibold">Observação:</span>
Exceção é uma escolha de negócio. Quando ativa, pode liberar acesso mesmo que o plano não permita.
Utilize <span class="font-medium">Remover exceção</span> quando a liberação deixar de fazer sentido.
</p>
</div>
<p class="mb-0">
<div class="mb-0">
<span class="font-semibold">Dica:</span>
Exceções comerciais liberam recursos fora do plano.
Se o acesso não refletir como esperado, verifique se existe uma exceção ativa para esta clínica.
A ação <span class="font-medium">Remover exceção</span> restaura o comportamento estritamente definido pelo plano.
</p>
</div>
</div>
</Message>
</TabPanel>
</TabView>
</div>
</template>
<style scoped>
/* Hero */
.health-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.5rem;
margin: 1rem;
}
.health-hero--stuck {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.health-hero__blobs {
position: absolute; inset: 0; pointer-events: none; overflow: hidden;
}
.health-hero__blob {
position: absolute; border-radius: 50%; filter: blur(70px);
}
.health-hero__blob--1 { width: 20rem; height: 20rem; top: -5rem; right: -4rem; background: rgba(248,113,113,0.12); }
.health-hero__blob--2 { width: 18rem; height: 18rem; top: 1rem; left: -5rem; background: rgba(251,113,133,0.09); }
.health-hero__inner {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1.25rem; flex-wrap: wrap;
}
.health-hero__icon-wrap {
flex-shrink: 0;
width: 4rem; height: 4rem; border-radius: 1.125rem;
border: 2px solid var(--surface-border);
background: var(--surface-ground);
display: grid; place-items: center;
}
.health-hero__icon { font-size: 1.5rem; color: var(--text-color); }
.health-hero__info { flex: 1; min-width: 0; }
.health-hero__title {
font-size: 1.25rem; font-weight: 700; letter-spacing: -0.025em;
color: var(--text-color); line-height: 1.2;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.health-hero__sub {
font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 4px; line-height: 1.5;
}
.health-hero__actions--desktop {
display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
}
.health-hero__actions--mobile { display: none; }
@media (max-width: 1199px) {
.health-hero__actions--desktop { display: none; }
.health-hero__actions--mobile { display: flex; }
}
</style>

View File

@@ -417,39 +417,28 @@ onBeforeUnmount(() => {
<Toast />
<ConfirmDialog />
<!-- Info decorativa (scrolls away naturalmente) -->
<div class="flex items-start gap-4 px-4 pb-3">
<div class="subs-hero__icon-wrap">
<i class="pi pi-credit-card subs-hero__icon" />
</div>
<div class="subs-hero__sub">
Painel operacional do SaaS: revise plano, status e período (Clínica x Terapeuta) com segurança.
<template v-if="!loading">
<br />{{ totalCount }} registro(s) {{ activeCount }} ativa(s)
</template>
</div>
</div>
<!-- Sentinel -->
<div ref="sentinelRef" class="h-px" />
<!-- sentinel -->
<div ref="sentinelRef" style="height: 1px; pointer-events: none;" />
<!-- hero -->
<!-- Hero sticky -->
<div
ref="heroRef"
class="subs-hero"
:class="{ 'subs-hero--stuck': heroStuck }"
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class="subs-hero__blob subs-hero__blob--1" />
<div class="subs-hero__blob subs-hero__blob--2" />
<div class="subs-hero__inner">
<!-- Título -->
<div class="subs-hero__info min-w-0">
<div class="subs-hero__title">Assinaturas</div>
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-72 h-72 -bottom-20 right-24 bg-fuchsia-400/10" />
</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Assinaturas</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Painel operacional do SaaS: revise plano, status e período (Clínica x Terapeuta) com segurança.</div>
</div>
<!-- Ações desktop ( 1200px) -->
<div class="subs-hero__actions subs-hero__actions--desktop">
<div class="hidden xl:flex items-center gap-2 shrink-0">
<SelectButton
v-model="typeFilter"
:options="typeOptions"
@@ -471,7 +460,7 @@ onBeforeUnmount(() => {
</div>
<!-- Ações mobile (< 1200px) -->
<div class="subs-hero__actions--mobile">
<div class="flex xl:hidden shrink-0">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
@@ -485,13 +474,13 @@ onBeforeUnmount(() => {
</div>
<!-- content -->
<div class="px-4 pb-4">
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Header foco -->
<div v-if="isFocused" class="mb-3 p-3 surface-100 border-round">
<div v-if="isFocused" class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
<div class="flex align-items-center justify-content-between gap-3 flex-wrap">
<div>
<div class="text-lg font-semibold">Assinatura em foco</div>
<small class="text-color-secondary">Filtro: {{ route.query.q }}</small>
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Assinatura em foco</div>
<div class="text-[1rem] text-[var(--text-color-secondary)]">Filtro: {{ route.query.q }}</div>
</div>
<Button
@@ -506,7 +495,7 @@ onBeforeUnmount(() => {
</div>
<!-- busca -->
<div class="mb-4">
<div>
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
@@ -528,7 +517,6 @@ onBeforeUnmount(() => {
:loading="loading"
stripedRows
responsiveLayout="scroll"
class="subs-table"
:rowHover="true"
paginator
:rows="15"
@@ -546,9 +534,9 @@ onBeforeUnmount(() => {
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ ownerKey(data) }}</span>
<small class="text-color-secondary">
<div class="text-[1rem] text-[var(--text-color-secondary)]">
{{ data.tenant_id ? `tenant_id: ${data.tenant_id}` : `user_id: ${data.user_id || '—'}` }}
</small>
</div>
</div>
</template>
</Column>
@@ -585,9 +573,9 @@ onBeforeUnmount(() => {
<template #body="{ data }">
<div>
<div>{{ fmtDate(data.current_period_start) }}</div>
<small class="text-color-secondary">
<div class="text-[1rem] text-[var(--text-color-secondary)]">
até {{ fmtDate(data.current_period_end) }}
</small>
</div>
</div>
</template>
</Column>
@@ -642,74 +630,10 @@ onBeforeUnmount(() => {
</Column>
<template #empty>
<div class="p-4 text-color-secondary">
<div class="p-4 text-[var(--text-color-secondary)]">
Nenhuma assinatura encontrada com os filtros atuais.
</div>
</template>
</DataTable>
</div>
</template>
<style scoped>
.subs-table :deep(.p-paginator) {
border-top: 1px solid var(--surface-border);
}
.subs-table :deep(.p-datatable-tbody > tr > td) {
vertical-align: top;
}
/* Hero */
.subs-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.5rem;
margin: 1rem;
}
.subs-hero--stuck {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.subs-hero__blob {
position: absolute; border-radius: 50%; filter: blur(70px);
}
.subs-hero__blob--1 { width: 20rem; height: 20rem; top: -5rem; right: -4rem; background: rgba(96,165,250,0.12); }
.subs-hero__blob--2 { width: 18rem; height: 18rem; top: 1rem; left: -5rem; background: rgba(99,102,241,0.09); }
.subs-hero__inner {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1.25rem; flex-wrap: wrap;
}
.subs-hero__icon-wrap {
flex-shrink: 0;
width: 4rem; height: 4rem; border-radius: 1.125rem;
border: 2px solid var(--surface-border);
background: var(--surface-ground);
display: grid; place-items: center;
}
.subs-hero__icon { font-size: 1.5rem; color: var(--text-color); }
.subs-hero__info { flex: 1; min-width: 0; }
.subs-hero__title {
font-size: 1.25rem; font-weight: 700; letter-spacing: -0.025em;
color: var(--text-color); line-height: 1.2;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.subs-hero__sub {
font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 4px; line-height: 1.5;
}
.subs-hero__actions--desktop {
display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
}
.subs-hero__actions--mobile { display: none; }
@media (max-width: 1199px) {
.subs-hero__actions--desktop { display: none; }
.subs-hero__actions--mobile { display: flex; }
}
</style>

View File

@@ -240,17 +240,29 @@ function sessionStatusLabel (session) {
</script>
<template>
<div class="saas-support p-4 md:p-6">
<Toast />
<!-- Cabeçalho -->
<div class="flex items-center gap-3 mb-5">
<div class="flex items-center justify-center w-10 h-10 rounded-xl bg-orange-100 dark:bg-orange-900/30">
<i class="pi pi-headphones text-orange-600 dark:text-orange-400 text-lg" />
<!-- Sentinel -->
<div class="h-px" />
<!-- Hero sticky -->
<div
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-orange-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-fuchsia-400/10" />
</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-3 min-w-0">
<div class="flex items-center justify-center w-10 h-10 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] flex-shrink-0">
<i class="pi pi-headphones text-[var(--text-color)]" />
</div>
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Suporte Técnico</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Gere e gerencie links seguros de acesso em modo debug</div>
</div>
<div class="flex-1">
<h1 class="text-xl font-bold m-0">Suporte Técnico</h1>
<p class="text-sm text-surface-500 m-0">Gere e gerencie links seguros de acesso em modo debug</p>
</div>
<Tag
v-if="activeSessionCount > 0"
@@ -258,6 +270,10 @@ function sessionStatusLabel (session) {
severity="warning"
/>
</div>
</div>
<!-- content -->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Tabs -->
<TabView @tab-change="onTabChange">
@@ -267,15 +283,15 @@ function sessionStatusLabel (session) {
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5 pt-2">
<!-- Formulário -->
<div class="card">
<h2 class="text-base font-semibold flex items-center gap-2 m-0 mb-4">
<i class="pi pi-plus-circle text-primary" />
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="text-[1rem] font-semibold flex items-center gap-2 mb-4">
<i class="pi pi-plus-circle text-[var(--primary-color)]" />
Configurar acesso de suporte
</h2>
</div>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label class="text-sm font-medium">Selecionar Cliente (Tenant)</label>
<label class="text-[1rem] font-medium">Selecionar Cliente (Tenant)</label>
<Select
v-model="selectedTenantId"
:options="tenants"
@@ -290,7 +306,7 @@ function sessionStatusLabel (session) {
</div>
<div class="flex flex-col gap-1">
<label class="text-sm font-medium">Duração do Acesso</label>
<label class="text-[1rem] font-medium">Duração do Acesso</label>
<Select
v-model="ttlMinutes"
:options="ttlOptions"
@@ -301,9 +317,9 @@ function sessionStatusLabel (session) {
</div>
<div class="flex flex-col gap-1">
<label class="text-sm font-medium">
<label class="text-[1rem] font-medium">
Nota / Motivo
<span class="text-surface-400 font-normal">(opcional)</span>
<span class="text-[var(--text-color-secondary)] font-normal">(opcional)</span>
</label>
<InputText
v-model="sessionNote"
@@ -325,45 +341,45 @@ function sessionStatusLabel (session) {
</div>
<!-- URL Gerada -->
<div class="card">
<h2 class="text-base font-semibold flex items-center gap-2 m-0 mb-4">
<i class="pi pi-link text-primary" />
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="text-[1rem] font-semibold flex items-center gap-2 mb-4">
<i class="pi pi-link text-[var(--primary-color)]" />
URL Gerada
</h2>
</div>
<div v-if="generatedUrl" class="flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label class="text-sm font-medium">Link de Acesso</label>
<label class="text-[1rem] font-medium">Link de Acesso</label>
<div class="flex gap-2">
<InputText :value="generatedUrl" readonly class="flex-1 font-mono text-xs" />
<InputText :value="generatedUrl" readonly class="flex-1 font-mono text-[1rem]" />
<Button icon="pi pi-copy" severity="secondary" outlined v-tooltip.top="'Copiar URL'" @click="copyUrl(generatedUrl)" />
</div>
</div>
<div class="flex items-center gap-2 text-sm">
<div class="flex items-center gap-2 text-[1rem]">
<i class="pi pi-clock text-orange-500" />
<span class="text-surface-500">Expira em:</span>
<span class="text-[var(--text-color-secondary)]">Expira em:</span>
<strong>{{ expiresLabel }}</strong>
</div>
<div class="flex items-center gap-2 text-xs text-surface-400 font-mono">
<div class="flex items-center gap-2 text-[1rem] text-[var(--text-color-secondary)] font-mono">
<i class="pi pi-key" />
<span>{{ tokenPreview }}</span>
</div>
<div v-if="sessionNote" class="flex items-start gap-2 text-sm text-surface-500">
<div v-if="sessionNote" class="flex items-start gap-2 text-[1rem] text-[var(--text-color-secondary)]">
<i class="pi pi-comment mt-0.5 flex-shrink-0" />
<span class="italic">{{ sessionNote }}</span>
</div>
<Message severity="info" :closable="false" class="text-sm">
Envie este link ao terapeuta ou acesse diretamente para monitorar os logs da agenda em tempo real.
<Message severity="info" :closable="false">
<div class="text-[1rem]">Envie este link ao terapeuta ou acesse diretamente para monitorar os logs da agenda em tempo real.</div>
</Message>
</div>
<div v-else class="flex flex-col items-center justify-center py-12 text-surface-400 gap-3">
<div v-else class="flex flex-col items-center justify-center py-12 text-[var(--text-color-secondary)] gap-3">
<i class="pi pi-shield text-4xl opacity-25" />
<span class="text-sm">Nenhuma sessão gerada ainda</span>
<div class="text-[1rem]">Nenhuma sessão gerada ainda</div>
</div>
</div>
</div>
@@ -378,12 +394,12 @@ function sessionStatusLabel (session) {
</span>
</template>
<div class="card mt-2">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 mt-2">
<div class="flex items-center justify-between mb-4">
<h2 class="text-base font-semibold flex items-center gap-2 m-0">
<i class="pi pi-circle-fill text-green-500 text-xs" />
<div class="text-[1rem] font-semibold flex items-center gap-2">
<i class="pi pi-circle-fill text-green-500" />
Sessões em vigor
</h2>
</div>
<Button
icon="pi pi-refresh"
severity="secondary"
@@ -405,15 +421,15 @@ function sessionStatusLabel (session) {
<Column header="Tenant" style="min-width: 200px">
<template #body="{ data }">
<div class="flex flex-col gap-0.5">
<span class="font-medium text-sm">{{ tenantName(data.tenant_id) }}</span>
<span class="font-mono text-xs text-surface-400">{{ data.tenant_id }}</span>
<span class="font-medium text-[1rem]">{{ tenantName(data.tenant_id) }}</span>
<span class="font-mono text-[1rem] text-[var(--text-color-secondary)]">{{ data.tenant_id }}</span>
</div>
</template>
</Column>
<Column header="Token">
<template #body="{ data }">
<span class="font-mono text-xs text-surface-400">{{ data.token.slice(0, 12) }}</span>
<span class="font-mono text-[1rem] text-[var(--text-color-secondary)]">{{ data.token.slice(0, 12) }}</span>
</template>
</Column>
@@ -427,14 +443,14 @@ function sessionStatusLabel (session) {
<Column header="Criada em">
<template #body="{ data }">
<span class="text-sm text-surface-500">{{ formatDate(data.created_at) }}</span>
<span class="text-[1rem] text-[var(--text-color-secondary)]">{{ formatDate(data.created_at) }}</span>
</template>
</Column>
<Column header="Nota">
<template #body="{ data }">
<span v-if="data._note" class="text-xs italic text-surface-500">{{ data._note }}</span>
<span v-else class="text-xs text-surface-300"></span>
<span v-if="data._note" class="text-[1rem] italic text-[var(--text-color-secondary)]">{{ data._note }}</span>
<span v-else class="text-[1rem] text-[var(--text-color-secondary)]"></span>
</template>
</Column>
@@ -452,12 +468,12 @@ function sessionStatusLabel (session) {
<!-- Tab 2: Histórico -->
<TabPanel header="Histórico">
<div class="card mt-2">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 mt-2">
<div class="flex items-center justify-between mb-4">
<h2 class="text-base font-semibold flex items-center gap-2 m-0">
<i class="pi pi-history text-primary" />
<div class="text-[1rem] font-semibold flex items-center gap-2">
<i class="pi pi-history text-[var(--primary-color)]" />
Últimas 100 sessões
</h2>
</div>
<Button
icon="pi pi-refresh"
severity="secondary"
@@ -480,41 +496,41 @@ function sessionStatusLabel (session) {
>
<Column header="Status" style="width: 110px">
<template #body="{ data }">
<Tag :value="sessionStatusLabel(data)" :severity="sessionStatusSeverity(data)" class="text-xs" />
<Tag :value="sessionStatusLabel(data)" :severity="sessionStatusSeverity(data)" />
</template>
</Column>
<Column header="Tenant" style="min-width: 180px">
<template #body="{ data }">
<div class="flex flex-col gap-0.5">
<span class="font-medium text-sm">{{ tenantName(data.tenant_id) }}</span>
<span class="font-mono text-xs text-surface-400">{{ data.tenant_id }}</span>
<span class="font-medium text-[1rem]">{{ tenantName(data.tenant_id) }}</span>
<span class="font-mono text-[1rem] text-[var(--text-color-secondary)]">{{ data.tenant_id }}</span>
</div>
</template>
</Column>
<Column header="Token">
<template #body="{ data }">
<span class="font-mono text-xs text-surface-400">{{ data.token.slice(0, 12) }}</span>
<span class="font-mono text-[1rem] text-[var(--text-color-secondary)]">{{ data.token.slice(0, 12) }}</span>
</template>
</Column>
<Column header="Criada em" sortable field="created_at">
<template #body="{ data }">
<span class="text-sm text-surface-500">{{ formatDate(data.created_at) }}</span>
<span class="text-[1rem] text-[var(--text-color-secondary)]">{{ formatDate(data.created_at) }}</span>
</template>
</Column>
<Column header="Expirava em">
<template #body="{ data }">
<span class="text-sm text-surface-500">{{ formatDate(data.expires_at) }}</span>
<span class="text-[1rem] text-[var(--text-color-secondary)]">{{ formatDate(data.expires_at) }}</span>
</template>
</Column>
<Column header="Nota">
<template #body="{ data }">
<span v-if="data._note" class="text-xs italic text-surface-500">{{ data._note }}</span>
<span v-else class="text-xs text-surface-300"></span>
<span v-if="data._note" class="text-[1rem] italic text-[var(--text-color-secondary)]">{{ data._note }}</span>
<span v-else class="text-[1rem] text-[var(--text-color-secondary)]"></span>
</template>
</Column>

View File

@@ -581,35 +581,28 @@ onBeforeUnmount(() => {
<Toast />
<ConfirmDialog />
<!-- Info decorativa (scrolls away naturalmente) -->
<div class="flex items-start gap-4 px-4 pb-3">
<div class="intents-hero__icon-wrap">
<i class="pi pi-inbox intents-hero__icon" />
</div>
<div class="intents-hero__sub">
Caixa de entrada de pagamento manual (PIX/boleto). Marque como <b>pago</b> para ativar a assinatura ou
<b>cancele</b> quando o pagamento não será concluído.
</div>
</div>
<!-- Sentinel -->
<div ref="heroSentinelRef" class="h-px" />
<!-- sentinel -->
<div ref="heroSentinelRef" class="intents-hero-sentinel" />
<!-- hero -->
<div ref="heroEl" class="intents-hero mb-4" :class="{ 'intents-hero--stuck': heroStuck }">
<div class="intents-hero__blobs" aria-hidden="true">
<div class="intents-hero__blob intents-hero__blob--1" />
<div class="intents-hero__blob intents-hero__blob--2" />
<!-- Hero sticky -->
<div
ref="heroEl"
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-72 h-72 -bottom-20 right-24 bg-fuchsia-400/10" />
</div>
<div class="intents-hero__inner">
<!-- Título -->
<div class="intents-hero__info min-w-0">
<div class="intents-hero__title">Intenções de assinatura</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Intenções de assinatura</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Caixa de entrada de pagamento manual (PIX/boleto). Marque como <b>pago</b> para ativar a assinatura ou <b>cancele</b> quando o pagamento não será concluído.</div>
</div>
<!-- Ações desktop ( 1200px) -->
<div class="intents-hero__actions intents-hero__actions--desktop">
<div class="hidden xl:flex items-center gap-2 shrink-0">
<Button
label="Atualizar"
icon="pi pi-refresh"
@@ -633,7 +626,7 @@ onBeforeUnmount(() => {
</div>
<!-- Ações mobile (< 1200px) -->
<div class="intents-hero__actions--mobile">
<div class="flex xl:hidden shrink-0">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
@@ -647,15 +640,14 @@ onBeforeUnmount(() => {
</div>
<!-- content -->
<div class="px-4 pb-4">
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Card: Resumo + Filtros -->
<Card class="mb-4">
<template #title>
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="flex items-center justify-between gap-3 flex-wrap mb-4">
<div class="flex items-center gap-2">
<i class="pi pi-filter text-color-secondary" />
<span>Busca & Filtros</span>
<i class="pi pi-filter text-[var(--text-color-secondary)]" />
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Busca &amp; Filtros</div>
</div>
<!-- contagens -->
<div class="flex flex-wrap gap-2 items-center">
@@ -664,14 +656,12 @@ onBeforeUnmount(() => {
<Tag :value="`Aguardando: ${totals.waiting}`" severity="warning" rounded />
<Tag :value="`Pagas: ${totals.paid}`" severity="success" rounded />
<Tag :value="`Canceladas: ${totals.canceled}`" severity="danger" rounded />
<span class="text-xs text-color-secondary">
<div class="text-[1rem] text-[var(--text-color-secondary)]">
<template v-if="lastRefreshAt">· {{ fmtDate(lastRefreshAt) }}</template>
</span>
</div>
</div>
</template>
</div>
<template #content>
<div class="grid grid-cols-12 gap-3">
<!-- Busca -->
<div class="col-span-12 md:col-span-5">
@@ -759,8 +749,7 @@ onBeforeUnmount(() => {
/>
</div>
</div>
</template>
</Card>
</div>
<DataTable
:value="filteredRows"
@@ -768,7 +757,6 @@ onBeforeUnmount(() => {
paginator
:rows="20"
:rowsPerPageOptions="[10, 20, 50]"
class="text-sm intents-table"
responsiveLayout="scroll"
sortField="created_at"
:sortOrder="-1"
@@ -776,7 +764,7 @@ onBeforeUnmount(() => {
>
<Column field="id" header="Intent ID" style="min-width: 18rem">
<template #body="{ data }">
<span class="text-xs">{{ data.id }}</span>
<div class="text-[1rem]">{{ data.id }}</div>
</template>
</Column>
@@ -786,9 +774,9 @@ onBeforeUnmount(() => {
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-medium">{{ data.plan_key || '—' }}</span>
<small class="text-color-secondary">
<div class="text-[1rem] text-[var(--text-color-secondary)]">
{{ intervalLabel(data.interval) }} {{ moneyBRL(data.amount_cents) }}
</small>
</div>
</div>
</template>
</Column>
@@ -803,9 +791,9 @@ onBeforeUnmount(() => {
:value="c.label"
rounded
/>
<span v-if="!diagChips(data).length" class="text-color-secondary"></span>
<span v-if="!diagChips(data).length" class="text-[var(--text-color-secondary)]"></span>
</div>
<div v-if="data.tenant_id" class="mt-1 text-xs text-color-secondary">
<div v-if="data.tenant_id" class="mt-1 text-[1rem] text-[var(--text-color-secondary)]">
tenant_id: {{ data.tenant_id }}
</div>
</template>
@@ -862,13 +850,13 @@ onBeforeUnmount(() => {
:dismissableMask="!acting"
:draggable="false"
>
<div v-if="selected" class="text-sm">
<div v-if="selected" class="text-[1rem]">
<div class="mb-3">
<div class="font-semibold">{{ selected.email }}</div>
<div class="text-color-secondary">
<div class="text-[var(--text-color-secondary)]">
Plano: {{ selected.plan_key }} Intervalo: {{ intervalLabel(selected.interval) }} Valor: {{ moneyBRL(selected.amount_cents) }}
</div>
<div class="text-color-secondary mt-1" v-if="selected.tenant_id">
<div class="text-[var(--text-color-secondary)] mt-1" v-if="selected.tenant_id">
tenant_id: {{ selected.tenant_id }}
</div>
</div>
@@ -901,21 +889,21 @@ onBeforeUnmount(() => {
:dismissableMask="!acting"
:draggable="false"
>
<div v-if="selectedSub" class="text-sm">
<div v-if="selectedSub" class="text-[1rem]">
<div class="mb-3">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div>
<div class="font-semibold">
{{ selectedSub.plan_key || '—' }} {{ intervalLabel(selectedSub.interval) }}
</div>
<div class="text-color-secondary mt-1">
<div class="text-[var(--text-color-secondary)] mt-1">
Período: {{ fmtDate(selectedSub.current_period_start) }} {{ fmtDate(selectedSub.current_period_end) }}
</div>
<div class="text-color-secondary mt-1">
<div class="text-[var(--text-color-secondary)] mt-1">
owner(user_id): {{ selectedSub.user_id }}
<span v-if="selectedSub.tenant_id"> tenant_id: {{ selectedSub.tenant_id }}</span>
</div>
<div class="text-color-secondary mt-1">
<div class="text-[var(--text-color-secondary)] mt-1">
subscription_id: {{ selectedSub.id }}
</div>
</div>
@@ -975,68 +963,3 @@ onBeforeUnmount(() => {
</div>
</Dialog>
</template>
<style scoped>
.intents-hero-sentinel { height: 1px; }
.intents-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.5rem;
margin: 1rem;
}
.intents-hero--stuck {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.intents-hero__blobs {
position: absolute; inset: 0; pointer-events: none; overflow: hidden;
}
.intents-hero__blob {
position: absolute; border-radius: 50%; filter: blur(70px);
}
.intents-hero__blob--1 { width: 20rem; height: 20rem; top: -5rem; right: -4rem; background: rgba(167,139,250,0.12); }
.intents-hero__blob--2 { width: 18rem; height: 18rem; top: 1rem; left: -5rem; background: rgba(52,211,153,0.09); }
.intents-hero__inner {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1.25rem; flex-wrap: wrap;
}
.intents-hero__icon-wrap {
flex-shrink: 0;
width: 4rem; height: 4rem; border-radius: 1.125rem;
border: 2px solid var(--surface-border);
background: var(--surface-ground);
display: grid; place-items: center;
}
.intents-hero__icon { font-size: 1.5rem; color: var(--text-color); }
.intents-hero__info { flex: 1; min-width: 0; }
.intents-hero__title {
font-size: 1.25rem; font-weight: 700; letter-spacing: -0.025em;
color: var(--text-color); line-height: 1.2;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.intents-hero__sub {
font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 4px; line-height: 1.5;
}
.intents-hero__actions--desktop {
display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
}
.intents-hero__actions--mobile { display: none; }
@media (max-width: 1199px) {
.intents-hero__actions--desktop { display: none; }
.intents-hero__actions--mobile { display: flex; }
}
.intents-table :deep(.p-paginator) {
border-top: 1px solid var(--surface-border);
}
</style>

View File

@@ -7,8 +7,7 @@ import { useLayout } from '@/layout/composables/layout'
const { layoutConfig, isDarkTheme } = useLayout()
const tenantStore = useTenantStore()
// ─── período ─────────────────────────────────────────────────────────────────
// ── Período ───────────────────────────────────────────────
const PERIODS = [
{ label: 'Esta semana', value: 'week' },
{ label: 'Este mês', value: 'month' },
@@ -21,14 +20,9 @@ const selectedPeriod = ref('month')
function periodRange (period) {
const now = new Date()
let start, end
if (period === 'week') {
const dow = now.getDay() // 0=Dom
start = new Date(now)
start.setDate(now.getDate() - dow)
start.setHours(0, 0, 0, 0)
end = new Date(now)
end.setHours(23, 59, 59, 999)
start = new Date(now); start.setDate(now.getDate() - now.getDay()); start.setHours(0, 0, 0, 0)
end = new Date(now); end.setHours(23, 59, 59, 999)
} else if (period === 'month') {
start = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0)
end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999)
@@ -39,12 +33,10 @@ function periodRange (period) {
start = new Date(now.getFullYear(), now.getMonth() - 5, 1, 0, 0, 0, 0)
end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999)
}
return { start, end }
}
// ─── dados ───────────────────────────────────────────────────────────────────
// ── Dados ─────────────────────────────────────────────────
const loading = ref(false)
const sessions = ref([])
const loadError = ref('')
@@ -55,7 +47,6 @@ async function loadSessions () {
if (!uid || !tenantId) return
const { start, end } = periodRange(selectedPeriod.value)
loading.value = true
loadError.value = ''
sessions.value = []
@@ -70,7 +61,6 @@ async function loadSessions () {
.lte('inicio_em', end.toISOString())
.order('inicio_em', { ascending: false })
.limit(500)
if (error) throw error
sessions.value = data || []
} catch (e) {
@@ -80,17 +70,43 @@ async function loadSessions () {
}
}
// ─── métricas ─────────────────────────────────────────────────────────────────
// ── Métricas ──────────────────────────────────────────────
const total = computed(() => sessions.value.length)
const realizadas = computed(() => sessions.value.filter(s => s.status === 'realizado').length)
const faltas = computed(() => sessions.value.filter(s => s.status === 'faltou').length)
const canceladas = computed(() => sessions.value.filter(s => s.status === 'cancelado').length)
const agendadas = computed(() => sessions.value.filter(s => !s.status || s.status === 'agendado').length)
const remarcadas = computed(() => sessions.value.filter(s => s.status === 'remarcado').length)
const taxaRealizacao = computed(() => {
const denom = realizadas.value + faltas.value + canceladas.value
if (!denom) return null
return Math.round((realizadas.value / denom) * 100)
})
// ─── gráfico (sessions por semana/mês) ───────────────────────────────────────
// ── Filtro de status na tabela ────────────────────────────
const filtroTabela = ref(null) // null = todos
const sessionsFiltradas = computed(() => {
if (!filtroTabela.value) return sessions.value
if (filtroTabela.value === 'agendado') return sessions.value.filter(s => !s.status || s.status === 'agendado')
return sessions.value.filter(s => s.status === filtroTabela.value)
})
function toggleFiltroTabela (val) {
filtroTabela.value = filtroTabela.value === val ? null : val
}
// ── Quick-stats config ────────────────────────────────────
const quickStats = computed(() => [
{ label: 'Total', value: total.value, filter: null, cls: '', valCls: 'text-[var(--text-color)]' },
{ label: 'Realizadas', value: realizadas.value, filter: 'realizado', cls: 'qs-ok', valCls: 'text-green-500' },
{ label: 'Faltas', value: faltas.value, filter: 'faltou', cls: 'qs-danger', valCls: 'text-red-500' },
{ label: 'Canceladas', value: canceladas.value, filter: 'cancelado', cls: 'qs-warn', valCls: 'text-orange-500' },
{ label: 'Agendadas', value: agendadas.value, filter: 'agendado', cls: 'qs-info', valCls: 'text-sky-500' },
{ label: 'Taxa realização', value: taxaRealizacao.value != null ? `${taxaRealizacao.value}%` : '—', filter: null, cls: taxaRealizacao.value != null && taxaRealizacao.value >= 85 ? 'qs-ok' : '', valCls: taxaRealizacao.value != null && taxaRealizacao.value >= 85 ? 'text-green-500' : 'text-[var(--text-color)]' },
])
// ── Gráfico ───────────────────────────────────────────────
function isoWeek (d) {
const dt = new Date(d)
const day = dt.getDay() || 7
@@ -99,14 +115,10 @@ function isoWeek (d) {
const wk = Math.ceil((((dt - yearStart) / 86400000) + 1) / 7)
return `${dt.getFullYear()}-S${String(wk).padStart(2, '0')}`
}
function isoMonth (d) {
const dt = new Date(d)
const yy = dt.getFullYear()
const mm = String(dt.getMonth() + 1).padStart(2, '0')
return `${yy}-${mm}`
return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}`
}
function monthLabel (key) {
const [y, m] = key.split('-')
const names = ['Jan','Fev','Mar','Abr','Mai','Jun','Jul','Ago','Set','Out','Nov','Dez']
@@ -115,10 +127,7 @@ function monthLabel (key) {
const chartData = computed(() => {
const groupBy = selectedPeriod.value === 'week' ? isoWeek : isoMonth
const labelFn = selectedPeriod.value === 'week'
? k => k
: monthLabel
const labelFn = selectedPeriod.value === 'week' ? k => k : monthLabel
const buckets = {}
for (const s of sessions.value) {
const key = groupBy(s.inicio_em)
@@ -129,38 +138,14 @@ const chartData = computed(() => {
else if (st === 'cancelado') buckets[key].cancelado++
else buckets[key].outros++
}
const keys = Object.keys(buckets).sort()
const labels = keys.map(labelFn)
const ds = getComputedStyle(document.documentElement)
return {
labels,
labels: keys.map(labelFn),
datasets: [
{
label: 'Realizadas',
backgroundColor: '#22c55e',
data: keys.map(k => buckets[k].realizado),
barThickness: 20,
},
{
label: 'Faltas',
backgroundColor: '#ef4444',
data: keys.map(k => buckets[k].faltou),
barThickness: 20,
},
{
label: 'Canceladas',
backgroundColor: '#f97316',
data: keys.map(k => buckets[k].cancelado),
barThickness: 20,
},
{
label: 'Outros',
backgroundColor: ds.getPropertyValue('--p-primary-300') || '#93c5fd',
data: keys.map(k => buckets[k].outros),
barThickness: 20,
},
{ label: 'Realizadas', backgroundColor: '#22c55e', data: keys.map(k => buckets[k].realizado), barThickness: 20 },
{ label: 'Faltas', backgroundColor: '#ef4444', data: keys.map(k => buckets[k].faltou), barThickness: 20 },
{ label: 'Canceladas', backgroundColor: '#f97316', data: keys.map(k => buckets[k].cancelado), barThickness: 20 },
{ label: 'Outros', backgroundColor: '#93c5fd', data: keys.map(k => buckets[k].outros), barThickness: 20 },
]
}
})
@@ -171,42 +156,22 @@ const chartOptions = computed(() => {
const textMutedColor = ds.getPropertyValue('--text-color-secondary') || '#64748b'
return {
maintainAspectRatio: false,
plugins: {
legend: { labels: { color: textMutedColor } }
},
plugins: { legend: { labels: { color: textMutedColor } } },
scales: {
x: {
stacked: true,
ticks: { color: textMutedColor },
grid: { color: 'transparent' }
},
y: {
stacked: true,
ticks: { color: textMutedColor, precision: 0 },
grid: { color: borderColor, drawTicks: false }
}
x: { stacked: true, ticks: { color: textMutedColor }, grid: { color: 'transparent' } },
y: { stacked: true, ticks: { color: textMutedColor, precision: 0 }, grid: { color: borderColor, drawTicks: false } }
}
}
})
// ─── tabela ───────────────────────────────────────────────────────────────────
// ── Tabela helpers ────────────────────────────────────────
const STATUS_LABEL = {
agendado: 'Agendado',
realizado: 'Realizado',
faltou: 'Faltou',
cancelado: 'Cancelado',
remarcado: 'Remarcado',
bloqueado: 'Bloqueado',
agendado: 'Agendado', realizado: 'Realizado', faltou: 'Faltou',
cancelado: 'Cancelado', remarcado: 'Remarcado', bloqueado: 'Bloqueado',
}
const STATUS_SEVERITY = {
agendado: 'info',
realizado: 'success',
faltou: 'danger',
cancelado: 'warn',
remarcado: 'secondary',
bloqueado: 'secondary',
agendado: 'info', realizado: 'success', faltou: 'danger',
cancelado: 'warn', remarcado: 'secondary', bloqueado: 'secondary',
}
function fmtDateTimeBR (iso) {
@@ -220,131 +185,252 @@ function fmtDateTimeBR (iso) {
const mi = String(d.getMinutes()).padStart(2, '0')
return `${dd}/${mm}/${yy} ${hh}:${mi}`
}
function sessionTitle (s) { return s.titulo_custom || s.titulo || (s.tipo ? s.tipo : 'Sessão') }
function patientName (s) { return s.patients?.nome_completo || '—' }
function sessionTitle (s) {
return s.titulo_custom || s.titulo || (s.tipo ? s.tipo : 'Sessão')
}
function patientName (s) {
return s.patients?.nome_completo || '—'
}
// taxa de realização
const taxaRealizacao = computed(() => {
const denom = realizadas.value + faltas.value + canceladas.value
if (!denom) return null
return Math.round((realizadas.value / denom) * 100)
})
// ─── watch & mount ────────────────────────────────────────────────────────────
watch(selectedPeriod, loadSessions)
// ── Watch & mount ─────────────────────────────────────────
watch(selectedPeriod, () => { filtroTabela.value = null; loadSessions() })
watch([() => layoutConfig.primary, () => layoutConfig.surface, isDarkTheme], () => {})
onMounted(loadSessions)
</script>
<template>
<div class="flex flex-col gap-6 p-4">
<!-- Cabeçalho -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 class="text-2xl font-bold text-slate-800">Relatórios</h1>
<p class="text-sm text-slate-500 mt-1">Visão geral das suas sessões</p>
<!-- Sentinel -->
<div class="h-px" />
<!--
HERO sticky
-->
<section
class="sticky mx-3 md:mx-4 mb-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5 transition-[border-radius] duration-200"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<!-- Blobs -->
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-indigo-500/10" />
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-emerald-400/[0.08]" />
</div>
<div class="relative z-[1] flex items-center gap-3 flex-wrap">
<!-- Brand -->
<div class="flex items-center gap-2 flex-shrink-0">
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
<i class="pi pi-chart-bar text-base" />
</div>
<div class="min-w-0 hidden lg:block">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Relatórios</div>
<div class="text-[1rem] text-[var(--text-color-secondary)]">Visão geral das suas sessões</div>
</div>
</div>
<!-- Seletor de período -->
<div class="flex-1 min-w-0 hidden xl:flex items-center mx-2">
<SelectButton
v-model="selectedPeriod"
:options="PERIODS"
option-label="label"
option-value="value"
:allow-empty="false"
class="shrink-0"
size="small"
/>
</div>
<!-- Refresh -->
<div class="flex items-center gap-1.5 flex-shrink-0 ml-auto">
<Button
icon="pi pi-refresh"
severity="secondary"
outlined
class="h-9 w-9 rounded-full"
:loading="loading"
title="Recarregar"
@click="loadSessions"
/>
</div>
</div>
<!-- Seletor de período mobile (abaixo da linha principal) -->
<div class="xl:hidden relative z-[1] mt-2.5 flex flex-wrap gap-1.5">
<button
v-for="p in PERIODS"
:key="p.value"
class="inline-flex items-center px-3 py-1 rounded-full text-[1rem] font-semibold border-[1.5px] cursor-pointer transition-all duration-150 select-none"
:class="selectedPeriod === p.value
? 'bg-[var(--primary-color,#6366f1)] border-[var(--primary-color,#6366f1)] text-white'
: 'border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)] text-[var(--text-color-secondary)] hover:border-indigo-300 hover:text-[var(--text-color)]'"
@click="selectedPeriod = p.value"
>
{{ p.label }}
</button>
</div>
</section>
<!--
CONTEÚDO
-->
<div class="px-3 md:px-4 pb-8 flex flex-col gap-3">
<!-- Erro -->
<Message v-if="loadError" severity="error">{{ loadError }}</Message>
<!-- Loading -->
<div v-if="loading" class="flex items-center gap-2 text-slate-500">
<i class="pi pi-spin pi-spinner" /> Carregando
<!-- Loading skeleton -->
<div v-if="loading" class="flex flex-col gap-3">
<!-- Stats skeleton -->
<div class="flex flex-wrap gap-2">
<div v-for="n in 6" :key="n" class="flex-1 min-w-[80px] h-[72px] rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
</div>
<!-- Chart skeleton -->
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] h-[280px] animate-pulse" />
</div>
<template v-else>
<!-- Cards de resumo -->
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-3">
<div class="rounded-2xl border border-slate-200 bg-white p-4 flex flex-col gap-1">
<span class="text-xs text-slate-500 uppercase tracking-wide">Total</span>
<span class="text-3xl font-bold text-slate-800">{{ total }}</span>
<!-- QUICK-STATS clicáveis -->
<div class="flex flex-wrap gap-2">
<div
v-for="s in quickStats"
:key="s.label"
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] transition-[border-color,box-shadow,background] duration-150"
:class="[
s.filter !== null ? 'cursor-pointer select-none' : '',
s.filter !== null && filtroTabela === s.filter
? 'border-[var(--primary-color,#6366f1)] shadow-[0_0_0_3px_rgba(99,102,241,0.15)] bg-[var(--surface-card,#fff)]'
: s.cls === 'qs-ok'
? 'border-green-500/25 bg-green-500/5 hover:border-green-500/40'
: s.cls === 'qs-danger'
? 'border-red-500/25 bg-red-500/5 hover:border-red-500/40'
: s.cls === 'qs-warn'
? 'border-orange-500/25 bg-orange-500/5 hover:border-orange-500/40'
: s.cls === 'qs-info'
? 'border-sky-500/25 bg-sky-500/5 hover:border-sky-500/40'
: 'border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] hover:border-indigo-300/50'
]"
@click="s.filter !== null ? toggleFiltroTabela(s.filter) : null"
>
<div class="text-[1.35rem] font-bold leading-none" :class="s.valCls">{{ s.value }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">{{ s.label }}</div>
</div>
<div class="rounded-2xl border border-green-100 bg-green-50 p-4 flex flex-col gap-1">
<span class="text-xs text-green-700 uppercase tracking-wide">Realizadas</span>
<span class="text-3xl font-bold text-green-700">{{ realizadas }}</span>
</div>
<div class="rounded-2xl border border-red-100 bg-red-50 p-4 flex flex-col gap-1">
<span class="text-xs text-red-600 uppercase tracking-wide">Faltas</span>
<span class="text-3xl font-bold text-red-600">{{ faltas }}</span>
</div>
<div class="rounded-2xl border border-orange-100 bg-orange-50 p-4 flex flex-col gap-1">
<span class="text-xs text-orange-600 uppercase tracking-wide">Canceladas</span>
<span class="text-3xl font-bold text-orange-600">{{ canceladas }}</span>
</div>
<div class="rounded-2xl border border-blue-100 bg-blue-50 p-4 flex flex-col gap-1">
<span class="text-xs text-blue-600 uppercase tracking-wide">Agendadas</span>
<span class="text-3xl font-bold text-blue-600">{{ agendadas }}</span>
</div>
<div class="rounded-2xl border border-slate-200 bg-white p-4 flex flex-col gap-1">
<span class="text-xs text-slate-500 uppercase tracking-wide">Taxa realização</span>
<span class="text-3xl font-bold text-slate-800">
{{ taxaRealizacao != null ? `${taxaRealizacao}%` : '—' }}
<!-- Chip de filtro ativo na tabela -->
<div v-if="filtroTabela" class="flex items-center gap-2">
<span class="text-[1rem] text-[var(--text-color-secondary)]">Filtrando por:</span>
<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[0.75rem] font-semibold bg-[var(--primary-color,#6366f1)] text-white">
{{ STATUS_LABEL[filtroTabela] || filtroTabela }}
<button class="ml-0.5 opacity-70 hover:opacity-100" @click="filtroTabela = null">
<i class="pi pi-times text-[0.6rem]" />
</button>
</span>
</div>
</div>
<!-- Gráfico -->
<div v-if="total > 0" class="rounded-2xl border border-slate-200 bg-white p-4">
<h2 class="text-base font-semibold text-slate-700 mb-4">
<!-- GRÁFICO -->
<div v-if="total > 0" class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
<div class="flex items-center gap-2">
<i class="pi pi-chart-bar text-[var(--text-color-secondary)] opacity-60" />
<span class="font-semibold text-[1rem] text-[var(--text-color)]">
Sessões por {{ selectedPeriod === 'week' ? 'semana' : 'mês' }}
</h2>
</span>
</div>
<span class="text-[0.72rem] text-[var(--text-color-secondary)] opacity-60">{{ total }} sessão{{ total !== 1 ? 'ões' : '' }}</span>
</div>
<div class="p-4">
<Chart type="bar" :data="chartData" :options="chartOptions" class="h-64" />
</div>
<!-- Tabela -->
<div class="rounded-2xl border border-slate-200 bg-white overflow-hidden">
<div class="px-4 py-3 border-b border-slate-100 flex items-center justify-between">
<h2 class="text-base font-semibold text-slate-700">Sessões no período</h2>
<span class="text-sm text-slate-500">{{ total }} registro{{ total !== 1 ? 's' : '' }}</span>
</div>
<div v-if="!sessions.length" class="px-4 py-8 text-center text-slate-500 text-sm">
Nenhuma sessão encontrada para o período selecionado.
<!-- TABELA -->
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
<!-- Cabeçalho da seção -->
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
<div class="flex items-center gap-2">
<i class="pi pi-table text-[var(--text-color-secondary)] opacity-60" />
<span class="font-semibold text-[1rem] text-[var(--text-color)]">Sessões no período</span>
<span
v-if="filtroTabela"
class="text-[0.72rem] text-[var(--text-color-secondary)] opacity-70"
>(filtrado)</span>
</div>
<span class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1.5 rounded-full bg-[var(--primary-color,#6366f1)] text-white text-[1rem] font-bold">
{{ sessionsFiltradas.length }}
</span>
</div>
<!-- Empty state (sem dados no período) -->
<div
v-if="!sessions.length"
class="flex flex-col items-center justify-center gap-3 py-14 px-6 text-center border-2 border-dashed border-[var(--surface-border,#e2e8f0)] mx-4 my-4 rounded-md bg-[var(--surface-ground,#f8fafc)]"
>
<div class="relative">
<div class="grid place-items-center w-16 h-16 rounded-2xl bg-[var(--surface-card,#fff)] border border-[var(--surface-border,#e2e8f0)] shadow-sm text-[var(--text-color-secondary)]">
<i class="pi pi-chart-bar text-3xl opacity-25" />
</div>
<div class="absolute -top-1.5 -right-1.5 w-6 h-6 rounded-full bg-[var(--surface-card,#fff)] border border-[var(--surface-border,#e2e8f0)] shadow-sm grid place-items-center">
<i class="pi pi-times text-[0.58rem] text-[var(--text-color-secondary)] opacity-50" />
</div>
</div>
<div>
<div class="font-bold text-[0.9rem] text-[var(--text-color)] mb-0.5">Nenhuma sessão no período</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] max-w-xs">Tente selecionar um período diferente.</div>
</div>
<div class="flex flex-wrap gap-2 mt-1 justify-center">
<button
v-for="p in PERIODS"
:key="p.value"
class="inline-flex items-center px-3 py-1 rounded-full text-[0.75rem] font-semibold border cursor-pointer transition-colors duration-150"
:class="selectedPeriod === p.value
? 'bg-[var(--primary-color,#6366f1)] border-[var(--primary-color,#6366f1)] text-white'
: 'border-[var(--surface-border,#e2e8f0)] text-[var(--text-color-secondary)] hover:border-indigo-300'"
@click="selectedPeriod = p.value"
>
{{ p.label }}
</button>
</div>
</div>
<!-- Empty state (filtro sem resultado) -->
<div
v-else-if="!sessionsFiltradas.length"
class="flex flex-col items-center gap-2 py-10 text-center text-[var(--text-color-secondary)]"
>
<i class="pi pi-filter-slash text-2xl opacity-30" />
<div class="font-semibold text-[0.88rem]">Nenhuma sessão com este status</div>
<Button label="Limpar filtro" icon="pi pi-times" severity="secondary" outlined size="small" class="rounded-full mt-1" @click="filtroTabela = null" />
</div>
<!-- DataTable -->
<DataTable
v-else
:value="sessions"
:value="sessionsFiltradas"
:rows="20"
paginator
:rows-per-page-options="[10, 20, 50]"
scrollable
scroll-height="480px"
class="text-sm"
class="rel-datatable"
>
<Column field="inicio_em" header="Data / Hora" :sortable="true" style="min-width: 140px">
<template #body="{ data }">{{ fmtDateTimeBR(data.inicio_em) }}</template>
<template #body="{ data }">
<span class="font-medium">{{ fmtDateTimeBR(data.inicio_em) }}</span>
</template>
</Column>
<Column header="Paciente" style="min-width: 160px">
<template #body="{ data }">{{ patientName(data) }}</template>
</Column>
<Column header="Sessão" style="min-width: 160px">
<template #body="{ data }">{{ sessionTitle(data) }}</template>
</Column>
<Column field="modalidade" header="Modalidade" style="min-width: 110px">
<template #body="{ data }">
{{ data.modalidade === 'online' ? 'Online' : data.modalidade === 'presencial' ? 'Presencial' : data.modalidade || '—' }}
</template>
</Column>
<Column field="status" header="Status" style="min-width: 110px">
<template #body="{ data }">
<Tag
@@ -355,6 +441,13 @@ onMounted(loadSessions)
</Column>
</DataTable>
</div>
</template>
</div>
</template>
<style scoped>
.rel-datatable :deep(.p-datatable-table-container) { border-radius: 0; }
.rel-datatable :deep(th) { background: var(--surface-ground) !important; font-size: 0.82rem; }
.rel-datatable :deep(td) { font-size: 0.85rem; }
</style>

File diff suppressed because it is too large Load Diff