Layout 100%, Notificações, SetupWizard
This commit is contained in:
@@ -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
21933
DBS/2026-03-17/schema.sql
Normal file
File diff suppressed because it is too large
Load Diff
15
package-lock.json
generated
15
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
249
src/components/notifications/NotificationDrawer.vue
Normal file
249
src/components/notifications/NotificationDrawer.vue
Normal 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>
|
||||
219
src/components/notifications/NotificationItem.vue
Normal file
219
src/components/notifications/NotificationItem.vue
Normal 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>
|
||||
23
src/composables/useNotifications.js
Normal file
23
src/composables/useNotifications.js
Normal 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
|
||||
}
|
||||
90
src/docs/doc_agenda_terapeuta.json
Normal file
90
src/docs/doc_agenda_terapeuta.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
90
src/docs/doc_therapist_dashboard.json
Normal file
90
src/docs/doc_therapist_dashboard.json
Normal file
File diff suppressed because one or more lines are too long
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
|
||||
}
|
||||
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 — só 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 — só 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>
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''')
|
||||
}
|
||||
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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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 — já 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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
Já 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" /> Já 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>
|
||||
@@ -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>
|
||||
Há 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
35
src/main.js
35
src/main.js
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
6
src/plugins/pinia.js
Normal 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()
|
||||
@@ -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;
|
||||
@@ -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')
|
||||
}
|
||||
]
|
||||
}
|
||||
182
src/sql-arquivos/notifications.sql
Normal file
182
src/sql-arquivos/notifications.sql
Normal 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;
|
||||
37
src/sql-arquivos/notifications_fix_intake_trigger.sql
Normal file
37
src/sql-arquivos/notifications_fix_intake_trigger.sql
Normal 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;
|
||||
$$;
|
||||
37
src/sql-arquivos/notifications_fix_scheduling_trigger.sql
Normal file
37
src/sql-arquivos/notifications_fix_scheduling_trigger.sql
Normal 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;
|
||||
$$;
|
||||
29
src/sql-arquivos/seed_login_carousel_slides.sql
Normal file
29
src/sql-arquivos/seed_login_carousel_slides.sql
Normal 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
|
||||
);
|
||||
123
src/stores/notificationStore.js
Normal file
123
src/stores/notificationStore.js
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
20
src/support/supportGuard.js
Normal file
20
src/support/supportGuard.js
Normal 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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 há 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>
|
||||
|
||||
@@ -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 há 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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 cá.</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 cá.</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 é só 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 é só 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ê já 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ê já 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>
|
||||
@@ -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 já 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=<uuid></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=<uuid></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 já 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 só 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 só 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>
|
||||
@@ -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 só existir anual, MRR = anual/12; se só 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 só existir anual, MRR = anual/12; se só 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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
564
src/views/pages/saas/SaasLoginCarousel.vue
Normal file
564
src/views/pages/saas/SaasLoginCarousel.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 já 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: só 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 já 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: só 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user