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 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,68 +76,15 @@
<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" />
<ToggleSwitch 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>
</div>
</div>
<!-- Descrição -->
<!-- Descrição -->
<FloatLabel variant="on">
<Textarea
id="cr-descricao"
@@ -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 class="md:col-span-12 text-xs opacity-40 font-mono">
key: {{ f.key }}
</div>
</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()
@@ -424,100 +456,4 @@ function makeKey (label) {
.replace(/(^_|_$)/g, '') || `field_${Math.random().toString(16).slice(2, 8)}`
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>
</script>

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" />
<Button icon="pi pi-chevron-left" severity="secondary" outlined class="h-8 w-8 rounded-full" @click="goPrev" />
<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>
<!-- 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" />
</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 -->
<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"
/>
<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 -->
<!-- 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"
@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 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" />
<Button icon="pi pi-plus" class="h-9 w-9 rounded-full" title="Novo compromisso" @click="onCreateFromButton" />
<!-- 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" />
<!-- 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>
<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">
<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'" />
<!-- 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>
</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">
<!-- 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>
<!-- 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,40 +178,38 @@
</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) }) }"
>
<template #date="{ date }">
<span class="mini-day-num">{{ date.day }}</span>
<span v-if="hasMiniEvent(date)" class="mini-day-dot" />
</template>
</Calendar>
<template #date="{ date }">
<span class="mini-day-num">{{ date.day }}</span>
<span v-if="hasMiniEvent(date)" class="mini-day-dot" />
</template>
</Calendar>
</div>
<ProximosFeriadosCard
class="mb-3"
:ownerId="clinicOwnerId"
:tenantId="tenantId || ''"
:workRules="workRules"
@bloqueado="refetch"
/>
: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

File diff suppressed because it is too large Load Diff

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"
/>
<!-- 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>
<InputGroup v-else class="max-w-xl">
<InputGroupAddon><i class="pi pi-link" /></InputGroupAddon>
<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>
<!-- Mobile (<1200px) -->
<div class="flex xl:hidden items-center shrink-0">
<!-- 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>
<!-- 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>
<!-- Divider -->
<Divider class="extlink-hero__divider my-2" />
<!--
CONTEÚDO
-->
<div class="flex flex-col lg:flex-row gap-3 px-3 md:px-4 pb-5">
<!-- 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">
<InputGroupAddon><i class="pi pi-link" /></InputGroupAddon>
<InputText readonly :value="publicUrl" class="font-mono text-xs" />
<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>
</div>
<!-- Conteúdo -->
<div class="flex flex-col lg:flex-row gap-4 px-3 md:px-5 mb-5">
<!-- Esquerda: ações do link -->
<div class="flex-1 min-w-0 flex flex-col gap-4">
<!-- 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>
<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>
</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>
<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>
</li>
</ol>
<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="min-w-0">
<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>
</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-[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>
<!-- 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>
<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="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>
<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>
@@ -241,26 +235,39 @@ import { supabase } from '@/lib/supabase/client'
const toast = useToast()
const inviteToken = ref('')
const rotating = ref(false)
const rotating = ref(false)
// ── Hero sticky ───────────────────────────────────────────
const headerEl = ref(null)
// ── Hero sticky ───────────────────────────────────────────
const headerEl = ref(null)
const headerSentinelRef = ref(null)
const headerStuck = ref(false)
const headerStuck = ref(false)
let _observer = null
// ── Mobile menu ───────────────────────────────────────────
// ── Mobile menu ───────────────────────────────────────────
const mobileMenuRef = ref(null)
const mobileMenuItems = computed(() => [
{ label: 'Copiar link', icon: 'pi pi-copy', command: () => copyLink(), disabled: !inviteToken.value },
{ label: 'Copiar mensagem', icon: 'pi pi-comment', command: () => copyInviteMessage(), disabled: !inviteToken.value },
{ label: 'Abrir no navegador', icon: 'pi pi-external-link', command: () => openLink(), disabled: !inviteToken.value },
{ label: 'Copiar link', icon: 'pi pi-copy', command: () => copyLink(), disabled: !inviteToken.value },
{ label: 'Copiar mensagem', icon: 'pi pi-comment', command: () => copyInviteMessage(), disabled: !inviteToken.value },
{ label: 'Abrir no navegador', icon: 'pi pi-external-link', command: () => openLink(), disabled: !inviteToken.value },
{ separator: true },
{ label: 'Gerar novo link', icon: 'pi pi-refresh', command: () => rotateLink() }
{ 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,13 +280,13 @@ const publicUrl = computed(() => {
return `${origin.value}/cadastro/paciente?t=${encodeURIComponent(inviteToken.value)}`
})
// ── Token helpers ───────────────────────────────────────────
function newToken() {
// ── Token helpers ─────────────────────────────────────────
function newToken () {
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID()
return 'tok_' + Math.random().toString(36).slice(2) + Date.now().toString(36)
}
async function requireUserId() {
async function requireUserId () {
const { data, error } = await supabase.auth.getUser()
if (error) throw error
const uid = data?.user?.id
@@ -287,7 +294,7 @@ async function requireUserId() {
return uid
}
async function loadOrCreateInvite() {
async function loadOrCreateInvite () {
const uid = await requireUserId()
const { data, error } = await supabase
@@ -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
@@ -315,19 +319,18 @@ async function loadOrCreateInvite() {
inviteToken.value = t
}
async function rotateLink() {
async function rotateLink () {
rotating.value = true
try {
const uid = await requireUserId()
const t = newToken()
const t = newToken()
const rpc = await supabase.rpc('rotate_patient_invite_token', { p_new_token: t })
if (rpc.error) {
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
@@ -345,7 +348,7 @@ async function rotateLink() {
}
}
async function copyLink() {
async function copyLink () {
try {
if (!publicUrl.value) return
await navigator.clipboard.writeText(publicUrl.value)
@@ -355,12 +358,12 @@ async function copyLink() {
}
}
function openLink() {
function openLink () {
if (!publicUrl.value) return
window.open(publicUrl.value, '_blank', 'noopener')
}
async function copyInviteMessage() {
async function copyInviteMessage () {
try {
if (!publicUrl.value) return
const msg = `Olá! Segue o link para seu pré-cadastro. Preencha com calma — campos opcionais podem ficar em branco:\n${publicUrl.value}`
@@ -387,96 +390,4 @@ 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>
</script>

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,
@@ -38,7 +39,21 @@ const initials = computed(() => {
return (a + b).toUpperCase()
})
const userName = computed(() => sessionUser.value?.user_metadata?.full_name || sessionUser.value?.email || 'Conta')
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) {
@@ -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')" />
<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>
</style>

View File

@@ -1,10 +1,10 @@
<!-- 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'
import { useLayout } from './composables/layout'
import { useMenuStore } from '@/stores/menuStore'
import { useLayout } from './composables/layout'
import { useEntitlementsStore } from '@/stores/entitlementsStore'
const menuStore = useMenuStore()
@@ -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>
<button
v-for="child in item.items"
:key="child.to || child.label"
class="rp__item"
:class="{
'rp__item--active': isActive(child),
'rp__item--locked': 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>
</button>
<!-- 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>
<!-- Item folha -->
<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-else
class="rp__item"
:class="{
'rp__item--active': isActive(item),
'rp__item--locked': isLocked(item)
}"
@click="navigate(item)"
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 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 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="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="{
'!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="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="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="{
'!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="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,
@@ -252,4 +408,4 @@ function closePanel () {
width: 0 !important;
opacity: 0;
}
</style>
</style>

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;
@@ -309,4 +682,4 @@ watch(() => route.path, () => hideMobileMenu())
.rs-slide-leave-to {
transform: translateX(-100%);
}
</style>
</style>

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 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="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()" />
<!-- 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>
<div class="pt-0">
<!-- 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"
class="cfg-sec-card"
:class="{ 'cfg-sec-card--active': activeTo === s.to }"
@click="ir(s.to)"
>
<i :class="s.icon" class="cfg-sec-card__icon" />
<span class="cfg-sec-card__label">{{ s.label }}</span>
</button>
</div>
<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>
<!-- 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>
</template>
<template #content>
<div class="flex flex-col gap-2">
<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' : ''"
@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" />
</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 -->
<router-view />
<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>
</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.
</div>
<!-- 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 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;
}
</style>
/* ── 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);
}
</style>
/* ── 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 -->
<button
type="button"
class="w-full flex items-center justify-between gap-3 text-left"
@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">
<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>
</div>
<i class="pi transition-transform duration-200 text-surface-400 shrink-0"
:class="expandedCard === 'identidade' ? 'pi-angle-up' : 'pi-angle-down'" />
</button>
<div class="agd-accordion" :class="{ 'agd-accordion--open': expandedCard.has('identidade') }">
<button
type="button"
class="agd-accordion__header"
@click="toggleCard('identidade')"
>
<div class="agd-accordion__icon bg-purple-100 dark:bg-purple-900/30 text-purple-600">
<i class="pi pi-palette" />
</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>
<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>
<button
type="button"
class="w-full flex items-center justify-between gap-3 text-left"
@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">
<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>
</div>
<i class="pi transition-transform duration-200 text-surface-400 shrink-0"
:class="expandedCard === 'perfil' ? 'pi-angle-up' : 'pi-angle-down'" />
</button>
<div class="agd-accordion" :class="{ 'agd-accordion--open': expandedCard.has('perfil') }">
<button
type="button"
class="agd-accordion__header"
@click="toggleCard('perfil')"
>
<div class="agd-accordion__icon bg-blue-100 dark:bg-blue-900/30 text-blue-600">
<i class="pi pi-map-marker" />
</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>
<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>
<button
type="button"
class="w-full flex items-center justify-between gap-3 text-left"
@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">
<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>
</div>
<i class="pi transition-transform duration-200 text-surface-400 shrink-0"
:class="expandedCard === 'fluxo' ? 'pi-angle-up' : 'pi-angle-down'" />
</button>
<div class="agd-accordion" :class="{ 'agd-accordion--open': expandedCard.has('fluxo') }">
<button
type="button"
class="agd-accordion__header"
@click="toggleCard('fluxo')"
>
<div class="agd-accordion__icon bg-cyan-100 dark:bg-cyan-900/30 text-cyan-600">
<i class="pi pi-sitemap" />
</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>
<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>
<button
type="button"
class="w-full flex items-center justify-between gap-3 text-left"
@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">
<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>
</div>
<i class="pi transition-transform duration-200 text-surface-400 shrink-0"
:class="expandedCard === 'pagamento' ? 'pi-angle-up' : 'pi-angle-down'" />
</button>
<div class="agd-accordion" :class="{ 'agd-accordion--open': expandedCard.has('pagamento') }">
<button
type="button"
class="agd-accordion__header"
@click="toggleCard('pagamento')"
>
<div class="agd-accordion__icon bg-green-100 dark:bg-green-900/30 text-green-600">
<i class="pi pi-credit-card" />
</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>
<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>
<button
type="button"
class="w-full flex items-center justify-between gap-3 text-left"
@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">
<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>
</div>
<i class="pi transition-transform duration-200 text-surface-400 shrink-0"
:class="expandedCard === 'triagem' ? 'pi-angle-up' : 'pi-angle-down'" />
</button>
<div class="agd-accordion" :class="{ 'agd-accordion--open': expandedCard.has('triagem') }">
<button
type="button"
class="agd-accordion__header"
@click="toggleCard('triagem')"
>
<div class="agd-accordion__icon bg-orange-100 dark:bg-orange-900/30 text-orange-600">
<i class="pi pi-shield" />
</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>
<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>
<button
type="button"
class="w-full flex items-center justify-between gap-3 text-left"
@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">
<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>
</div>
<i class="pi transition-transform duration-200 text-surface-400 shrink-0"
:class="expandedCard === 'textos' ? 'pi-angle-up' : 'pi-angle-down'" />
</button>
<div class="agd-accordion" :class="{ 'agd-accordion--open': expandedCard.has('textos') }">
<button
type="button"
class="agd-accordion__header"
@click="toggleCard('textos')"
>
<div class="agd-accordion__icon bg-pink-100 dark:bg-pink-900/30 text-pink-600">
<i class="pi pi-file-edit" />
</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>
<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);
}
</style>
/* ── 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" />
</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>
</div>
</div>
<Button
label="Novo convênio"
icon="pi pi-plus"
:disabled="pageLoading || addingNew"
@click="addingNew = true; cancelEdit()"
/>
</div>
</template>
</Card>
<!-- 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 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>
<!-- 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>
</div>
</template>
<template #content>
<!-- 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>
<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>
</template>
</Card>
</div>
</div>
<!-- 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>
</template>
</Card>
<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>
<!-- Lista de convênios -->
<Card v-for="plan in plans" :key="plan.id" :class="{ 'opacity-60': !plan.active }">
<template #content>
<!-- Modo edição do plano -->
<template v-if="editingId === plan.id">
<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,169 +319,138 @@ 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>
</template>
</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="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>
</div>
<div class="flex items-center gap-2 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)"
/>
<Button
:icon="plan.active ? 'pi pi-eye-slash' : 'pi pi-eye'"
:severity="plan.active ? 'secondary' : 'success'"
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)" />
<!-- Modo leitura -->
<template v-else>
<!-- Cabeçalho do plano -->
<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-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-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" 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"
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="'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">
<template v-for="ps in plan.insurance_plan_services" :key="ps.id">
<!-- 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>
<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 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>
<!-- 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>
</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>
</div>
</template>
</div>
<div v-else-if="addingServicePlanId !== plan.id" class="text-sm text-color-secondary mb-3 italic">
Nenhum procedimento cadastrado.
</div>
<!-- Formulário adicionar procedimento -->
<div v-if="addingServicePlanId === plan.id" class="mt-3">
<!-- Cards de serviços para auto-preencher -->
<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">
<button
v-for="svc in services.filter(s => s.active)"
:key="svc.id"
class="svc-quick-card"
@click="fillFromService(svc)"
>
<span class="svc-quick-name">{{ svc.name }}</span>
<span class="svc-quick-price">{{ fmtBRL(svc.price) }}</span>
</button>
<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>
<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>
<InputText v-model="newServiceForm.name" placeholder="Ex: Consulta" class="w-full" size="small" />
<!-- 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="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>
<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 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>
</div>
<Button
v-if="addingServicePlanId !== plan.id"
label="Adicionar procedimento"
icon="pi pi-plus"
severity="secondary"
outlined
size="small"
class="mt-2"
@click="startAddService(plan.id)"
/>
</template>
</div>
<div v-else-if="addingServicePlanId !== plan.id" class="text-sm text-[var(--text-color-secondary)] italic px-1 py-2">
Nenhum procedimento cadastrado.
</div>
</template>
<!-- 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="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"
class="svc-quick-card"
@click="fillFromService(svc)"
>
<span class="svc-quick-name">{{ svc.name }}</span>
<span class="svc-quick-price">{{ fmtBRL(svc.price) }}</span>
</button>
</div>
</div>
<!-- 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="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>
<!-- 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>
<Button
v-if="addingServicePlanId !== plan.id"
label="Adicionar procedimento"
icon="pi pi-plus"
severity="secondary" outlined size="small" class="mt-2 rounded-full"
@click="startAddService(plan.id)"
/>
</div>
</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); }
</style>
.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" />
</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>
</div>
</div>
<Button
label="Novo desconto"
icon="pi pi-plus"
:disabled="pageLoading || addingNew"
@click="addingNew = true; cancelEdit()"
/>
</div>
</template>
</Card>
<!-- 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 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>
<!-- 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>
<template v-for="disc in discounts" :key="disc.id">
<div class="dsc-list">
<!-- Modo edição inline -->
<div v-if="editingId === disc.id" class="discount-row editing">
<div class="grid grid-cols-12 gap-3 flex-1">
<template v-for="disc in discounts" :key="disc.id">
<!-- Paciente (desabilitado na edição) -->
<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"
/>
<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
/>
<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
/>
<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
/>
<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
/>
<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"
/>
<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>
</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>
</div>
<div class="text-sm text-600 mt-0.5">
<span v-if="disc.active_from || disc.active_to">
{{ 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>
</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>
</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 -->
<!-- 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="newForm.patient_id"
inputId="new-patient"
:options="patients"
optionLabel="nome_completo"
optionValue="id"
filter
class="w-full"
/>
<label for="new-patient">Paciente *</label>
<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="newForm.discount_pct"
inputId="new-pct"
:min="0"
:max="100"
:minFractionDigits="0"
:maxFractionDigits="2"
suffix="%"
fluid
/>
<label for="new-pct">Desconto %</label>
<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="newForm.discount_flat"
inputId="new-flat"
mode="currency"
currency="BRL"
locale="pt-BR"
:min="0"
fluid
/>
<label for="new-flat">Desconto R$</label>
<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="newForm.active_from"
inputId="new-from"
dateFormat="dd/mm/yy"
showButtonBar
fluid
/>
<label for="new-from">Vigência: de</label>
<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="newForm.active_to"
inputId="new-to"
dateFormat="dd/mm/yy"
showButtonBar
fluid
/>
<label for="new-to">Vigência: até</label>
<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="newForm.reason"
inputId="new-reason"
class="w-full"
/>
<label for="new-reason">Motivo (opcional)</label>
<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"
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="cancelEdit" />
<Button label="Salvar" icon="pi pi-check" size="small" :loading="savingEdit" class="rounded-full" @click="saveEdit" />
</div>
</div>
<!-- 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-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' }}
</span>
<span v-else>Vigência indefinida</span>
</div>
<div v-if="disc.reason" class="text-xs text-[var(--text-color-secondary)] italic mt-0.5">{{ disc.reason }}</div>
</div>
<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>
<!-- 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" />
<label for="new-patient">Paciente *</label>
</FloatLabel>
</div>
<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 />
<label for="new-pct">Desconto %</label>
</FloatLabel>
</div>
<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 />
<label for="new-flat">Desconto R$</label>
</FloatLabel>
</div>
<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 />
<label for="new-from">Vigência: de</label>
</FloatLabel>
</div>
<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 />
<label for="new-to">Vigência: até</label>
</FloatLabel>
</div>
<div class="col-span-12">
<FloatLabel variant="on">
<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 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>
</template>
</Card>
</div>
</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>
</template>
</Card>
<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>
<!-- 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;
}
</style>
/* 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.
</div>
</div>
</div>
</template>
</Card>
<!-- 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>
<!-- 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>
<div v-for="et in exceptionTypes" :key="et.value" class="cfg-wrap">
<!-- 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>
<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>
</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))"
label="Configurar"
icon="pi pi-cog"
size="small"
severity="secondary"
outlined
class="ml-auto flex-shrink-0"
@click="startEdit(et.value)"
<!-- 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)">
<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" />
</template>
<Button
v-if="editingType !== et.value && !isGlobalRecord(recordFor(et.value))"
label="Configurar"
icon="pi pi-cog"
size="small"
severity="secondary"
outlined
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">
<!-- Modo edição -->
<div v-else class="exc-edit">
<!-- Modo de cobrança -->
<div>
<label class="exc-label">Modo de cobrança</label>
<SelectButton
v-model="editForm.charge_mode"
:options="chargeModeOptions"
optionLabel="label"
optionValue="value"
class="flex-wrap mt-1"
/>
</div>
<div class="font-semibold text-900">{{ et.label }}</div>
<!-- Modo de cobrança -->
<div>
<label class="text-sm text-600 block mb-2">Modo de cobrança</label>
<SelectButton
v-model="editForm.charge_mode"
:options="chargeModeOptions"
optionLabel="label"
optionValue="value"
class="flex-wrap"
/>
</div>
<div class="grid grid-cols-12 gap-3">
<!-- Taxa fixa (R$) -->
<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
/>
<label for="edit-charge-value">Taxa fixa (R$)</label>
</FloatLabel>
</div>
<!-- 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
/>
<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">
<FloatLabel variant="on">
<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">
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"
/>
</div>
</div>
<div class="grid grid-cols-12 gap-3">
<!-- 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 />
<label for="edit-charge-value">Taxa fixa (R$)</label>
</FloatLabel>
</div>
</template>
</template>
</Card>
<!-- 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 />
<label for="edit-charge-pct">Percentual da sessão (%)</label>
</FloatLabel>
</div>
<!-- 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 />
<label for="edit-min-hours">Cobrar se cancelado com menos de X horas (vazio = sempre)</label>
</FloatLabel>
<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>
<!-- 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>
<!-- 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>
</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" />
</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>
</div>
</div>
<Button
label="Novo serviço"
icon="pi pi-plus"
:disabled="pageLoading || addingNew"
@click="addingNew = true; cancelEdit()"
/>
</div>
</template>
</Card>
<!-- 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 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>
<!-- 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>
</div>
</template>
<template #content>
<!-- 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>
<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>
</template>
</Card>
</div>
</div>
<!-- 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>
</template>
</Card>
<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>
<!-- 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 edição -->
<template v-if="editingId === svc.id">
<!-- 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-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 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>
</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>
</div>
</template>
</div>
</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,45 +12,42 @@ 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 = [
...(Array.isArray(publicRoutes) ? publicRoutes : [publicRoutes]),
...(Array.isArray(authRoutes) ? authRoutes : [authRoutes]),
...(Array.isArray(miscRoutes) ? miscRoutes : [miscRoutes]),
...(Array.isArray(billingRoutes) ? billingRoutes : [billingRoutes]),
...(Array.isArray(saasRoutes) ? saasRoutes : [saasRoutes]),
...(Array.isArray(meRoutes) ? meRoutes : [meRoutes]),
...(Array.isArray(adminRoutes) ? adminRoutes : [adminRoutes]),
...(Array.isArray(therapistRoutes) ? therapistRoutes : [therapistRoutes]),
...(Array.isArray(supervisorRoutes) ? supervisorRoutes : [supervisorRoutes]),
...(Array.isArray(editorRoutes) ? editorRoutes : [editorRoutes]),
...(Array.isArray(portalRoutes) ? portalRoutes : [portalRoutes]),
...(Array.isArray(configuracoesRoutes) ? configuracoesRoutes : [configuracoesRoutes]),
...(Array.isArray(featuresRoutes) ? featuresRoutes : [featuresRoutes]),
// ✅ compat: rota antiga /login → /auth/login (evita 404 se algum trecho legado usar /login)
{
path: '/login',
redirect: (to) => ({
path: '/auth/login',
query: to.query || {}
})
},
...(Array.isArray(publicRoutes) ? publicRoutes : [publicRoutes]),
...(Array.isArray(authRoutes) ? authRoutes : [authRoutes]),
...(Array.isArray(miscRoutes) ? miscRoutes : [miscRoutes]),
...(Array.isArray(billingRoutes) ? billingRoutes : [billingRoutes]),
...(Array.isArray(saasRoutes) ? saasRoutes : [saasRoutes]),
...(Array.isArray(meRoutes) ? meRoutes : [meRoutes]),
...(Array.isArray(adminRoutes) ? adminRoutes : [adminRoutes]),
...(Array.isArray(therapistRoutes) ? therapistRoutes : [therapistRoutes]),
...(Array.isArray(supervisorRoutes) ? supervisorRoutes : [supervisorRoutes]),
...(Array.isArray(editorRoutes) ? editorRoutes : [editorRoutes]),
...(Array.isArray(portalRoutes) ? portalRoutes : [portalRoutes]),
...(Array.isArray(configuracoesRoutes) ? configuracoesRoutes : [configuracoesRoutes]),
...(Array.isArray(featuresRoutes) ? featuresRoutes : [featuresRoutes]),
// inserido no routes.misc { path: '/:pathMatch(.*)*', name: 'notfound', component: () => import('@/views/pages/NotFound.vue') }
// ✅ compat: rota antiga /login → /auth/login
{
path: '/login',
redirect: (to) => ({
path: '/auth/login',
query: to.query || {}
})
},
];
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;
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>
</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)]">
<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,215 +422,272 @@ onMounted(fetchMeuPlanoClinic)
</script>
<template>
<div class="p-4 md:p-6">
<Toast />
<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>
<!-- 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="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>
<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"
/>
<!-- 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>
<!-- 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>
<!-- 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>
<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>
<!-- Conteúdo com assinatura -->
<div v-else class="flex flex-col gap-3">
<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>
<!-- 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 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 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 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 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>
</template>
</Card>
</div>
<Divider class="my-6" />
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<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.
<!-- 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 v-else-if="!features.length" class="text-color-secondary">
Nenhuma feature vinculada a este plano.
</div>
<div class="p-4">
<div v-if="!features.length" class="text-[1rem] text-[var(--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"
>
<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) }}
<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>
<Tag :value="`${g.items.length}`" severity="secondary" rounded />
</div>
<div class="p-4">
<ul class="m-0 p-0 list-none space-y-3">
<li
<!-- 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>
<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>
</div>
<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-[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>
</li>
</ul>
</div>
</div>
<div class="text-xs text-color-secondary">
Agrupamento automático por prefixo da key (ex.: <b>agenda.*</b>, <b>patients.*</b>, etc.).
</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).
</div>
<div v-else-if="!events.length" class="text-color-secondary">
Sem eventos registrados.
</div>
<div v-else class="space-y-3">
<div
v-for="ev in events"
:key="ev.id"
class="rounded-2xl border border-[var(--surface-border)] p-3"
>
<div class="flex items-start justify-between gap-3 flex-wrap">
<div class="min-w-0">
<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">
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>
<div v-if="ev.reason" class="mt-2 text-sm opacity-80">
{{ ev.reason }}
</div>
</div>
<div class="text-sm text-color-secondary">
{{ fmtDate(ev.created_at) }}
</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 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="mt-4 text-xs text-color-secondary">
Mostrando até 50 eventos (mais recentes).
<!-- 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>
<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>
</template>
</Card>
<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="flex flex-col gap-2">
<div
v-for="ev in events"
:key="ev.id"
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-[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-[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-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-[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>
</div>
</template>
<style scoped>
/* (intencionalmente vazio) */
</style>
</style>

View File

@@ -302,236 +302,261 @@ 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
</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>
<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>
<!--
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)]'"
>{{ 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-5 mb-5 flex flex-col gap-4">
<!--
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-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
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 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" />
<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>
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<template v-else>
<!-- 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>
<!-- 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>
<Tag v-if="features.length" :value="`${features.length}`" severity="secondary" />
</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>
<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 class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<div v-else class="space-y-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>
</div>
<!-- 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>
<!-- 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"
:title="f.description || f.key"
>
<i class="pi pi-check-circle text-emerald-500 text-sm mt-0.5 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="p-4">
<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">
<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>
<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-md hover:bg-[var(--surface-ground,#f8fafc)] transition-colors"
:title="f.description || f.key"
>
<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-[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>
</div>
</div>
</div>
</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>
</div>
<Tag v-if="events.length" :value="`${events.length}`" severity="secondary" />
</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>
<!-- 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>
<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 class="space-y-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"
>
<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)]">
por <b>{{ displayUser(ev.created_by) }}</b>
</span>
</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-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">
<span class="font-medium text-[var(--text-color)]">{{ planKeyOrName(ev.old_plan_id) }}</span>
<i class="pi pi-arrow-right text-xs 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.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>
<div v-else class="flex flex-col gap-2">
<div
v-for="ev in events"
:key="ev.id"
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-[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-[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-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-[1rem] text-[var(--text-color-secondary)] flex-shrink-0">{{ fmtDate(ev.created_at) }}</div>
</div>
<div class="text-xs text-[var(--text-color-secondary)] shrink-0">{{ fmtDate(ev.created_at) }}</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>
</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 />
<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="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="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"
/>
<!-- 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 || saving" />
</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="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>
<div class="mt-3 flex flex-wrap items-center gap-3">
<!-- 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>
<!-- 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="`Plano atual: ${currentSub.plan_key} ${intervalLabel(currentSub.interval)} • ${currentSub.status}`"
:value="`Atual: ${currentSub.plan_key} · ${intervalLabel(currentSub.interval)}`"
severity="success"
rounded
/>
<Tag
v-else
value="Você ainda não tem um plano pessoal."
severity="warning"
rounded
/>
<Tag v-else value="Sem plano pessoal" severity="warning" />
</div>
<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>
</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>
<!-- 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">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText v-model="q" id="therapist_upgrade_search" class="w-full pr-10" variant="filled" />
</IconField>
<label for="therapist_upgrade_search">Buscar plano...</label>
</FloatLabel>
</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>
</template>
</Card>
<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>
<Divider />
<!-- Cards estilo vitrine -->
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 mt-4">
<Card
<!-- 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">
<div class="min-w-0">
<div class="font-semibold truncate">{{ p.name || p.key }}</div>
<small class="text-color-secondary">{{ p.key }}</small>
</div>
<Tag v-if="currentSub?.plan_id === p.id" severity="success" value="Atual" rounded />
<!-- 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-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>
</template>
<Tag v-if="currentSub?.plan_id === p.id" severity="success" value="Atual" />
</div>
<template #content>
<div class="text-sm text-color-secondary" v-if="p.description">
{{ p.description }}
<!-- 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>
<!-- 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)"
/>
<Button
label="Mensal"
severity="secondary"
outlined
:disabled="loading || saving"
@click="choosePlan(p, 'month')"
/>
<Button
label="Anual"
severity="secondary"
outlined
:disabled="loading || saving"
@click="choosePlan(p, 'year')"
/>
<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>
<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>
<!-- 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>
</template>
</Card>
</div>
</div>
</div>
<div v-if="!filteredPlans.length && !loading" class="mt-4 text-sm text-color-secondary">
Nenhum plano encontrado.
</div>
</div>
</template>
</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,174 +311,292 @@ 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" />
</div>
<!-- Sentinel -->
<div class="h-px" />
<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)]">
Contexto: <b>{{ isTherapist ? 'Terapeuta' : 'Clínica' }}</b>
<span class="mx-2 opacity-50"></span>
Plano atual: <b>{{ currentPlanKey || '—' }}</b>
</div>
</div>
<!--
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="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" />
<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-1.5 opacity-40">·</span>
Plano atual: <b>{{ currentPlanKey || '—' }}</b>
</div>
</div>
</div>
<!-- recurso bloqueado -->
<div
v-if="requestedFeatureLabel"
class="relative overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4"
>
<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>
</div>
<!-- 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>
<!-- 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 && !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="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>
</Transition>
<!--
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 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
v-for="n in 2"
: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-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>
<!-- 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="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>
<!-- 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>{{ p.description || p.key }}</template>
</div>
<div class="text-[1.6rem] font-bold leading-none text-[var(--text-color)]">{{ priceLabelForPlan(p.id) }}</div>
</div>
<!-- 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="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>
<div class="h-px bg-[var(--surface-border,#e2e8f0)] my-3" />
<!-- Ações -->
<div class="flex flex-col gap-2">
<Button
label="Ver planos"
icon="pi pi-arrow-down"
v-if="currentPlanId !== p.id"
:label="`Mudar para ${String(p.key || '').toUpperCase()}`"
icon="pi pi-arrow-up"
class="w-full rounded-full"
:loading="upgrading"
:disabled="upgrading || loading"
@click="changePlan(p.id)"
/>
<Button
v-else
label="Você já está neste plano"
icon="pi pi-check"
severity="secondary"
outlined
@click="() => document.getElementById('plans-grid')?.scrollIntoView({ behavior: 'smooth', block: 'start' })"
class="w-full rounded-full"
disabled
/>
</div>
</div>
<!-- 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" />
<Button
v-if="String(p.key).toLowerCase() !== 'free'"
label="Falar com suporte"
icon="pi pi-comments"
severity="secondary"
outlined
class="w-full rounded-full"
:disabled="upgrading"
@click="contactSupport"
/>
<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-[1rem] text-amber-500">
Sem assinatura ativa clique em <b>Assinatura</b> para ativar/criar.
</div>
</div>
</div>
</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">
<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)]'"
>
<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>
<Card class="relative border-0">
<template #title>
<div class="flex items-center justify-between gap-2">
<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>
</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)]">
<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>
</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">
<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>
</li>
</ul>
<Divider class="my-4" />
<div class="flex flex-col gap-3">
<Button
v-if="currentPlanId !== p.id"
:label="`Mudar para ${String(p.key || '').toUpperCase()}`"
icon="pi pi-arrow-up"
size="large"
class="w-full"
:loading="upgrading"
:disabled="upgrading || loading"
@click="changePlan(p.id)"
/>
<Button
v-else
label="Você já está neste plano"
icon="pi pi-check"
severity="secondary"
outlined
class="w-full"
disabled
/>
<Button
v-if="String(p.key).toLowerCase() !== 'free'"
label="Falar com suporte"
icon="pi pi-comments"
severity="secondary"
outlined
class="w-full"
:disabled="upgrading"
@click="contactSupport"
/>
<div class="text-center text-xs text-[var(--text-color-secondary)]">
Cancele quando quiser. Sem burocracia.
</div>
<div v-if="!subscription?.id" class="text-center text-xs 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>
</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>
@@ -30,4 +34,4 @@ import StatsWidget from '@/components/dashboard/StatsWidget.vue';
<NotificationsWidget />
</div>
</div>
</template>
</template>

View File

@@ -324,268 +324,256 @@ watch(
</script>
<template>
<div class="p-4 md:p-6">
<Toast />
<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" />
</div>
<!-- Sentinel -->
<div class=h-px />
<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>
<div class="shrink-0 flex items-center gap-2">
<Button
label="Recarregar"
icon="pi pi-refresh"
severity="secondary"
outlined
: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>
</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" />
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"
>
<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"
>
<i class="pi pi-spin pi-spinner" />
Atualizando módulos
</span>
</div>
</div>
</div>
<!-- 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>
<!-- Banner: acesso somente leitura para terapeutas -->
<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>
<Button
label=Recarregar
icon=pi pi-refresh
severity=secondary
outlined
:loading=loading
:disabled=applyingPreset || !!savingKey
@click=reload
/>
</div>
</div>
<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 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 text-[1rem] text-[var(--text-color-secondary)] opacity-70
>
<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 text-[1rem] text-[var(--text-color-secondary)] opacity-70
>
<i class=pi pi-spin pi-spinner />
Atualizando módulos
</span>
</div>
</div>
</div>
<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">
Para aluguel de salas: sem pacientes, com salas.
</div>
<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>
<Button
size="small"
label="Aplicar"
severity="secondary"
outlined
:loading="applyingPreset"
:disabled="!isOwner || !tenantReady || loading || !!savingKey"
@click="applyPreset('coworking')"
/>
</div>
</template>
</Card>
<Button
size=small
label=Aplicar
severity=secondary
outlined
:loading=applyingPreset
:disabled=!isOwner || !tenantReady || loading || !!savingKey
@click=applyPreset('coworking')
/>
</div>
</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">
Para secretária gerenciar agenda (pacientes opcional).
</div>
<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>
<Button
size="small"
label="Aplicar"
severity="secondary"
outlined
:loading="applyingPreset"
:disabled="!isOwner || !tenantReady || loading || !!savingKey"
@click="applyPreset('reception')"
/>
</div>
</template>
</Card>
<Button
size=small
label=Aplicar
severity=secondary
outlined
:loading=applyingPreset
:disabled=!isOwner || !tenantReady || loading || !!savingKey
@click=applyPreset('reception')
/>
</div>
</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">
Pacientes + recepção + salas (se quiser).
</div>
<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>
<Button
size="small"
label="Aplicar"
severity="secondary"
outlined
:loading="applyingPreset"
:disabled="!isOwner || !tenantReady || loading || !!savingKey"
@click="applyPreset('full')"
/>
</div>
</template>
</Card>
<Button
size=small
label=Aplicar
severity=secondary
outlined
:loading=applyingPreset
:disabled=!isOwner || !tenantReady || loading || !!savingKey
@click=applyPreset('full')
/>
</div>
</div>
</div>
<!-- Modules -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<Card class="rounded-[2rem]">
<template #content>
<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')"
/>
<div
v-if="planDenied.has('patients')"
class="mt-3 text-xs rounded-2xl border border-[var(--surface-border)] p-3 opacity-90"
>
<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">
Quando desligado:
<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>RLS bloqueia acesso direto no banco.</li>
</ul>
</div>
</template>
</Card>
<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')
/>
<div
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 />
Este módulo foi bloqueado pelo plano atual do tenant.
</div>
<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>
<li>Menu Pacientes some.</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>
</div>
<Card class="rounded-[2rem]">
<template #content>
<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')"
/>
<div
v-if="planDenied.has('shared_reception')"
class="mt-3 text-xs rounded-2xl border border-[var(--surface-border)] p-3 opacity-90"
>
<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">
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>
<li>policies e telas para a secretária</li>
<li>nível de visibilidade do paciente na agenda</li>
</ul>
</div>
</template>
</Card>
<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')
/>
<div
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 />
Este módulo foi bloqueado pelo plano atual do tenant.
</div>
<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>
<li>policies e telas para a secretária</li>
<li>nível de visibilidade do paciente na agenda</li>
</ul>
</div>
</div>
<Card class="rounded-[2rem]">
<template #content>
<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')"
/>
<div
v-if="planDenied.has('rooms')"
class="mt-3 text-xs rounded-2xl border border-[var(--surface-border)] p-3 opacity-90"
>
<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">
Isso prepara o terreno para a clínica operar como locação de sala, com agenda vinculando sala + profissional.
</div>
</template>
</Card>
<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')
/>
<div
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 />
Este módulo foi bloqueado pelo plano atual do tenant.
</div>
<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>
</div>
<Card class="rounded-[2rem]">
<template #content>
<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')"
/>
<div
v-if="planDenied.has('intake_public')"
class="mt-3 text-xs rounded-2xl border border-[var(--surface-border)] p-3 opacity-90"
>
<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">
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 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')
/>
<div
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 />
Este módulo foi bloqueado pelo plano atual do tenant.
</div>
<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>
</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,91 +743,76 @@ onMounted(async () => {
</script>
<template>
<div class="p-4 md:p-6">
<Toast />
<ConfirmDialog />
<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 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)]">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="relative 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">
Gerencie terapeutas e secretarias vinculados ao seu tenant.
</div>
</div>
<div class="flex items-center gap-2 flex-wrap">
<Button
label="Convidar terapeuta"
icon="pi pi-user-plus"
@click="openInvite('therapist')"
:disabled="!tenantReady || !canManage"
/>
<Button
label="Adicionar secretária"
icon="pi pi-users"
severity="secondary"
outlined
@click="openInvite('secretary')"
:disabled="!tenantReady || !canManage"
/>
</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">
Carregando permissões da clínica
</div>
<!-- Aviso de permissão -->
<div v-else-if="!canManage" class="mt-2 text-sm 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">
tenantId={{ tenantId }} | role={{ effectiveRole || '(vazio)' }} | canManage={{ canManage }}
</div>
<div class="flex items-center gap-2 flex-wrap shrink-0">
<Button
label="Convidar terapeuta"
icon="pi pi-user-plus"
@click="openInvite('therapist')"
:disabled="!tenantReady || !canManage"
/>
<Button
label="Adicionar secretária"
icon="pi pi-users"
severity="secondary"
outlined
@click="openInvite('secretary')"
:disabled="!tenantReady || !canManage"
/>
</div>
</div>
<!-- Loading leve do tenant -->
<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 (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="text-[1rem] opacity-70">
tenantId={{ tenantId }} | role={{ effectiveRole || '(vazio)' }} | canManage={{ canManage }}
</div>
</div>
</div>
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- 🔎 Aviso sobre logins de teste -->
<!-- 🔎 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%)]"
>
<!-- 🔎 Aviso sobre logins de teste + atalhos de convite -->
<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-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,424 +407,341 @@ 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="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>
<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>
<!-- desktop actions -->
<div class="hidden xl:flex items-center gap-2">
<SelectButton
v-model="intervalView"
:options="intervalOptions"
optionLabel="label"
optionValue="value"
:disabled="loading"
/>
<Button
label="Recarregar"
icon="pi pi-refresh"
severity="secondary"
outlined
:loading="loading"
@click="loadStats"
/>
<Button
label="Assinaturas"
icon="pi pi-credit-card"
severity="secondary"
outlined
:disabled="loading"
@click="router.push('/saas/subscriptions')"
/>
<Button
label="Eventos"
icon="pi pi-history"
severity="secondary"
outlined
:disabled="loading"
@click="router.push('/saas/subscription-events')"
/>
</div>
<!-- mobile -->
<div class="flex xl:hidden">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
severity="warn"
outlined
@click="(e) => mobileMenuRef.toggle(e)"
/>
<Menu ref="mobileMenuRef" :model="heroMenuItems" popup />
</div>
</div>
<!-- desktop actions -->
<div class="hidden xl:flex items-center gap-2 shrink-0">
<SelectButton
v-model="intervalView"
:options="intervalOptions"
optionLabel="label"
optionValue="value"
:disabled="loading"
/>
<Button
label="Recarregar"
icon="pi pi-refresh"
severity="secondary"
outlined
:loading="loading"
@click="loadStats"
/>
<Button
label="Assinaturas"
icon="pi pi-credit-card"
severity="secondary"
outlined
:disabled="loading"
@click="router.push('/saas/subscriptions')"
/>
<Button
label="Eventos"
icon="pi pi-history"
severity="secondary"
outlined
:disabled="loading"
@click="router.push('/saas/subscription-events')"
/>
</div>
<!-- mobile -->
<div class="flex xl:hidden shrink-0">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
severity="warn"
outlined
@click="(e) => mobileMenuRef.toggle(e)"
/>
<Menu ref="mobileMenuRef" :model="heroMenuItems" popup />
</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>
<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="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>
<div class="text-4xl font-semibold">{{ totalActive }}</div>
<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>
<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="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>
<div class="text-4xl font-semibold">{{ totalCanceled }}</div>
<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>
<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="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>
<div class="text-3xl font-semibold">{{ moneyBRLFromCents(revenueCents) }}</div>
<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>
<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="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>
<div class="text-3xl font-semibold">{{ moneyBRLFromCents(arpaCents) }}</div>
<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>
<Tag :value="intentsLoading ? 'carregando' : 'últimas'" severity="secondary" rounded />
</div>
</template>
<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 #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="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="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="text-2xl font-semibold">{{ totalIntentsPaid }}</div>
</div>
<div class="grid grid-cols-12 gap-3">
<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-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-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-[1rem] text-[var(--text-color-secondary)]">
Carregando intenções
</div>
<div v-else>
<div v-if="!intents.length" class="text-[1rem] text-[var(--text-color-secondary)]">
Nenhuma intenção encontrada.
</div>
<Divider class="my-3" />
<div v-if="intentsLoading" class="text-color-secondary text-sm">
Carregando intenções
</div>
<div v-else>
<div v-if="!intents.length" class="text-color-secondary text-sm">
Nenhuma intenção encontrada.
</div>
<div v-else class="space-y-2">
<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"
>
<div class="min-w-0">
<div class="font-medium truncate">
{{ maskEmail(it.email) }}
</div>
<div class="text-xs 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">
{{ fmtDate(it.created_at) }}
</div>
<div v-else class="space-y-2">
<div
v-for="(it, idx) in intents"
:key="idx"
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="shrink-0">
<Tag :value="it.status || '—'" :severity="statusSeverity(it.status)" rounded />
<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-[1rem] text-[var(--text-color-secondary)] mt-1">
{{ fmtDate(it.created_at) }}
</div>
</div>
</div>
<div class="flex gap-2 flex-wrap mt-3">
<Button
label="Atualizar"
icon="pi pi-refresh"
severity="secondary"
outlined
size="small"
:loading="intentsLoading || loading"
@click="loadIntents"
/>
<Button
label="Ver eventos"
icon="pi pi-history"
severity="secondary"
outlined
size="small"
:disabled="loading"
@click="openIntentEvents"
/>
</div>
<div class="text-color-secondary text-xs mt-3">
Mostrando {{ intentsLimit }} itens mais recentes de <span class="font-mono">subscription_intents</span>.
<div class="shrink-0">
<Tag :value="it.status || '—'" :severity="statusSeverity(it.status)" rounded />
</div>
</div>
</div>
</template>
</Card>
<div class="flex gap-2 flex-wrap mt-3">
<Button
label="Atualizar"
icon="pi pi-refresh"
severity="secondary"
outlined
size="small"
:loading="intentsLoading || loading"
@click="loadIntents"
/>
<Button
label="Ver eventos"
icon="pi pi-history"
severity="secondary"
outlined
size="small"
:disabled="loading"
@click="openIntentEvents"
/>
</div>
<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>
</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>
<Tag :severity="healthSeverity" :value="healthLabel" rounded />
</div>
</template>
<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 #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">
divergências entre plano (esperado) e entitlements (atual)
</small>
<div class="flex items-center justify-between gap-2">
<div class="text-4xl font-semibold">{{ totalMismatches }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] text-right">
divergências entre plano (esperado) e entitlements (atual)
</div>
</div>
<div class="text-color-secondary text-sm mt-2">
{{ healthHint }}
</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-2">
{{ healthHint }}
</div>
<Divider class="my-3" />
<Divider class="my-3" />
<div class="flex gap-2 flex-wrap">
<Button
v-if="totalMismatches > 0"
label="Corrigir tudo"
icon="pi pi-refresh"
severity="danger"
:loading="loading"
@click="askFixAll"
/>
<Button
label="Ver divergências"
icon="pi pi-search"
severity="secondary"
outlined
:disabled="loading"
@click="router.push('/saas/subscription-health')"
/>
</div>
<div class="flex gap-2 flex-wrap">
<Button
v-if="totalMismatches > 0"
label="Corrigir tudo"
icon="pi pi-refresh"
severity="danger"
:loading="loading"
@click="askFixAll"
/>
<Button
label="Ver divergências"
icon="pi pi-search"
severity="secondary"
outlined
:disabled="loading"
@click="router.push('/saas/subscription-health')"
/>
</div>
<div class="text-color-secondary text-xs mt-3" v-if="lastUpdatedAt">
Atualizado em {{ fmtDate(lastUpdatedAt) }}
</div>
</template>
</Card>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-3" v-if="lastUpdatedAt">
Atualizado em {{ fmtDate(lastUpdatedAt) }}
</div>
</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 style="height: 260px;">
<Chart type="bar" :data="chartData" :options="chartOptions" />
</div>
</template>
</Card>
<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>
</div>
</div>
</div>
<!-- Breakdown table (com ações) -->
<div class="mt-4">
<Card>
<template #title>Distribuição por plano</template>
<template #content>
<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">
{{ data.plan_active ? 'ativo no catálogo' : 'inativo no catálogo' }}
</small>
</div>
</template>
</Column>
<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>
<div class="text-[1rem] text-[var(--text-color-secondary)]">
{{ data.plan_active ? 'ativo no catálogo' : 'inativo no catálogo' }}
</div>
</div>
</template>
</Column>
<Column header="Público" style="width: 12rem">
<template #body="{ data }">
<Tag
:value="planTargetLabel(data.plan_target)"
:severity="planTargetSeverity(data.plan_target)"
rounded
/>
</template>
</Column>
<Column header="Público" style="width: 12rem">
<template #body="{ data }">
<Tag
:value="planTargetLabel(data.plan_target)"
:severity="planTargetSeverity(data.plan_target)"
rounded
/>
</template>
</Column>
<Column header="Ativas" style="width: 8rem">
<template #body="{ data }">{{ data.active_count }}</template>
</Column>
<Column header="Ativas" style="width: 8rem">
<template #body="{ data }">{{ data.active_count }}</template>
</Column>
<Column header="Canceladas" style="width: 10rem">
<template #body="{ data }">{{ data.canceled_count }}</template>
</Column>
<Column header="Canceladas" style="width: 10rem">
<template #body="{ data }">{{ data.canceled_count }}</template>
</Column>
<Column header="Preço (ref.)" style="min-width: 12rem">
<template #body="{ data }">{{ moneyBRLFromCents(data.price_cents) }}</template>
</Column>
<Column header="Preço (ref.)" style="min-width: 12rem">
<template #body="{ data }">{{ moneyBRLFromCents(data.price_cents) }}</template>
</Column>
<Column :header="intervalView === 'year' ? 'ARR' : 'MRR'" style="min-width: 12rem">
<template #body="{ data }">{{ moneyBRLFromCents(data.revenue_cents) }}</template>
</Column>
<Column :header="intervalView === 'year' ? 'ARR' : 'MRR'" style="min-width: 12rem">
<template #body="{ data }">{{ moneyBRLFromCents(data.revenue_cents) }}</template>
</Column>
<Column header="Ações" style="width: 16rem">
<template #body="{ data }">
<div class="flex gap-2 justify-end flex-wrap">
<Button
label="Abrir vitrine"
icon="pi pi-external-link"
severity="secondary"
outlined
size="small"
:disabled="!data.plan_key || data.plan_key === '(sem plano)'"
@click="openPlanPublic(data.plan_key)"
/>
<Button
icon="pi pi-pencil"
severity="secondary"
outlined
size="small"
:disabled="!data.plan_key || data.plan_key === '(sem plano)'"
v-tooltip.top="'Abrir catálogo interno do plano'"
@click="openPlanCatalog(data.plan_key)"
/>
</div>
</template>
</Column>
</DataTable>
<Column header="Ações" style="width: 16rem">
<template #body="{ data }">
<div class="flex gap-2 justify-end flex-wrap">
<Button
label="Abrir vitrine"
icon="pi pi-external-link"
severity="secondary"
outlined
size="small"
:disabled="!data.plan_key || data.plan_key === '(sem plano)'"
@click="openPlanPublic(data.plan_key)"
/>
<Button
icon="pi pi-pencil"
severity="secondary"
outlined
size="small"
:disabled="!data.plan_key || data.plan_key === '(sem plano)'"
v-tooltip.top="'Abrir catálogo interno do plano'"
@click="openPlanCatalog(data.plan_key)"
/>
</div>
</template>
</Column>
</DataTable>
<div class="text-color-secondary text-sm 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 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>
</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,84 +125,93 @@ 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" />
</div>
<div>
<h1 class="faq-title">Central de Ajuda</h1>
<p class="faq-subtitle">Encontre respostas para as dúvidas mais comuns</p>
</div>
<!-- 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>
<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">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText
v-model="busca"
placeholder="Buscar pergunta…"
class="faq-search-input"
/>
<InputIcon v-if="busca" class="pi pi-times cursor-pointer" @click="busca = ''" />
</IconField>
<div v-if="totalResultados !== null" class="faq-search-result">
{{ totalResultados }} resultado{{ totalResultados !== 1 ? 's' : '' }} encontrado{{ totalResultados !== 1 ? 's' : '' }}
</div>
<!-- Busca -->
<div>
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText
v-model="busca"
placeholder="Buscar pergunta…"
class="w-full"
/>
<InputIcon v-if="busca" class="pi pi-times cursor-pointer" @click="busca = ''" />
</IconField>
<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">
<i class="pi pi-spinner pi-spin text-2xl opacity-30" />
</div>
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-16">
<i class="pi pi-spinner pi-spin text-2xl opacity-30" />
</div>
<template v-else>
<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>
</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,56 +233,53 @@ 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 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>
<div class="features-hero__inner">
<div class="features-hero__info min-w-0">
<div class="features-hero__title">Recursos do Sistema</div>
</div>
<!-- Ações desktop ( 1200px) -->
<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 desktop ( 1200px) -->
<div class="features-hero__actions features-hero__actions--desktop">
<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">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
severity="warn"
size="small"
aria-haspopup="true"
aria-controls="features_hero_menu"
@click="(e) => heroMenuRef.toggle(e)"
/>
<Menu ref="heroMenuRef" id="features_hero_menu" :model="heroMenuItems" :popup="true" />
</div>
<!-- Ações mobile (< 1200px) -->
<div class="flex xl:hidden">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
severity="warn"
size="small"
aria-haspopup="true"
aria-controls="features_hero_menu"
@click="(e) => heroMenuRef.toggle(e)"
/>
<Menu ref="heroMenuRef" id="features_hero_menu" :model="heroMenuItems" :popup="true" />
</div>
</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>
@@ -334,151 +330,87 @@ onBeforeUnmount(() => {
</template>
</Column>
</DataTable>
</div>
</div>
<Dialog
v-model:visible="showDlg"
modal
:header="isEdit ? 'Editar recurso' : 'Novo recurso'"
:style="{ width: '640px' }"
:closable="!saving"
:dismissableMask="!saving"
:draggable="false"
>
<div class="flex flex-col gap-4">
<!-- Key -->
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-tag" />
<InputText
id="cr-key"
v-model.trim="form.key"
class="w-full"
variant="filled"
:disabled="saving"
autocomplete="off"
autofocus
@blur="form.key = slugifyKey(form.key)"
@keydown.enter.prevent="save"
/>
</IconField>
<label for="cr-key">Key *</label>
</FloatLabel>
<small class="text-color-secondary block 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>
<!-- Nome -->
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-bookmark" />
<InputText
id="cr-name"
v-model.trim="form.name"
class="w-full"
variant="filled"
:disabled="saving"
autocomplete="off"
@keydown.enter.prevent="save"
/>
</IconField>
<label for="cr-name">Nome *</label>
</FloatLabel>
<small class="text-color-secondary block mt-1">
Nome exibido para o usuário na página de upgrade e nas listagens.
</small>
</div>
<!-- Descrição PT-BR -->
<div>
<FloatLabel variant="on">
<Textarea
id="cr-desc-pt"
v-model.trim="form.descricao"
<Dialog
v-model:visible="showDlg"
modal
:header="isEdit ? 'Editar recurso' : 'Novo recurso'"
:style="{ width: '640px' }"
:closable="!saving"
:dismissableMask="!saving"
:draggable="false"
>
<div class="flex flex-col gap-4">
<!-- Key -->
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-tag" />
<InputText
id="cr-key"
v-model.trim="form.key"
class="w-full"
rows="3"
autoResize
variant="filled"
:disabled="saving"
autocomplete="off"
autofocus
@blur="form.key = slugifyKey(form.key)"
@keydown.enter.prevent="save"
/>
<label for="cr-desc-pt">Descrição</label>
</FloatLabel>
<small class="text-color-secondary block mt-1">
Explique o que o recurso habilita e para quem se aplica.
</small>
</IconField>
<label for="cr-key">Key *</label>
</FloatLabel>
<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.
</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" />
</template>
</Dialog>
</div>
</template>
<!-- Nome -->
<div>
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-bookmark" />
<InputText
id="cr-name"
v-model.trim="form.name"
class="w-full"
variant="filled"
:disabled="saving"
autocomplete="off"
@keydown.enter.prevent="save"
/>
</IconField>
<label for="cr-name">Nome *</label>
</FloatLabel>
<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.
</div>
</div>
<style scoped>
.features-root { padding: 1rem; }
@media (min-width: 768px) { .features-root { padding: 1.5rem; } }
<!-- Descrição PT-BR -->
<div>
<FloatLabel variant="on">
<Textarea
id="cr-desc-pt"
v-model.trim="form.descricao"
class="w-full"
rows="3"
autoResize
:disabled="saving"
/>
<label for="cr-desc-pt">Descrição</label>
</FloatLabel>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">
Explique o que o recurso habilita e para quem se aplica.
</div>
</div>
</div>
.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>
<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" />
</template>
</Dialog>
</template>

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,57 +398,55 @@ 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 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>
<div class="matrix-hero__inner">
<div class="matrix-hero__info min-w-0">
<div class="matrix-hero__title">Controle de Recursos</div>
</div>
<!-- Ações desktop ( 1200px) -->
<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" />
<Button label="Salvar alterações" icon="pi pi-save" size="small" :loading="saving" :disabled="loading || !hasPending" @click="saveChanges" />
</div>
<!-- Ações desktop ( 1200px) -->
<div class="matrix-hero__actions matrix-hero__actions--desktop">
<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" />
<Button label="Salvar alterações" icon="pi pi-save" size="small" :loading="saving" :disabled="loading || !hasPending" @click="saveChanges" />
</div>
<!-- Ações mobile (< 1200px) -->
<div class="matrix-hero__actions--mobile">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
severity="warn"
size="small"
aria-haspopup="true"
aria-controls="matrix_hero_menu"
@click="(e) => heroMenuRef.toggle(e)"
/>
<Menu ref="heroMenuRef" id="matrix_hero_menu" :model="heroMenuItems" :popup="true" />
</div>
<!-- Ações mobile (< 1200px) -->
<div class="flex xl:hidden">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
severity="warn"
size="small"
aria-haspopup="true"
aria-controls="matrix_hero_menu"
@click="(e) => heroMenuRef.toggle(e)"
/>
<Menu ref="heroMenuRef" id="matrix_hero_menu" :model="heroMenuItems" :popup="true" />
</div>
</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>
</template>

View File

@@ -329,56 +329,53 @@ 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 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>
<div class="limits-hero__inner">
<div class="limits-hero__info min-w-0">
<div class="limits-hero__title">Limites por Plano</div>
</div>
<!-- Ações desktop ( 1200px) -->
<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 desktop ( 1200px) -->
<div class="limits-hero__actions limits-hero__actions--desktop">
<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">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
severity="warn"
size="small"
aria-haspopup="true"
aria-controls="limits_hero_menu"
@click="(e) => heroMenuRef.toggle(e)"
/>
<Menu ref="heroMenuRef" id="limits_hero_menu" :model="heroMenuItems" :popup="true" />
</div>
<!-- Ações mobile (< 1200px) -->
<div class="flex xl:hidden">
<Button
label="Ações"
icon="pi pi-ellipsis-v"
severity="warn"
size="small"
aria-haspopup="true"
aria-controls="limits_hero_menu"
@click="(e) => heroMenuRef.toggle(e)"
/>
<Menu ref="heroMenuRef" id="limits_hero_menu" :model="heroMenuItems" :popup="true" />
</div>
</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,260 +512,195 @@ onBeforeUnmount(() => {
</template>
</Column>
</DataTable>
</div>
</div><!-- /px-4 pb-4 -->
<!-- Dialog: editar limites de plan_features -->
<Dialog
v-model:visible="showDlg"
modal
:draggable="false"
:closable="!saving"
:dismissableMask="!saving"
:style="{ width: '680px' }"
>
<template #header>
<div class="flex flex-col gap-1">
<div class="text-lg 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 />
</div>
<!-- Dialog: editar limites de plan_features -->
<Dialog
v-model:visible="showDlg"
modal
:draggable="false"
:closable="!saving"
:dismissableMask="!saving"
:style="{ width: '680px' }"
>
<template #header>
<div class="flex flex-col gap-1">
<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 />
</div>
</template>
</div>
</template>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-4">
<!-- Campos existentes -->
<div v-if="limitFields.length">
<div class="font-semibold mb-2">Limites configurados</div>
<div class="flex flex-col gap-2">
<div
v-for="(field, idx) in limitFields"
:key="idx"
class="flex items-center gap-3 surface-100 border-round 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>
<!-- Valor -->
<div class="w-40 shrink-0">
<template v-if="field.type === 'number'">
<InputNumber
v-model="field.value"
class="w-full"
inputClass="w-full"
:disabled="saving"
:min="-1"
placeholder="-1 = ilimitado"
/>
</template>
<template v-else-if="field.type === 'boolean'">
<SelectButton
v-model="field.value"
:options="[{ label: 'Sim', value: true }, { label: 'Não', value: false }]"
optionLabel="label"
optionValue="value"
:disabled="saving"
/>
</template>
<template v-else>
<InputText v-model="field.value" class="w-full" :disabled="saving" />
</template>
</div>
<!-- Ações rápidas -->
<div class="flex gap-1 shrink-0">
<Button
icon="pi pi-infinity"
size="small"
severity="secondary"
outlined
v-tooltip.top="'Definir como ilimitado (-1)'"
:disabled="saving || field.type !== 'number'"
@click="setUnlimited(idx)"
/>
<Button
icon="pi pi-trash"
size="small"
severity="danger"
outlined
v-tooltip.top="'Remover este campo'"
:disabled="saving"
@click="removeLimitField(idx)"
/>
</div>
</div>
</div>
</div>
<div v-else class="text-sm text-color-secondary surface-100 border-round p-3 text-center">
Nenhum limite configurado. Adicione abaixo.
</div>
<Divider />
<!-- Adicionar novo campo -->
<div>
<div class="font-semibold mb-3">Adicionar campo de limite</div>
<div class="flex flex-col gap-3">
<!-- Nome -->
<div>
<label class="text-xs font-semibold text-color-secondary uppercase tracking-wide block mb-1">
Nome do campo *
</label>
<InputText
id="new-limit-key"
v-model="newLimitKey"
class="w-full"
variant="filled"
:disabled="saving"
autocomplete="off"
placeholder="ex: max_patients"
@keydown.enter.prevent="addLimitField"
/>
<small class="text-color-secondary mt-1 block">
Ex: <span class="font-mono">max_patients</span>, <span class="font-mono">max_sessions_per_month</span>
</small>
<!-- Campos existentes -->
<div v-if="limitFields.length">
<div class="font-semibold mb-2">Limites configurados</div>
<div class="flex flex-col gap-2">
<div
v-for="(field, idx) in limitFields"
:key="idx"
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-[1rem]">{{ field.key }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)]">{{ field.type }}</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>
<SelectButton
v-model="newLimitType"
:options="limitTypeOptions"
optionLabel="label"
optionValue="value"
:disabled="saving"
/>
</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>
<!-- Valor -->
<div class="w-40 shrink-0">
<template v-if="field.type === 'number'">
<InputNumber
v-if="newLimitType === 'number'"
v-model="newLimitValue"
v-model="field.value"
class="w-full"
inputClass="w-full"
variant="filled"
:disabled="saving"
:min="-1"
placeholder="-1 = ilimitado"
/>
</template>
<template v-else-if="field.type === 'boolean'">
<SelectButton
v-else-if="newLimitType === 'boolean'"
v-model="newLimitValue"
v-model="field.value"
:options="[{ label: 'Sim', value: true }, { label: 'Não', value: false }]"
optionLabel="label"
optionValue="value"
:disabled="saving"
/>
<InputText
v-else
v-model="newLimitValue"
class="w-full"
variant="filled"
:disabled="saving"
/>
</div>
</template>
<template v-else>
<InputText v-model="field.value" class="w-full" :disabled="saving" />
</template>
</div>
<!-- Ações rápidas -->
<div class="flex gap-1 shrink-0">
<Button
icon="pi pi-plus"
label="Adicionar"
:disabled="saving || !newLimitKey?.trim()"
@click="addLimitField"
icon="pi pi-infinity"
size="small"
severity="secondary"
outlined
v-tooltip.top="'Definir como ilimitado (-1)'"
:disabled="saving || field.type !== 'number'"
@click="setUnlimited(idx)"
/>
<Button
icon="pi pi-trash"
size="small"
severity="danger"
outlined
v-tooltip.top="'Remover este campo'"
:disabled="saving"
@click="removeLimitField(idx)"
/>
</div>
</div>
</div>
</div>
<!-- Dica de boas práticas -->
<div class="surface-100 border-round p-3 text-xs 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>
<div><span class="font-mono">max_sessions_per_month</span> sessões/mês</div>
<div><span class="font-mono">max_members</span> membros da clínica</div>
<div><span class="font-mono">max_therapists</span> terapeutas vinculados</div>
<div><span class="font-mono">-1</span> sem limite (planos PRO)</div>
<div><span class="font-mono">0</span> bloqueado completamente</div>
<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>
<Divider />
<!-- Adicionar novo campo -->
<div>
<div class="font-semibold mb-3">Adicionar campo de limite</div>
<div class="flex flex-col gap-3">
<!-- Nome -->
<div>
<label class="text-[1rem] font-semibold text-[var(--text-color-secondary)] uppercase tracking-wide block mb-1">
Nome do campo *
</label>
<InputText
id="new-limit-key"
v-model="newLimitKey"
class="w-full"
variant="filled"
:disabled="saving"
autocomplete="off"
placeholder="ex: max_patients"
@keydown.enter.prevent="addLimitField"
/>
<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>
</div>
</div>
<!-- Tipo + Valor + Botão -->
<div class="flex items-end gap-2 flex-wrap">
<div>
<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"
optionLabel="label"
optionValue="value"
:disabled="saving"
/>
</div>
<div class="flex-1" style="min-width: 8rem;">
<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"
class="w-full"
inputClass="w-full"
variant="filled"
:disabled="saving"
:min="-1"
placeholder="-1 = ilimitado"
/>
<SelectButton
v-else-if="newLimitType === 'boolean'"
v-model="newLimitValue"
:options="[{ label: 'Sim', value: true }, { label: 'Não', value: false }]"
optionLabel="label"
optionValue="value"
:disabled="saving"
/>
<InputText
v-else
v-model="newLimitValue"
class="w-full"
variant="filled"
:disabled="saving"
/>
</div>
<Button
icon="pi pi-plus"
label="Adicionar"
:disabled="saving || !newLimitKey?.trim()"
@click="addLimitField"
/>
</div>
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined :disabled="saving" @click="showDlg = false" />
<Button label="Salvar limites" icon="pi pi-check" :loading="saving" @click="saveLimits" />
</template>
</Dialog>
</div>
</template>
<!-- Dica de boas práticas -->
<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>
<div><span class="font-mono">max_sessions_per_month</span> sessões/mês</div>
<div><span class="font-mono">max_members</span> membros da clínica</div>
<div><span class="font-mono">max_therapists</span> terapeutas vinculados</div>
<div><span class="font-mono">-1</span> sem limite (planos PRO)</div>
<div><span class="font-mono">0</span> bloqueado completamente</div>
</div>
</div>
</div>
<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>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined :disabled="saving" @click="showDlg = false" />
<Button label="Salvar limites" icon="pi pi-check" :loading="saving" @click="saveLimits" />
</template>
</Dialog>
</template>

View File

@@ -432,321 +432,231 @@ 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 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>
<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>
<!-- Ações desktop ( 1200px) -->
<div class=hidden xl:flex items-center gap-2 flex-wrap>
<SelectButton
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 />
</div>
<!-- Ações desktop ( 1200px) -->
<div class="plans-hero__actions plans-hero__actions--desktop">
<SelectButton
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" />
</div>
<!-- Ações mobile (< 1200px) -->
<div class="plans-hero__actions--mobile">
<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)"
/>
<Menu ref="heroMenuRef" id="plans_hero_menu" :model="heroMenuItems" :popup="true" />
</div>
<!-- Ações mobile (< 1200px) -->
<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)
/>
<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>
</Column>
</DataTable>
</div>
</div>
<Dialog
v-model:visible="showDlg"
modal
:draggable="false"
:header="isEdit ? 'Editar plano' : 'Novo plano'"
:style="{ width: '620px' }"
class="plans-dialog"
>
<div class="flex flex-col gap-4">
<div>
<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"
/>
<small class="text-color-secondary">
Planos existentes não mudam de público. Isso evita inconsistência no catálogo.
</small>
</div>
<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)"
/>
</IconField>
<label for="plan_key">Key</label>
</FloatLabel>
<small class="text-color-secondary -mt-3">
Key é técnica e estável (slug). Planos padrão do sistema têm a key protegida.
</small>
<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"
/>
</IconField>
<label for="plan_name">Nome</label>
</FloatLabel>
<small class="text-color-secondary -mt-3">
Nome interno para administração. (Nome público vem de <b>plan_public</b>.)
</small>
<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" />
<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"
/>
</IconField>
<label for="price_monthly">Preço mensal (R$)</label>
</FloatLabel>
<small class="text-color-secondary">Deixe vazio para sem preço definido.</small>
</div>
<div>
<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"
/>
</IconField>
<label for="price_yearly">Preço anual (R$)</label>
</FloatLabel>
<small class="text-color-secondary">Deixe vazio para sem preço definido.</small>
</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" />
<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"
/>
</IconField>
<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>
<Dialog
v-model:visible=showDlg
modal
:draggable=false
:header=isEdit ? 'Editar plano' : 'Novo plano'
:style={ width: '620px' }
>
<div class=flex flex-col gap-4>
<div>
<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
/>
<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.
</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" />
</template>
</Dialog>
</div>
</template>
<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)
/>
</IconField>
<label for=plan_key>Key</label>
</FloatLabel>
<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.
</div>
<style scoped>
/* ─── Root ──────────────────────────────────────────────── */
.plans-root { padding: 1rem; }
@media (min-width: 768px) { .plans-root { padding: 1.5rem; } }
<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
/>
</IconField>
<label for=plan_name>Nome</label>
</FloatLabel>
<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>.)
</div>
/* ─── Hero ──────────────────────────────────────────────── */
.plans-hero-sentinel { height: 1px; }
<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 />
<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
/>
</IconField>
<label for=price_monthly>Preço mensal (R$)</label>
</FloatLabel>
<div class=text-[1rem] text-[var(--text-color-secondary)] mt-1>Deixe vazio para sem preço definido.</div>
</div>
.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); }
<div>
<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
/>
</IconField>
<label for=price_yearly>Preço anual (R$)</label>
</FloatLabel>
<div class=text-[1rem] text-[var(--text-color-secondary)] mt-1>Deixe vazio para sem preço definido.</div>
</div>
</div>
.plans-hero__inner {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1.25rem; flex-wrap: wrap;
}
<!-- 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 />
<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
/>
</IconField>
<label for=max_supervisees>Limite de supervisionados</label>
</FloatLabel>
<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>
/* Í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>
<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 />
</template>
</Dialog>
</template>

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 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>
<div class="showcase-hero__inner">
<div class="showcase-hero__info min-w-0">
<div class="showcase-hero__title">Vitrine de Planos</div>
</div>
<!-- Ações desktop ( 1200px) -->
<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 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>
<!-- Ações mobile (< 1200px) -->
<div class="showcase-hero__actions--mobile">
<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)"
/>
<Menu ref="heroMenuRef" id="showcase_hero_menu" :model="heroMenuItems" :popup="true" />
</div>
<!-- Ações mobile (< 1200px) -->
<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)
/>
<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,407 +599,338 @@ 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">
<!-- 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">
<Tag
:value="targetFilter === 'all' ? 'Vitrine (Todos)' : `Vitrine (${targetLabel(targetFilter)})`"
:severity="targetFilter === 'therapist' ? 'success' : (targetFilter === 'clinic' ? 'info' : 'secondary')"
rounded
/>
<span class="text-sm text-color-secondary">
Ajuste nomes, descrições, badges e benefícios e veja o resultado aqui.
</span>
<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>
<Tag
:value=targetFilter === 'all' ? 'Vitrine (Todos)' : `Vitrine (${targetLabel(targetFilter)})`
:severity=targetFilter === 'therapist' ? 'success' : (targetFilter === 'clinic' ? 'info' : 'secondary')
rounded
/>
<div class=text-[1rem] text-[var(--text-color-secondary)]>
Ajuste nomes, descrições, badges e benefícios e veja o resultado aqui.
</div>
<h2 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>
<SelectButton
v-model="billingInterval"
:options="intervalOptions"
optionLabel="label"
optionValue="value"
<div class=text-3xl md:text-5xl font-semibold leading-tight>
Um plano não é preço.<br />
É promessa organizada.
</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
/>
</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
/>
</div>
</div>
</div>
</div>
</div>
<!-- Cards -->
<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-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=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'
rounded
/>
</div>
<div class="flex flex-col gap-2">
<div class="text-sm text-color-secondary">Planos sem preço</div>
<SelectButton
v-model="previewPricePolicy"
:options="previewPolicyOptions"
optionLabel="label"
optionValue="value"
/>
</div>
<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>
{{ priceDisplayForPreview(p).main }}
</div>
<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>
{{ priceDisplayForPreview(p).main }}
</div>
<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>
{{ priceDisplayForPreview(p).main }}
</div>
<div class=text-[1rem] text-[var(--text-color-secondary)] mt-1>
Fale com a equipe para montar o plano ideal.
</div>
</template>
</div>
<div class=text-[var(--text-color-secondary)] mt-3 min-h-[44px]>
{{ p.public_description || '—' }}
</div>
<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-[1rem] text-[var(--text-color-secondary)]>
Nenhum benefício configurado.
</div>
</div>
</div>
</div>
<!-- Cards -->
<div class="p-6 md:p-10 pt-0">
<div v-if="!previewPlans.length" class="text-sm 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-for="p in previewPlans"
:key="p.plan_id"
:class="[
'relative rounded-2xl 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="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'"
rounded
/>
</div>
<span class="text-xs text-color-secondary">{{ p.plan_key }}</span>
</div>
<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">
{{ priceDisplayForPreview(p).sub }}
</div>
</template>
<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">
{{ billingInterval === 'year' ? 'no anual' : 'no mensal' }}
</div>
</template>
<template v-else>
<div class="text-2xl font-semibold leading-none">
{{ priceDisplayForPreview(p).main }}
</div>
<div class="text-sm 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]">
{{ 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' : '']">
{{ b.text }}
</span>
</li>
</ul>
<div v-else class="mt-4 text-sm text-color-secondary">
Nenhum benefício configurado.
</div>
</div>
</div>
</div>
<div v-if="previewPricePolicy === 'hide'" class="mt-6 text-xs 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 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><!-- /px-4 pb-4 -->
</div><!-- /content -->
<!-- Dialog principal ( sem drag: removemos draggable) -->
<Dialog
v-model:visible="showDlg"
modal
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">
<!-- Nome público (FloatLabel + Icon) -->
<FloatLabel variant="on">
<!-- Dialog principal ( sem drag: removemos draggable) -->
<Dialog
v-model:visible=showDlg
modal
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>
<!-- Nome público (FloatLabel + Icon) -->
<FloatLabel variant=on>
<IconField>
<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
autofocus
@keydown.enter.prevent=save
/>
</IconField>
<label for=pp-public-name>Nome público *</label>
</FloatLabel>
<!-- Descrição pública -->
<FloatLabel variant=on>
<IconField>
<InputIcon class=pi pi-align-left />
<Textarea
id=pp-public-desc
v-model.trim=form.public_description
class=w-full
rows=3
autoResize
:disabled=saving
/>
</IconField>
<label for=pp-public-desc>Descrição pública</label>
</FloatLabel>
<!-- Badge -->
<FloatLabel variant=on>
<IconField>
<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
/>
</IconField>
<label for=pp-badge>Badge (opcional)</label>
</FloatLabel>
<div class=grid grid-cols-1 md:grid-cols-2 gap-4>
<!-- Ordem -->
<FloatLabel variant=on>
<IconField>
<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"
autofocus
@keydown.enter.prevent="save"
<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
/>
</IconField>
<label for="pp-public-name">Nome público *</label>
<label for=pp-sort>Ordem</label>
</FloatLabel>
<!-- Descrição pública -->
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-align-left" />
<Textarea
id="pp-public-desc"
v-model.trim="form.public_description"
class="w-full"
rows="3"
autoResize
:disabled="saving"
/>
</IconField>
<label for="pp-public-desc">Descrição pública</label>
</FloatLabel>
<!-- Badge -->
<FloatLabel variant="on">
<IconField>
<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"
/>
</IconField>
<label for="pp-badge">Badge (opcional)</label>
</FloatLabel>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Ordem -->
<FloatLabel variant="on">
<IconField>
<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"
/>
</IconField>
<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" />
<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" />
<label>Destaque</label>
</div>
<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 />
<label>Destaque</label>
</div>
</div>
</div>
<!-- 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>
<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)" />
</div>
</template>
</Column>
</DataTable>
</div>
</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" />
</template>
</Dialog>
<!-- 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>
<!-- Dialog bullet ( sem drag + inputs padronizados) -->
<Dialog
v-model:visible="showBulletDlg"
modal
: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">
<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) />
</div>
</template>
</Column>
</DataTable>
</div>
</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 />
</template>
</Dialog>
<!-- Dialog bullet ( sem drag + inputs padronizados) -->
<Dialog
v-model:visible=showBulletDlg
modal
: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>
<IconField>
<InputIcon class=pi pi-list />
<Textarea
id=pp-bullet-text
v-model.trim=bulletForm.text
class=w-full
rows=3
autoResize
:disabled=bulletSaving
/>
</IconField>
<label for=pp-bullet-text>Texto *</label>
</FloatLabel>
<div class=grid grid-cols-1 md:grid-cols-2 gap-4>
<FloatLabel variant=on>
<IconField>
<InputIcon class="pi pi-list" />
<Textarea
id="pp-bullet-text"
v-model.trim="bulletForm.text"
class="w-full"
rows="3"
autoResize
:disabled="bulletSaving"
<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
/>
</IconField>
<label for="pp-bullet-text">Texto *</label>
<label for=pp-bullet-order>Ordem</label>
</FloatLabel>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<FloatLabel variant="on">
<IconField>
<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"
/>
</IconField>
<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" />
<label>Destaque</label>
</div>
<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" />
</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>
<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 />
</template>
</Dialog>
</template>

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 />
<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" />
</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>
<!-- 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>
<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,120 +640,116 @@ 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="flex items-center gap-2">
<i class="pi pi-filter text-color-secondary" />
<span>Busca & Filtros</span>
</div>
<!-- contagens -->
<div class="flex flex-wrap gap-2 items-center">
<Tag :value="`Total: ${totals.total}`" severity="secondary" rounded />
<Tag :value="`Novas: ${totals.new}`" severity="info" rounded />
<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">
<template v-if="lastRefreshAt">· {{ fmtDate(lastRefreshAt) }}</template>
</span>
<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-[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">
<Tag :value="`Total: ${totals.total}`" severity="secondary" rounded />
<Tag :value="`Novas: ${totals.new}`" severity="info" rounded />
<Tag :value="`Aguardando: ${totals.waiting}`" severity="warning" rounded />
<Tag :value="`Pagas: ${totals.paid}`" severity="success" rounded />
<Tag :value="`Canceladas: ${totals.canceled}`" severity="danger" rounded />
<div class="text-[1rem] text-[var(--text-color-secondary)]">
<template v-if="lastRefreshAt">· {{ fmtDate(lastRefreshAt) }}</template>
</div>
</div>
</template>
</div>
<template #content>
<div class="grid grid-cols-12 gap-3">
<!-- Busca -->
<div class="col-span-12 md:col-span-5">
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText
v-model="q"
id="si-search"
class="w-full pr-10"
variant="filled"
:disabled="acting"
placeholder="ex.: email@dominio.com"
@keyup.enter="refresh"
/>
</IconField>
<label for="si-search">Buscar por e-mail / plano / tenant_id</label>
</FloatLabel>
</div>
<!-- Status -->
<div class="col-span-12 md:col-span-3">
<FloatLabel variant="on" class="w-full">
<Dropdown
v-model="status"
:options="statusOptions"
optionLabel="label"
optionValue="value"
class="w-full"
showClear
<div class="grid grid-cols-12 gap-3">
<!-- Busca -->
<div class="col-span-12 md:col-span-5">
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText
v-model="q"
id="si-search"
class="w-full pr-10"
variant="filled"
:disabled="acting"
placeholder="ex.: email@dominio.com"
@keyup.enter="refresh"
/>
<label>Status</label>
</FloatLabel>
</div>
<!-- Intervalo -->
<div class="col-span-12 md:col-span-2">
<FloatLabel variant="on" class="w-full">
<Dropdown
v-model="interval"
:options="intervalOptions"
optionLabel="label"
optionValue="value"
class="w-full"
showClear
:disabled="acting"
/>
<label>Intervalo</label>
</FloatLabel>
</div>
<!-- Plano -->
<div class="col-span-12 md:col-span-2">
<FloatLabel variant="on" class="w-full">
<Dropdown
v-model="planKey"
:options="planOptions"
optionLabel="label"
optionValue="value"
class="w-full"
showClear
:disabled="acting"
/>
<label>Plano</label>
</FloatLabel>
</div>
<!-- Botões -->
<div class="col-span-12 flex gap-2 flex-wrap">
<Button
label="Aplicar"
icon="pi pi-filter"
@click="refresh"
:disabled="acting"
/>
<Button
v-if="hasAnyFilter"
label="Limpar filtros"
icon="pi pi-times"
severity="secondary"
outlined
@click="clearFilters"
:disabled="acting"
/>
</div>
</IconField>
<label for="si-search">Buscar por e-mail / plano / tenant_id</label>
</FloatLabel>
</div>
</template>
</Card>
<!-- Status -->
<div class="col-span-12 md:col-span-3">
<FloatLabel variant="on" class="w-full">
<Dropdown
v-model="status"
:options="statusOptions"
optionLabel="label"
optionValue="value"
class="w-full"
showClear
:disabled="acting"
/>
<label>Status</label>
</FloatLabel>
</div>
<!-- Intervalo -->
<div class="col-span-12 md:col-span-2">
<FloatLabel variant="on" class="w-full">
<Dropdown
v-model="interval"
:options="intervalOptions"
optionLabel="label"
optionValue="value"
class="w-full"
showClear
:disabled="acting"
/>
<label>Intervalo</label>
</FloatLabel>
</div>
<!-- Plano -->
<div class="col-span-12 md:col-span-2">
<FloatLabel variant="on" class="w-full">
<Dropdown
v-model="planKey"
:options="planOptions"
optionLabel="label"
optionValue="value"
class="w-full"
showClear
:disabled="acting"
/>
<label>Plano</label>
</FloatLabel>
</div>
<!-- Botões -->
<div class="col-span-12 flex gap-2 flex-wrap">
<Button
label="Aplicar"
icon="pi pi-filter"
@click="refresh"
:disabled="acting"
/>
<Button
v-if="hasAnyFilter"
label="Limpar filtros"
icon="pi pi-times"
severity="secondary"
outlined
@click="clearFilters"
:disabled="acting"
/>
</div>
</div>
</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,11 +7,10 @@ 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' },
{ label: 'Esta semana', value: 'week' },
{ label: 'Este mês', value: 'month' },
{ label: 'Últimos 3 meses', value: '3months' },
{ label: 'Últimos 6 meses', value: '6months' },
]
@@ -21,44 +20,36 @@ 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)
end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999)
} else if (period === '3months') {
start = new Date(now.getFullYear(), now.getMonth() - 2, 1, 0, 0, 0, 0)
end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999)
end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999)
} else if (period === '6months') {
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)
end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999)
}
return { start, end }
}
// ─── dados ───────────────────────────────────────────────────────────────────
const loading = ref(false)
const sessions = ref([])
// ── Dados ─────────────────────────────────────────────────
const loading = ref(false)
const sessions = ref([])
const loadError = ref('')
async function loadSessions () {
const uid = tenantStore.user?.id || null
const uid = tenantStore.user?.id || null
const tenantId = tenantStore.activeTenantId || null
if (!uid || !tenantId) return
const { start, end } = periodRange(selectedPeriod.value)
loading.value = true
loading.value = true
loadError.value = ''
sessions.value = []
sessions.value = []
try {
const { data, error } = await supabase
@@ -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,133 +70,108 @@ 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)
})
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)
// ── Filtro de status na tabela ────────────────────────────
const filtroTabela = ref(null) // null = todos
// ─── gráfico (sessions por semana/mês) ───────────────────────────────────────
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 dt = new Date(d)
const day = dt.getDay() || 7
dt.setDate(dt.getDate() + 4 - day)
const yearStart = new Date(dt.getFullYear(), 0, 1)
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']
const names = ['Jan','Fev','Mar','Abr','Mai','Jun','Jul','Ago','Set','Out','Nov','Dez']
return `${names[Number(m) - 1]}/${y}`
}
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)
if (!buckets[key]) buckets[key] = { realizado: 0, faltou: 0, cancelado: 0, outros: 0 }
const st = s.status || 'agendado'
if (st === 'realizado') buckets[key].realizado++
else if (st === 'faltou') buckets[key].faltou++
else if (st === 'cancelado') buckets[key].cancelado++
else buckets[key].outros++
if (st === 'realizado') buckets[key].realizado++
else if (st === 'faltou') buckets[key].faltou++
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 },
]
}
})
const chartOptions = computed(() => {
const ds = getComputedStyle(document.documentElement)
const borderColor = ds.getPropertyValue('--surface-border') || '#e2e8f0'
const ds = getComputedStyle(document.documentElement)
const borderColor = ds.getPropertyValue('--surface-border') || '#e2e8f0'
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>
<SelectButton
v-model="selectedPeriod"
:options="PERIODS"
option-label="label"
option-value="value"
:allow-empty="false"
class="shrink-0"
/>
<!-- 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"
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>
<!-- 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>
<!-- 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' }}
</span>
</div>
<span class="text-[0.72rem] text-[var(--text-color-secondary)] opacity-60">{{ total }} sessão{{ total !== 1 ? 'ões' : '' }}</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 class="p-4">
<Chart type="bar" :data="chartData" :options="chartOptions" class="h-64" />
</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}%` : '—' }}
</div>
<!-- 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>
</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">
Sessões por {{ selectedPeriod === 'week' ? 'semana' : 'mês' }}
</h2>
<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>
<!-- 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>
<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.
<!-- 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