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\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(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 /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",
|
"@primeuix/themes": "^2.0.0",
|
||||||
"@supabase/supabase-js": "^2.95.3",
|
"@supabase/supabase-js": "^2.95.3",
|
||||||
"chart.js": "3.3.2",
|
"chart.js": "3.3.2",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
"primevue": "^4.5.4",
|
"primevue": "^4.5.4",
|
||||||
@@ -2537,6 +2538,15 @@
|
|||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
|
"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": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
|
"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": {
|
"debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"@primeuix/themes": "^2.0.0",
|
"@primeuix/themes": "^2.0.0",
|
||||||
"@supabase/supabase-js": "^2.95.3",
|
"@supabase/supabase-js": "^2.95.3",
|
||||||
"chart.js": "3.3.2",
|
"chart.js": "3.3.2",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
"primevue": "^4.5.4",
|
"primevue": "^4.5.4",
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
<!-- Painel de ajuda lateral — home com sessão/docs/faq + navegação interna + votação -->
|
<!-- Painel de ajuda lateral — home com sessão/docs/faq + navegação interna + votação -->
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useAjuda } from '@/composables/useAjuda'
|
import { useAjuda } from '@/composables/useAjuda'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
sessionDocs, sessionFaq,
|
sessionDocs, sessionFaq,
|
||||||
@@ -64,6 +65,50 @@ function fechar () {
|
|||||||
faqAbertos.value = {}
|
faqAbertos.value = {}
|
||||||
closeDrawer()
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -106,7 +151,7 @@ function fechar () {
|
|||||||
<div class="doc-view">
|
<div class="doc-view">
|
||||||
|
|
||||||
<!-- Conteúdo -->
|
<!-- 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 -->
|
<!-- Mídias -->
|
||||||
<template v-if="docAtual?.medias?.length">
|
<template v-if="docAtual?.medias?.length">
|
||||||
@@ -135,7 +180,7 @@ function fechar () {
|
|||||||
</button>
|
</button>
|
||||||
<Transition name="expand">
|
<Transition name="expand">
|
||||||
<div v-if="faqAbertos[item.id] && item.resposta"
|
<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>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -298,7 +343,7 @@ function fechar () {
|
|||||||
</button>
|
</button>
|
||||||
<Transition name="expand">
|
<Transition name="expand">
|
||||||
<div v-if="faqAbertos[item.id] && item.resposta"
|
<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>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -326,7 +371,7 @@ function fechar () {
|
|||||||
</button>
|
</button>
|
||||||
<Transition name="expand">
|
<Transition name="expand">
|
||||||
<div v-if="faqAbertos[item.id] && item.resposta"
|
<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>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -705,6 +750,34 @@ function fechar () {
|
|||||||
font-weight: 400;
|
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 transition ───────────────────────────────────────── */
|
||||||
.expand-enter-active,
|
.expand-enter-active,
|
||||||
.expand-leave-active {
|
.expand-leave-active {
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
<Dialog
|
<Dialog
|
||||||
v-model:visible="isOpen"
|
v-model:visible="isOpen"
|
||||||
modal
|
modal
|
||||||
|
:draggable="false"
|
||||||
:closable="!saving"
|
:closable="!saving"
|
||||||
:dismissableMask="!saving"
|
:dismissableMask="!saving"
|
||||||
:style="{ width: '34rem', maxWidth: '92vw' }"
|
:style="{ width: '34rem', maxWidth: '92vw' }"
|
||||||
|
pt:mask:class="backdrop-blur-xs"
|
||||||
@hide="onHide"
|
@hide="onHide"
|
||||||
>
|
>
|
||||||
<template #header>
|
<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' }"
|
:style="{ width: '1000px', maxWidth: '96vw' }"
|
||||||
:breakpoints="{ '960px': '96vw', '640px': '98vw' }"
|
:breakpoints="{ '960px': '96vw', '640px': '98vw' }"
|
||||||
class="agenda-event-composer"
|
class="agenda-event-composer"
|
||||||
|
pt:mask:class="backdrop-blur-xs"
|
||||||
>
|
>
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="w-full flex items-center justify-between gap-3">
|
<div class="w-full flex items-center justify-between gap-3">
|
||||||
@@ -24,47 +25,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 shrink-0">
|
<!-- actions moved to footer -->
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -924,7 +885,7 @@
|
|||||||
:style="{ width: '560px', maxWidth: '96vw' }"
|
:style="{ width: '560px', maxWidth: '96vw' }"
|
||||||
:breakpoints="{ '640px': '98vw' }"
|
:breakpoints="{ '640px': '98vw' }"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-4 p-1">
|
<div class="flex flex-col gap-4">
|
||||||
<!-- Data -->
|
<!-- Data -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-2">Data</label>
|
<label class="block text-sm font-medium mb-2">Data</label>
|
||||||
@@ -1060,6 +1021,53 @@
|
|||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</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 -->
|
<!-- Cadastro Rápido de Paciente -->
|
||||||
<!-- ══════════════════════════════════════════════ -->
|
<!-- ══════════════════════════════════════════════ -->
|
||||||
@@ -2662,7 +2670,7 @@ function statusSeverity (v) {
|
|||||||
.commitment-card {
|
.commitment-card {
|
||||||
width: 100%; text-align: left;
|
width: 100%; text-align: left;
|
||||||
border: 1px solid var(--surface-border);
|
border: 1px solid var(--surface-border);
|
||||||
border-radius: 1.25rem;
|
border-radius: 6px;
|
||||||
background: color-mix(in srgb, var(--surface-card), transparent 10%);
|
background: color-mix(in srgb, var(--surface-card), transparent 10%);
|
||||||
transition: box-shadow .12s ease, transform .12s ease, border-color .12s;
|
transition: box-shadow .12s ease, transform .12s ease, border-color .12s;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -2704,7 +2712,7 @@ function statusSeverity (v) {
|
|||||||
/* ── paciente hero ──────────────────────────────── */
|
/* ── paciente hero ──────────────────────────────── */
|
||||||
.patient-hero {
|
.patient-hero {
|
||||||
border: 1.5px solid var(--surface-border);
|
border: 1.5px solid var(--surface-border);
|
||||||
border-radius: 1.25rem;
|
border-radius: 6px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: color-mix(in srgb, var(--surface-card), transparent 10%);
|
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.) */
|
/* Card genérico para seções (data/horário, etc.) */
|
||||||
.field-card {
|
.field-card {
|
||||||
border-radius: 1rem;
|
border-radius: 6px;
|
||||||
border: 1px solid var(--surface-border);
|
border: 1px solid var(--surface-border);
|
||||||
background: var(--surface-card);
|
background: var(--surface-card);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -2848,7 +2856,7 @@ function statusSeverity (v) {
|
|||||||
}
|
}
|
||||||
.side-card {
|
.side-card {
|
||||||
border: 1px solid var(--surface-border);
|
border: 1px solid var(--surface-border);
|
||||||
border-radius: 1.25rem;
|
border-radius: 6px;
|
||||||
padding: .9rem 1rem;
|
padding: .9rem 1rem;
|
||||||
background: color-mix(in srgb, var(--surface-card), transparent 10%);
|
background: color-mix(in srgb, var(--surface-card), transparent 10%);
|
||||||
}
|
}
|
||||||
@@ -2879,7 +2887,7 @@ function statusSeverity (v) {
|
|||||||
|
|
||||||
/* ── serie banner ───────────────────────────────── */
|
/* ── serie banner ───────────────────────────────── */
|
||||||
.serie-banner {
|
.serie-banner {
|
||||||
border-radius: 1rem;
|
border-radius: 6px;
|
||||||
padding: .75rem .9rem;
|
padding: .75rem .9rem;
|
||||||
background: color-mix(in srgb, var(--blue-500, #3b82f6) 8%, var(--surface-card));
|
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);
|
border: 1px solid color-mix(in srgb, var(--blue-400, #60a5fa) 30%, transparent);
|
||||||
@@ -2922,7 +2930,7 @@ function statusSeverity (v) {
|
|||||||
.recorrencia-preview {
|
.recorrencia-preview {
|
||||||
display: flex; align-items: center; gap: .5rem;
|
display: flex; align-items: center; gap: .5rem;
|
||||||
padding: .5rem .75rem;
|
padding: .5rem .75rem;
|
||||||
border-radius: .75rem;
|
border-radius: 6px;
|
||||||
background: color-mix(in srgb, var(--p-primary-500) 8%, transparent);
|
background: color-mix(in srgb, var(--p-primary-500) 8%, transparent);
|
||||||
border: 1px solid color-mix(in srgb, var(--p-primary-400) 25%, transparent);
|
border: 1px solid color-mix(in srgb, var(--p-primary-400) 25%, transparent);
|
||||||
}
|
}
|
||||||
@@ -2994,7 +3002,7 @@ function statusSeverity (v) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
border-radius: 0.75rem;
|
border-radius: 6px;
|
||||||
background: color-mix(in srgb, var(--primary-500, #6366f1) 8%, var(--surface-card));
|
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);
|
border: 1px solid color-mix(in srgb, var(--primary-400, #818cf8) 25%, transparent);
|
||||||
}
|
}
|
||||||
@@ -3125,7 +3133,7 @@ function statusSeverity (v) {
|
|||||||
.rec-startdate-row {
|
.rec-startdate-row {
|
||||||
display: flex; align-items: center; justify-content: space-between; gap: .5rem;
|
display: flex; align-items: center; justify-content: space-between; gap: .5rem;
|
||||||
padding: .45rem .65rem;
|
padding: .45rem .65rem;
|
||||||
border-radius: .75rem;
|
border-radius: 6px;
|
||||||
background: color-mix(in srgb, var(--surface-ground), transparent 30%);
|
background: color-mix(in srgb, var(--surface-ground), transparent 30%);
|
||||||
border: 1px solid var(--surface-border);
|
border: 1px solid var(--surface-border);
|
||||||
}
|
}
|
||||||
@@ -3177,7 +3185,7 @@ function statusSeverity (v) {
|
|||||||
/* ── personalizar box ───────────────────────────── */
|
/* ── personalizar box ───────────────────────────── */
|
||||||
.personalizar-box {
|
.personalizar-box {
|
||||||
border: 1px solid var(--surface-border);
|
border: 1px solid var(--surface-border);
|
||||||
border-radius: .85rem;
|
border-radius: 6px;
|
||||||
padding: .75rem;
|
padding: .75rem;
|
||||||
background: color-mix(in srgb, var(--surface-ground), transparent 40%);
|
background: color-mix(in srgb, var(--surface-ground), transparent 40%);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -3190,7 +3198,7 @@ function statusSeverity (v) {
|
|||||||
.patient-item {
|
.patient-item {
|
||||||
width: 100%; display: flex; align-items: center; justify-content: space-between;
|
width: 100%; display: flex; align-items: center; justify-content: space-between;
|
||||||
gap: 1rem; text-align: left; padding: .85rem .95rem;
|
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%);
|
background: color-mix(in srgb, var(--surface-card), transparent 10%);
|
||||||
transition: box-shadow .12s ease, transform .12s ease;
|
transition: box-shadow .12s ease, transform .12s ease;
|
||||||
}
|
}
|
||||||
@@ -3199,7 +3207,7 @@ function statusSeverity (v) {
|
|||||||
/* ── serie panel (Recorrências Aplicadas) ─────────── */
|
/* ── serie panel (Recorrências Aplicadas) ─────────── */
|
||||||
.serie-panel {
|
.serie-panel {
|
||||||
border: 1px solid var(--surface-border);
|
border: 1px solid var(--surface-border);
|
||||||
border-radius: 1.1rem;
|
border-radius: 6px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.serie-panel__header {
|
.serie-panel__header {
|
||||||
@@ -3273,7 +3281,7 @@ function statusSeverity (v) {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: .35rem;
|
gap: .35rem;
|
||||||
border: 1px solid var(--surface-border);
|
border: 1px solid var(--surface-border);
|
||||||
border-radius: .5rem;
|
border-radius: 6px;
|
||||||
padding: .5rem;
|
padding: .5rem;
|
||||||
}
|
}
|
||||||
.commitment-item-row {
|
.commitment-item-row {
|
||||||
|
|||||||
@@ -6,14 +6,15 @@
|
|||||||
:closable="!saving"
|
:closable="!saving"
|
||||||
:dismissableMask="!saving"
|
:dismissableMask="!saving"
|
||||||
class="dc-dialog w-[96vw] max-w-2xl"
|
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>
|
<template #header>
|
||||||
<div class="flex w-full items-center justify-between gap-3 px-1">
|
<div class="flex w-full items-center justify-between gap-3 px-1">
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
<div class="flex items-center gap-3 min-w-0">
|
||||||
<!-- Dot de cor -->
|
<!-- Dot de cor -->
|
||||||
<span
|
<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 }"
|
:style="{ backgroundColor: previewBgColor }"
|
||||||
/>
|
/>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
@@ -37,33 +38,17 @@
|
|||||||
v-tooltip.top="'Excluir'"
|
v-tooltip.top="'Excluir'"
|
||||||
@click="emitDelete"
|
@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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Banner de preview -->
|
<!-- Banner de preview -->
|
||||||
<div
|
<div
|
||||||
class="dc-banner"
|
class="h-[72px] flex items-center justify-center transition-colors duration-[250ms] rounded-[6px]"
|
||||||
:style="{ backgroundColor: previewBgColor }"
|
:style="{ backgroundColor: previewBgColor }"
|
||||||
>
|
>
|
||||||
<span
|
<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' }"
|
:style="{ color: form.text_color || '#ffffff' }"
|
||||||
>
|
>
|
||||||
{{ form.name || 'Nome do compromisso' }}
|
{{ form.name || 'Nome do compromisso' }}
|
||||||
@@ -71,7 +56,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Corpo -->
|
<!-- Corpo -->
|
||||||
<div class="flex flex-col gap-4 p-4">
|
<div class="flex flex-col gap-4 mt-4">
|
||||||
|
|
||||||
<!-- Nome + Ativo -->
|
<!-- Nome + Ativo -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@@ -91,68 +76,15 @@
|
|||||||
<label for="cr-nome">Nome *</label>
|
<label for="cr-nome">Nome *</label>
|
||||||
</FloatLabel>
|
</FloatLabel>
|
||||||
</div>
|
</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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Seção Cor -->
|
<!-- Descrição -->
|
||||||
<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 -->
|
|
||||||
<FloatLabel variant="on">
|
<FloatLabel variant="on">
|
||||||
<Textarea
|
<Textarea
|
||||||
id="cr-descricao"
|
id="cr-descricao"
|
||||||
@@ -166,10 +98,83 @@
|
|||||||
<label for="cr-descricao">Descrição</label>
|
<label for="cr-descricao">Descrição</label>
|
||||||
</FloatLabel>
|
</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 -->
|
<!-- 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="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
|
<Button
|
||||||
label="Adicionar campo"
|
label="Adicionar campo"
|
||||||
icon="pi pi-plus"
|
icon="pi pi-plus"
|
||||||
@@ -190,7 +195,7 @@
|
|||||||
<div
|
<div
|
||||||
v-for="(f, idx) in form.fields"
|
v-for="(f, idx) in form.fields"
|
||||||
:key="f.key"
|
: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">
|
<div class="md:col-span-6">
|
||||||
<FloatLabel variant="on">
|
<FloatLabel variant="on">
|
||||||
@@ -233,14 +238,32 @@
|
|||||||
@click="removeField(idx)"
|
@click="removeField(idx)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="md:col-span-12 text-xs opacity-40 font-mono">
|
|
||||||
key: {{ f.key }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -249,8 +272,9 @@ import { computed, reactive, watch } from 'vue'
|
|||||||
|
|
||||||
import Textarea from 'primevue/textarea'
|
import Textarea from 'primevue/textarea'
|
||||||
import Dropdown from 'primevue/dropdown'
|
import Dropdown from 'primevue/dropdown'
|
||||||
import InputSwitch from 'primevue/inputswitch'
|
|
||||||
import ColorPicker from 'primevue/colorpicker'
|
import ColorPicker from 'primevue/colorpicker'
|
||||||
|
import ToggleSwitch from 'primevue/toggleswitch'
|
||||||
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
modelValue: { type: Boolean, default: false },
|
modelValue: { type: Boolean, default: false },
|
||||||
@@ -281,6 +305,16 @@ const presetColors = [
|
|||||||
{ bg: '292524', text: '#ffffff', name: 'Escuro' },
|
{ 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) {
|
function applyPreset (p) {
|
||||||
if (props.saving) return
|
if (props.saving) return
|
||||||
form.bg_color = p.bg
|
form.bg_color = p.bg
|
||||||
@@ -350,9 +384,9 @@ function hydrate () {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const isActiveLocked = computed(() => !!form.locked) // nativo+locked → sempre ativo, nunca pode desativar
|
const isActiveLocked = computed(() => !!form.locked)
|
||||||
const isEditLocked = computed(() => false) // edição sempre permitida
|
const isEditLocked = computed(() => false)
|
||||||
const isFieldsLocked = computed(() => false) // campos sempre editáveis
|
const isFieldsLocked = computed(() => false)
|
||||||
const canDelete = computed(() => !form.native)
|
const canDelete = computed(() => !form.native)
|
||||||
|
|
||||||
const canSubmit = computed(() => {
|
const canSubmit = computed(() => {
|
||||||
@@ -408,13 +442,11 @@ function removeField (idx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncKey (field) {
|
function syncKey (field) {
|
||||||
// se o user renomear, a key acompanha (sem quebrar: simples por enquanto)
|
|
||||||
const next = makeKey(field.label)
|
const next = makeKey(field.label)
|
||||||
field.key = next
|
field.key = next
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeKey (label) {
|
function makeKey (label) {
|
||||||
|
|
||||||
const k = String(label || '')
|
const k = String(label || '')
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -425,99 +457,3 @@ function makeKey (label) {
|
|||||||
return k
|
return k
|
||||||
}
|
}
|
||||||
</script>
|
</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>
|
|
||||||
@@ -3,141 +3,102 @@
|
|||||||
<Toast />
|
<Toast />
|
||||||
<ConfirmDialog />
|
<ConfirmDialog />
|
||||||
|
|
||||||
<!-- Sentinel para detecção de sticky -->
|
<!-- Sentinel -->
|
||||||
<div ref="headerSentinelRef" class="ag-sentinel" />
|
<div ref="headerSentinelRef" class="ag-sentinel" />
|
||||||
|
|
||||||
<!-- Hero Header sticky -->
|
<!-- Topbar compacta sticky -->
|
||||||
<div ref="headerEl" class="ag-hero mx-3 md:mx-5 mb-3" :class="{ 'ag-hero--stuck': headerStuck }">
|
<div ref="headerEl" class="ag-topbar mx-3 md:mx-4 mb-3" :class="{ 'ag-topbar--stuck': headerStuck }">
|
||||||
<!-- Blobs decorativos -->
|
<div class="ag-topbar__blobs" aria-hidden="true">
|
||||||
<div class="ag-hero__blobs" aria-hidden="true">
|
<div class="ag-topbar__blob ag-topbar__blob--1" />
|
||||||
<div class="ag-hero__blob ag-hero__blob--1" />
|
<div class="ag-topbar__blob ag-topbar__blob--2" />
|
||||||
<div class="ag-hero__blob ag-hero__blob--2" />
|
|
||||||
<div class="ag-hero__blob ag-hero__blob--3" />
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="ag-topbar__inner">
|
||||||
|
|
||||||
<!-- Linha 1: brand + controles -->
|
|
||||||
<div class="ag-hero__row1">
|
|
||||||
<!-- Brand -->
|
<!-- Brand -->
|
||||||
<div class="ag-hero__brand">
|
<div class="ag-topbar__brand">
|
||||||
<div class="ag-hero__icon">
|
<div class="ag-topbar__icon"><i class="pi pi-calendar text-base" /></div>
|
||||||
<i class="pi pi-calendar text-lg" />
|
<div class="min-w-0 hidden xl:block">
|
||||||
</div>
|
<div class="ag-topbar__title">Agenda · Clínica</div>
|
||||||
<div class="min-w-0">
|
<div class="ag-topbar__sub">{{ subtitleText }}</div>
|
||||||
<div class="ag-hero__title">Agenda</div>
|
|
||||||
<div class="ag-hero__sub">{{ subtitleText }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Controles desktop (≥1200px) -->
|
<!-- Navegação -->
|
||||||
<div class="ag-hero__desktop-controls">
|
<div class="ag-topbar__nav">
|
||||||
<!-- Navegação (sempre visível) -->
|
<Button label="Hoje" severity="secondary" outlined size="small" class="rounded-full hidden lg:flex" @click="goToday" />
|
||||||
<div class="flex items-center gap-1">
|
<Button icon="pi pi-chevron-left" severity="secondary" outlined class="h-8 w-8 rounded-full" @click="goPrev" />
|
||||||
<Button label="Hoje" severity="secondary" outlined size="small" class="rounded-full" @click="goToday" />
|
<span class="ag-topbar__date-pill" @click="toggleMonthPicker">
|
||||||
<Button icon="pi pi-chevron-left" severity="secondary" outlined class="h-8 w-8 rounded-full" @click="goPrev" />
|
<i class="pi pi-calendar text-xs opacity-60" />
|
||||||
<Button icon="pi pi-chevron-right" severity="secondary" outlined class="h-8 w-8 rounded-full" @click="goNext" />
|
{{ subtitleText }}
|
||||||
<Button :label="visibleTitle" icon="pi pi-calendar" severity="secondary" outlined size="small" class="rounded-full" @click="toggleMonthPicker" />
|
</span>
|
||||||
</div>
|
<Button icon="pi pi-chevron-right" severity="secondary" outlined class="h-8 w-8 rounded-full" @click="goNext" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Busca (oculta quando colado) -->
|
<!-- Filtros (desktop) -->
|
||||||
<div v-if="!headerStuck" class="w-[260px]">
|
<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">
|
<FloatLabel variant="on">
|
||||||
<IconField>
|
<IconField>
|
||||||
<InputIcon class="pi pi-search" />
|
<InputIcon class="pi pi-search" />
|
||||||
<InputText id="agendaSearch" v-model="search" class="w-full" autocomplete="off" @keyup.enter="openSearchModal" />
|
<InputText id="agendaSearch" v-model="search" class="w-full" autocomplete="off" @keyup.enter="openSearchModal" />
|
||||||
</IconField>
|
</IconField>
|
||||||
<label for="agendaSearch">Buscar paciente...</label>
|
<label for="agendaSearch">Buscar...</label>
|
||||||
</FloatLabel>
|
</FloatLabel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Ações rápidas -->
|
<!-- Sino feriados -->
|
||||||
<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 -->
|
|
||||||
<div v-if="feriadosTodosProximos.length" class="relative">
|
<div v-if="feriadosTodosProximos.length" class="relative">
|
||||||
<Button
|
<Button icon="pi pi-bell" :severity="feriadosSemBloqueio.length ? 'danger' : 'secondary'" outlined class="h-9 w-9 rounded-full" @click="feriadosAlertaOpen = true" />
|
||||||
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>
|
<span v-if="feriadosSemBloqueio.length" class="ag-badge">{{ feriadosSemBloqueio.length }}</span>
|
||||||
</div>
|
</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 -->
|
<Button icon="pi pi-plus" class="h-9 w-9 rounded-full" title="Novo compromisso" @click="onCreateFromButton" />
|
||||||
<Divider class="ag-hero__divider my-2" />
|
|
||||||
|
|
||||||
<!-- Linha 2: filtros -->
|
<!-- Mobile -->
|
||||||
<div class="ag-hero__row2">
|
<div class="flex xl:hidden items-center gap-1">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<Button icon="pi pi-search" severity="secondary" outlined class="h-9 w-9 rounded-full" @click="searchModalOpen = true" />
|
||||||
<div class="flex items-center gap-2">
|
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => headerMenuRef.toggle(e)" />
|
||||||
<span class="text-sm opacity-60">Exibir:</span>
|
<Menu ref="headerMenuRef" :model="headerMenuItems" :popup="true" />
|
||||||
<SelectButton v-model="onlySessions" :options="onlySessionsOptions" optionLabel="label" optionValue="value" :allowEmpty="false" />
|
|
||||||
</div>
|
</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">
|
<!-- Desktop: extras -->
|
||||||
<Tag :value="`Busca: ${searchTrim}`" severity="secondary" />
|
<div class="hidden xl:flex items-center gap-1">
|
||||||
<Button label="Limpar" icon="pi pi-times" text severity="secondary" size="small" @click="clearSearch" />
|
<Button label="Bloquear" icon="pi pi-lock" size="small" class="rounded-full" severity="danger" outlined @click="(e) => blockMenuRef.toggle(e)" />
|
||||||
<Button v-if="searchResults.length" :label="`Ver resultados (${searchResults.length})`" icon="pi pi-list" severity="secondary" outlined size="small" class="rounded-full" @click="openSearchModal" />
|
<Menu ref="blockMenuRef" :model="blockMenuItems" :popup="true" />
|
||||||
</div>
|
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" @click="refetch" />
|
||||||
</div>
|
<Button icon="pi pi-sync" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Recorrências" @click="goRecorrencias" />
|
||||||
</div>
|
<Button icon="pi pi-cog" severity="secondary" outlined class="h-9 w-9 rounded-full" @click="goSettings" />
|
||||||
|
|
||||||
<!-- 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'" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Layout: 2 colunas -->
|
<!-- Aviso: fora da jornada -->
|
||||||
<div class="flex flex-col lg:flex-row gap-4 px-3 md:px-5 pb-5">
|
<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);">
|
||||||
<!-- Coluna maior -->
|
<div class="flex items-center gap-3">
|
||||||
<div class="w-full lg:flex-1 lg:order-2 min-w-0">
|
<i class="pi pi-exclamation-triangle shrink-0" style="color:var(--yellow-600,#ca8a04);" />
|
||||||
<div class="overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] shadow-sm">
|
<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">
|
<div class="p-2">
|
||||||
<AgendaClinicMosaic
|
<AgendaClinicMosaic
|
||||||
ref="calendarRef"
|
ref="calendarRef"
|
||||||
@@ -169,12 +130,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<div class="w-full lg:basis-[24%] lg:max-w-[24%] lg:order-1">
|
<div class="hidden xl:flex flex-col gap-3 w-full xl:w-[280px] shrink-0">
|
||||||
<!-- Resultados (DESKTOP) -->
|
<!-- Resultados -->
|
||||||
<div
|
<div v-if="searchTrim" class="ag-card">
|
||||||
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="mb-2 flex items-center justify-between gap-2">
|
<div class="mb-2 flex items-center justify-between gap-2">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="font-semibold truncate">Resultados</div>
|
<div class="font-semibold truncate">Resultados</div>
|
||||||
@@ -220,40 +178,38 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mini calendário -->
|
<!-- Mini calendário -->
|
||||||
<div class="mb-3 rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3 shadow-sm">
|
<div class="ag-card">
|
||||||
<div class="mb-2 flex items-center justify-between">
|
<div class="ag-card__head mb-1">
|
||||||
<span class="font-semibold">Calendário</span>
|
<span class="ag-card__title"><i class="pi pi-calendar" />{{ visibleTitle }}</span>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-0.5">
|
||||||
<Button label="Hoje" severity="secondary" text class="h-9 rounded-full" @click="miniGoToday" />
|
<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-9 w-9 rounded-full" @click="miniPrevMonth" />
|
<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-9 w-9 rounded-full" @click="miniNextMonth" />
|
<Button icon="pi pi-chevron-right" severity="secondary" text class="h-7 w-7 rounded-full" @click="miniNextMonth" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Calendar
|
<Calendar
|
||||||
v-model="miniDate"
|
v-model="miniDate"
|
||||||
inline
|
inline
|
||||||
showWeek
|
class="ag-mini-cal"
|
||||||
class="w-full"
|
|
||||||
@update:modelValue="onMiniPick"
|
@update:modelValue="onMiniPick"
|
||||||
:pt="{ day: ({ context }) => ({ class: miniDayClass(context.date) }) }"
|
:pt="{ day: ({ context }) => ({ class: miniDayClass(context.date) }) }"
|
||||||
>
|
>
|
||||||
<template #date="{ date }">
|
<template #date="{ date }">
|
||||||
<span class="mini-day-num">{{ date.day }}</span>
|
<span class="mini-day-num">{{ date.day }}</span>
|
||||||
<span v-if="hasMiniEvent(date)" class="mini-day-dot" />
|
<span v-if="hasMiniEvent(date)" class="mini-day-dot" />
|
||||||
</template>
|
</template>
|
||||||
</Calendar>
|
</Calendar>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ProximosFeriadosCard
|
<ProximosFeriadosCard
|
||||||
class="mb-3"
|
:ownerId="clinicOwnerId"
|
||||||
:ownerId="clinicOwnerId"
|
:tenantId="tenantId || ''"
|
||||||
:tenantId="tenantId || ''"
|
:workRules="workRules"
|
||||||
:workRules="workRules"
|
@bloqueado="refetch"
|
||||||
@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" />
|
<Button label="Novo Compromisso" icon="pi pi-plus" class="w-full rounded-full" @click="onCreateFromButton" />
|
||||||
</div>
|
</div>
|
||||||
</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) { opacity: 0.9; filter: saturate(0.25); }
|
||||||
:deep(.evt-private.fc-event) { border-style: dashed; }
|
:deep(.evt-private.fc-event) { border-style: dashed; }
|
||||||
|
|
||||||
/* ── Hero Header ─────────────────────────────────── */
|
/* ── Topbar ─────────────────────────────────────────── */
|
||||||
.ag-sentinel { height: 1px; }
|
.ag-sentinel { height: 1px; }
|
||||||
|
|
||||||
.ag-hero {
|
.ag-topbar {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: var(--layout-sticky-top, 56px);
|
top: var(--layout-sticky-top, 56px);
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 1.75rem;
|
border-radius: 6px;
|
||||||
border: 1px solid var(--surface-border);
|
border: 1px solid var(--surface-border);
|
||||||
background: var(--surface-card);
|
background: var(--surface-card);
|
||||||
padding: 1.25rem 1.5rem;
|
padding: 8px 12px;
|
||||||
}
|
|
||||||
.ag-hero--stuck {
|
|
||||||
margin-left: 0;
|
|
||||||
margin-right: 0;
|
|
||||||
border-top-left-radius: 0;
|
|
||||||
border-top-right-radius: 0;
|
|
||||||
}
|
}
|
||||||
|
.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-topbar__inner {
|
||||||
.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 {
|
|
||||||
position: relative; z-index: 1;
|
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 {
|
.ag-topbar__brand { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
|
||||||
display: flex; align-items: center; gap: 0.75rem;
|
.ag-topbar__icon {
|
||||||
flex-shrink: 0; min-width: 0;
|
|
||||||
}
|
|
||||||
.ag-hero__icon {
|
|
||||||
display: grid; place-items: center;
|
display: grid; place-items: center;
|
||||||
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem;
|
width: 2.25rem; height: 2.25rem; border-radius: 6px; flex-shrink: 0;
|
||||||
flex-shrink: 0;
|
|
||||||
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
|
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
|
||||||
color: var(--p-primary-500, #6366f1);
|
color: var(--p-primary-500, #6366f1);
|
||||||
}
|
}
|
||||||
.ag-hero__title {
|
.ag-topbar__title { font-size: 1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
|
||||||
font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em;
|
.ag-topbar__sub { font-size: 0.75rem; color: var(--text-color-secondary); }
|
||||||
color: var(--text-color); white-space: nowrap;
|
.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 {
|
.ag-topbar__date-pill:hover { border-color: var(--p-primary-400); }
|
||||||
font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px;
|
.ag-topbar__filters { flex-shrink: 0; }
|
||||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
.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 {
|
/* ── Calendar wrap ──────────────────────────────────── */
|
||||||
flex: 1; display: flex; align-items: center;
|
.ag-cal-wrap {
|
||||||
justify-content: flex-end; gap: 0.75rem; flex-wrap: wrap;
|
border: 1px solid var(--surface-border);
|
||||||
}
|
border-radius: 6px; background: var(--surface-card); overflow: hidden;
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile < 1200px */
|
/* ── Sidebar cards ──────────────────────────────────── */
|
||||||
@media (max-width: 1199px) {
|
.ag-card {
|
||||||
.ag-hero__desktop-controls { display: none; }
|
border: 1px solid var(--surface-border);
|
||||||
.ag-hero__mobile-controls { display: flex; margin-left: auto; }
|
border-radius: 6px; background: var(--surface-card); padding: 0.75rem;
|
||||||
.ag-hero__divider,
|
}
|
||||||
.ag-hero__row2 { display: none; }
|
.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>
|
</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 />
|
<Toast />
|
||||||
|
|
||||||
<!-- Sentinel -->
|
<!-- 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 }">
|
HERO sticky
|
||||||
<div class="extlink-hero__blobs" aria-hidden="true">
|
═══════════════════════════════════════════════════════ -->
|
||||||
<div class="extlink-hero__blob extlink-hero__blob--1" />
|
<section
|
||||||
<div class="extlink-hero__blob extlink-hero__blob--2" />
|
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>
|
</div>
|
||||||
|
|
||||||
<!-- Row 1 -->
|
<div class="relative z-[1] flex items-center gap-3">
|
||||||
<div class="extlink-hero__row1">
|
|
||||||
<div class="extlink-hero__brand">
|
<!-- Brand -->
|
||||||
<div class="extlink-hero__icon"><i class="pi pi-link text-lg" /></div>
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
<div class="min-w-0">
|
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||||
<div class="extlink-hero__title">Link de Cadastro</div>
|
<i class="pi pi-link text-base" />
|
||||||
<div class="extlink-hero__sub">Compartilhe com o paciente para preencher o pré-cadastro com calma e segurança</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Desktop (≥1200px) -->
|
<!-- Status + link rápido — desktop -->
|
||||||
<div class="hidden xl:flex items-center gap-2 shrink-0">
|
<div class="hidden xl:flex flex-1 min-w-0 mx-2 items-center gap-3">
|
||||||
|
<!-- Badge de status -->
|
||||||
<span
|
<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
|
:class="inviteToken
|
||||||
? 'border-emerald-200 text-emerald-700 bg-emerald-50'
|
? 'border-emerald-200 text-emerald-700 bg-emerald-50'
|
||||||
: 'border-[var(--surface-border)] text-[var(--text-color-secondary)] bg-[var(--surface-ground)]'"
|
: 'border-[var(--surface-border)] text-[var(--text-color-secondary)] bg-[var(--surface-ground)]'"
|
||||||
>
|
>
|
||||||
<span
|
<span class="h-1.5 w-1.5 rounded-full" :class="inviteToken ? 'bg-emerald-500 animate-pulse' : 'bg-[var(--text-color-secondary)]'" />
|
||||||
class="h-2 w-2 rounded-full"
|
|
||||||
:class="inviteToken ? 'bg-emerald-500 animate-pulse' : 'bg-[var(--text-color-secondary)]'"
|
|
||||||
/>
|
|
||||||
{{ inviteToken ? 'Link ativo' : 'Gerando…' }}
|
{{ inviteToken ? 'Link ativo' : 'Gerando…' }}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
|
||||||
label="Gerar novo link"
|
<!-- Link inline -->
|
||||||
icon="pi pi-refresh"
|
<div v-if="!inviteToken" class="flex items-center gap-2 text-[1rem] text-[var(--text-color-secondary)]">
|
||||||
severity="secondary"
|
<i class="pi pi-spin pi-spinner" /> Gerando link…
|
||||||
outlined
|
</div>
|
||||||
class="rounded-full"
|
<InputGroup v-else class="max-w-xl">
|
||||||
:loading="rotating"
|
<InputGroupAddon><i class="pi pi-link" /></InputGroupAddon>
|
||||||
@click="rotateLink"
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile (<1200px) -->
|
<!-- Ações desktop -->
|
||||||
<div class="flex xl:hidden items-center shrink-0">
|
<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)" />
|
<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" />
|
<Menu ref="mobileMenuRef" :model="mobileMenuItems" :popup="true" />
|
||||||
</div>
|
</div>
|
||||||
</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) -->
|
<!-- ── ESQUERDA: link + mensagem ──────────────────── -->
|
||||||
<div class="extlink-hero__row2">
|
<div class="flex-1 min-w-0 flex flex-col gap-3">
|
||||||
<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">
|
|
||||||
|
|
||||||
<!-- Card principal: link -->
|
<!-- Card principal: link -->
|
||||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
|
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
|
||||||
<div class="p-5 border-b border-[var(--surface-border)] flex items-center justify-between gap-3 flex-wrap">
|
|
||||||
|
<!-- 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>
|
||||||
<div class="font-semibold text-[var(--text-color)]">Seu link público</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>
|
</div>
|
||||||
<span
|
<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
|
:class="inviteToken
|
||||||
? 'border-emerald-200 text-emerald-700 bg-emerald-50'
|
? 'border-emerald-200 text-emerald-700 bg-emerald-50'
|
||||||
: 'border-[var(--surface-border)] text-[var(--text-color-secondary)] bg-[var(--surface-ground)]'"
|
: '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…' }}
|
{{ inviteToken ? 'Ativo' : 'Gerando…' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-5 space-y-4">
|
<div class="p-4 flex flex-col gap-4">
|
||||||
<!-- Skeleton -->
|
<!-- Skeleton -->
|
||||||
<div v-if="!inviteToken" class="space-y-3">
|
<div v-if="!inviteToken" class="flex flex-col gap-3">
|
||||||
<div class="h-10 rounded-xl bg-[var(--surface-ground)] animate-pulse" />
|
<div class="h-10 rounded-md bg-[var(--surface-ground,#f8fafc)] animate-pulse" />
|
||||||
<div class="h-10 rounded-xl bg-[var(--surface-ground)] animate-pulse" />
|
<div class="h-10 rounded-md bg-[var(--surface-ground,#f8fafc)] animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-4">
|
<div v-else class="flex flex-col gap-4">
|
||||||
<!-- Link com ações -->
|
<!-- InputGroup do link -->
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
<InputGroupAddon><i class="pi pi-link" /></InputGroupAddon>
|
<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-copy" severity="secondary" title="Copiar" @click="copyLink" />
|
||||||
<Button icon="pi pi-external-link" severity="secondary" title="Abrir" @click="openLink" />
|
<Button icon="pi pi-external-link" severity="secondary" title="Abrir" @click="openLink" />
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
|
|
||||||
<div class="text-xs text-[var(--text-color-secondary)]">
|
<div class="text-[0.75rem] text-[var(--text-color-secondary)]">
|
||||||
Token: <span class="font-mono select-all">{{ inviteToken }}</span>
|
Token: <span class="font-mono select-all opacity-60">{{ inviteToken }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- CTAs rápidas -->
|
<!-- CTAs rápidas -->
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
|
||||||
<button class="extlink-cta-btn" @click="copyLink">
|
<!-- Copiar link -->
|
||||||
<div class="extlink-cta-btn__icon bg-[color-mix(in_srgb,var(--p-primary-500,#6366f1)_12%,transparent)] text-[var(--p-primary-500,#6366f1)]">
|
<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" />
|
<i class="pi pi-copy" />
|
||||||
</div>
|
</div>
|
||||||
<div class="text-left min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="font-semibold text-sm text-[var(--text-color)]">Copiar link</div>
|
<div class="font-semibold text-[1rem] 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="text-[0.75rem] text-[var(--text-color-secondary)]">Cole no WhatsApp ou e-mail</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button class="extlink-cta-btn" @click="copyInviteMessage">
|
<!-- Copiar mensagem pronta -->
|
||||||
<div class="extlink-cta-btn__icon bg-emerald-500/10 text-emerald-600">
|
<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" />
|
<i class="pi pi-comment" />
|
||||||
</div>
|
</div>
|
||||||
<div class="text-left min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="font-semibold text-sm text-[var(--text-color)]">Copiar mensagem pronta</div>
|
<div class="font-semibold text-[1rem] 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="text-[0.75rem] text-[var(--text-color-secondary)]">Texto formatado com o link incluso</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,84 +158,66 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mensagem pronta -->
|
<!-- 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">
|
<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
|
Mensagem pronta para envio
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-[var(--text-color-secondary)] mb-3">Copie e cole ao enviar o link ao paciente:</div>
|
<div class="text-[1rem] 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="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:
|
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>
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<Button
|
<Button icon="pi pi-copy" label="Copiar mensagem" severity="secondary" outlined class="rounded-full" :disabled="!publicUrl" @click="copyInviteMessage" />
|
||||||
icon="pi pi-copy"
|
|
||||||
label="Copiar mensagem"
|
|
||||||
severity="secondary"
|
|
||||||
outlined
|
|
||||||
class="rounded-full"
|
|
||||||
:disabled="!publicUrl"
|
|
||||||
@click="copyInviteMessage"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Direita: instruções -->
|
<!-- ── DIREITA: instruções ────────────────────────── -->
|
||||||
<div class="lg:w-80 shrink-0 flex flex-col gap-4">
|
<div class="w-full lg:w-[272px] lg:flex-shrink-0 flex flex-col gap-3">
|
||||||
|
|
||||||
<!-- Como funciona -->
|
<!-- Como funciona -->
|
||||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
|
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
|
||||||
<div class="p-5 border-b border-[var(--surface-border)]">
|
<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="font-semibold text-[var(--text-color)]">Como funciona</div>
|
<div class="w-8 h-8 rounded-md flex items-center justify-center flex-shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||||
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">Simples e sem fricção para o paciente</div>
|
<i class="pi pi-list-check text-[0.9rem]" />
|
||||||
</div>
|
</div>
|
||||||
<div class="p-5">
|
<div class="min-w-0">
|
||||||
<ol class="space-y-4">
|
<span class="block text-[1rem] font-bold text-[var(--text-color)]">Como funciona</span>
|
||||||
<li class="flex gap-3">
|
<span class="block text-[0.72rem] text-[var(--text-color-secondary)]">Simples e sem fricção para o paciente</span>
|
||||||
<div class="extlink-step shrink-0">1</div>
|
</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>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Boas práticas -->
|
<!-- Boas práticas -->
|
||||||
<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)] overflow-hidden">
|
||||||
<div class="font-semibold text-[var(--text-color)] flex items-center gap-2 mb-3">
|
<div class="flex items-center gap-2.5 px-3.5 pt-3 pb-2.5 border-b border-[var(--surface-border,#f1f5f9)]">
|
||||||
<i class="pi pi-shield text-sm text-[var(--text-color-secondary)]" />
|
<div class="w-8 h-8 rounded-md flex items-center justify-center flex-shrink-0 bg-emerald-500/10 text-emerald-600">
|
||||||
Boas práticas
|
<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>
|
</div>
|
||||||
<ul class="space-y-2.5">
|
|
||||||
<li class="flex gap-2 text-sm text-[var(--text-color-secondary)]">
|
<ul class="flex flex-col divide-y divide-[var(--surface-border,#f1f5f9)]">
|
||||||
<i class="pi pi-check text-emerald-500 mt-0.5 shrink-0" />
|
<li v-for="tip in goodPractices" :key="tip" class="flex items-start gap-2.5 px-3.5 py-2.5">
|
||||||
<span>Gere um novo link se suspeitar que ele foi repassado indevidamente.</span>
|
<i class="pi pi-check text-emerald-500 mt-0.5 flex-shrink-0 text-[1rem]" />
|
||||||
</li>
|
<span class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">{{ tip }}</span>
|
||||||
<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>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -241,26 +235,39 @@ import { supabase } from '@/lib/supabase/client'
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
const inviteToken = ref('')
|
const inviteToken = ref('')
|
||||||
const rotating = ref(false)
|
const rotating = ref(false)
|
||||||
|
|
||||||
// ── Hero sticky ────────────────────────────────────────────
|
// ── Hero sticky ───────────────────────────────────────────
|
||||||
const headerEl = ref(null)
|
const headerEl = ref(null)
|
||||||
const headerSentinelRef = ref(null)
|
const headerSentinelRef = ref(null)
|
||||||
const headerStuck = ref(false)
|
const headerStuck = ref(false)
|
||||||
let _observer = null
|
let _observer = null
|
||||||
|
|
||||||
// ── Mobile menu ────────────────────────────────────────────
|
// ── Mobile menu ───────────────────────────────────────────
|
||||||
const mobileMenuRef = ref(null)
|
const mobileMenuRef = ref(null)
|
||||||
|
|
||||||
const mobileMenuItems = computed(() => [
|
const mobileMenuItems = computed(() => [
|
||||||
{ label: 'Copiar link', icon: 'pi pi-copy', command: () => copyLink(), 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: '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: 'Abrir no navegador', icon: 'pi pi-external-link', command: () => openLink(), disabled: !inviteToken.value },
|
||||||
{ separator: true },
|
{ 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 PUBLIC_BASE_URL = ''
|
||||||
|
|
||||||
const origin = computed(() => {
|
const origin = computed(() => {
|
||||||
@@ -273,13 +280,13 @@ const publicUrl = computed(() => {
|
|||||||
return `${origin.value}/cadastro/paciente?t=${encodeURIComponent(inviteToken.value)}`
|
return `${origin.value}/cadastro/paciente?t=${encodeURIComponent(inviteToken.value)}`
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Token helpers ───────────────────────────────────────────
|
// ── Token helpers ─────────────────────────────────────────
|
||||||
function newToken() {
|
function newToken () {
|
||||||
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID()
|
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID()
|
||||||
return 'tok_' + Math.random().toString(36).slice(2) + Date.now().toString(36)
|
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()
|
const { data, error } = await supabase.auth.getUser()
|
||||||
if (error) throw error
|
if (error) throw error
|
||||||
const uid = data?.user?.id
|
const uid = data?.user?.id
|
||||||
@@ -287,7 +294,7 @@ async function requireUserId() {
|
|||||||
return uid
|
return uid
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadOrCreateInvite() {
|
async function loadOrCreateInvite () {
|
||||||
const uid = await requireUserId()
|
const uid = await requireUserId()
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
@@ -301,10 +308,7 @@ async function loadOrCreateInvite() {
|
|||||||
if (error) throw error
|
if (error) throw error
|
||||||
|
|
||||||
const token = data?.[0]?.token
|
const token = data?.[0]?.token
|
||||||
if (token) {
|
if (token) { inviteToken.value = token; return }
|
||||||
inviteToken.value = token
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const t = newToken()
|
const t = newToken()
|
||||||
const { error: insErr } = await supabase
|
const { error: insErr } = await supabase
|
||||||
@@ -315,19 +319,18 @@ async function loadOrCreateInvite() {
|
|||||||
inviteToken.value = t
|
inviteToken.value = t
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rotateLink() {
|
async function rotateLink () {
|
||||||
rotating.value = true
|
rotating.value = true
|
||||||
try {
|
try {
|
||||||
const uid = await requireUserId()
|
const uid = await requireUserId()
|
||||||
const t = newToken()
|
const t = newToken()
|
||||||
|
|
||||||
const rpc = await supabase.rpc('rotate_patient_invite_token', { p_new_token: t })
|
const rpc = await supabase.rpc('rotate_patient_invite_token', { p_new_token: t })
|
||||||
if (rpc.error) {
|
if (rpc.error) {
|
||||||
const { error: e1 } = await supabase
|
const { error: e1 } = await supabase
|
||||||
.from('patient_invites')
|
.from('patient_invites')
|
||||||
.update({ active: false, updated_at: new Date().toISOString() })
|
.update({ active: false, updated_at: new Date().toISOString() })
|
||||||
.eq('owner_id', uid)
|
.eq('owner_id', uid).eq('active', true)
|
||||||
.eq('active', true)
|
|
||||||
if (e1) throw e1
|
if (e1) throw e1
|
||||||
|
|
||||||
const { error: e2 } = await supabase
|
const { error: e2 } = await supabase
|
||||||
@@ -345,7 +348,7 @@ async function rotateLink() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyLink() {
|
async function copyLink () {
|
||||||
try {
|
try {
|
||||||
if (!publicUrl.value) return
|
if (!publicUrl.value) return
|
||||||
await navigator.clipboard.writeText(publicUrl.value)
|
await navigator.clipboard.writeText(publicUrl.value)
|
||||||
@@ -355,12 +358,12 @@ async function copyLink() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openLink() {
|
function openLink () {
|
||||||
if (!publicUrl.value) return
|
if (!publicUrl.value) return
|
||||||
window.open(publicUrl.value, '_blank', 'noopener')
|
window.open(publicUrl.value, '_blank', 'noopener')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function copyInviteMessage() {
|
async function copyInviteMessage () {
|
||||||
try {
|
try {
|
||||||
if (!publicUrl.value) return
|
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}`
|
const msg = `Olá! Segue o link para seu pré-cadastro. Preencha com calma — campos opcionais podem ficar em branco:\n${publicUrl.value}`
|
||||||
@@ -388,95 +391,3 @@ onMounted(async () => {
|
|||||||
|
|
||||||
onBeforeUnmount(() => { _observer?.disconnect() })
|
onBeforeUnmount(() => { _observer?.disconnect() })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* ── Sentinel ─────────────────────────────────────── */
|
|
||||||
.extlink-sentinel { height: 1px; }
|
|
||||||
|
|
||||||
/* ── Hero ─────────────────────────────────────────── */
|
|
||||||
.extlink-hero {
|
|
||||||
position: sticky;
|
|
||||||
top: var(--layout-sticky-top, 56px);
|
|
||||||
z-index: 20;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 1.75rem;
|
|
||||||
border: 1px solid var(--surface-border);
|
|
||||||
background: var(--surface-card);
|
|
||||||
padding: 1.25rem 1.5rem;
|
|
||||||
}
|
|
||||||
.extlink-hero--stuck {
|
|
||||||
margin-left: 0; margin-right: 0;
|
|
||||||
border-top-left-radius: 0; border-top-right-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Blobs decorativos */
|
|
||||||
.extlink-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
|
|
||||||
.extlink-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
|
|
||||||
.extlink-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(99,102,241,0.10); }
|
|
||||||
.extlink-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(16,185,129,0.08); }
|
|
||||||
|
|
||||||
/* Linha 1 */
|
|
||||||
.extlink-hero__row1 {
|
|
||||||
position: relative; z-index: 1;
|
|
||||||
display: flex; align-items: center; gap: 1rem;
|
|
||||||
}
|
|
||||||
.extlink-hero__brand {
|
|
||||||
display: flex; align-items: center; gap: 0.75rem;
|
|
||||||
flex: 1; min-width: 0;
|
|
||||||
}
|
|
||||||
.extlink-hero__icon {
|
|
||||||
display: grid; place-items: center;
|
|
||||||
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem; flex-shrink: 0;
|
|
||||||
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
|
|
||||||
color: var(--p-primary-500, #6366f1);
|
|
||||||
}
|
|
||||||
.extlink-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
|
|
||||||
.extlink-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
|
|
||||||
|
|
||||||
/* Linha 2 */
|
|
||||||
.extlink-hero__row2 {
|
|
||||||
position: relative; z-index: 1;
|
|
||||||
display: flex; align-items: center; gap: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 767px) {
|
|
||||||
.extlink-hero__divider,
|
|
||||||
.extlink-hero__row2 { display: none; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── CTA button ───────────────────────────────────── */
|
|
||||||
.extlink-cta-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.875rem;
|
|
||||||
padding: 0.875rem 1rem;
|
|
||||||
border-radius: 1rem;
|
|
||||||
border: 1px solid var(--surface-border);
|
|
||||||
background: var(--surface-ground);
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.1s ease;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
.extlink-cta-btn:hover {
|
|
||||||
background: var(--surface-hover);
|
|
||||||
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
|
|
||||||
transform: translateY(-1px);
|
|
||||||
}
|
|
||||||
.extlink-cta-btn:active { transform: translateY(0); }
|
|
||||||
.extlink-cta-btn__icon {
|
|
||||||
display: grid; place-items: center;
|
|
||||||
width: 2.25rem; height: 2.25rem;
|
|
||||||
border-radius: 0.75rem; flex-shrink: 0;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Step numbers ─────────────────────────────────── */
|
|
||||||
.extlink-step {
|
|
||||||
display: grid; place-items: center;
|
|
||||||
width: 2rem; height: 2rem;
|
|
||||||
border-radius: 0.625rem;
|
|
||||||
font-size: 0.8rem; font-weight: 700;
|
|
||||||
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
|
|
||||||
color: var(--p-primary-500, #6366f1);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
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 AppRailPanel from './AppRailPanel.vue'
|
||||||
import AppRailSidebar from './AppRailSidebar.vue'
|
import AppRailSidebar from './AppRailSidebar.vue'
|
||||||
import AjudaDrawer from '@/components/AjudaDrawer.vue'
|
import AjudaDrawer from '@/components/AjudaDrawer.vue'
|
||||||
|
import SupportDebugBanner from '@/support/components/SupportDebugBanner.vue'
|
||||||
|
|
||||||
import { fetchDocsForPath, useAjuda } from '@/composables/useAjuda'
|
import { fetchDocsForPath, useAjuda } from '@/composables/useAjuda'
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ import { useEntitlementsStore } from '@/stores/entitlementsStore'
|
|||||||
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore'
|
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { layoutConfig, layoutState, hideMobileMenu, isDesktop } = useLayout()
|
const { layoutConfig, layoutState, hideMobileMenu, isDesktop, effectiveVariant } = useLayout()
|
||||||
|
|
||||||
const layoutArea = computed(() => route.meta?.area || null)
|
const layoutArea = computed(() => route.meta?.area || null)
|
||||||
provide('layoutArea', layoutArea)
|
provide('layoutArea', layoutArea)
|
||||||
@@ -100,7 +101,7 @@ onBeforeUnmount(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- ══ Layout Rail ══ -->
|
<!-- ══ Layout Rail ══ -->
|
||||||
<template v-else-if="layoutConfig.variant === 'rail'">
|
<template v-else-if="effectiveVariant === 'rail'">
|
||||||
<div class="l2-root">
|
<div class="l2-root">
|
||||||
<!-- Rail de ícones: oculto em mobile (≤ 1200px) via CSS -->
|
<!-- Rail de ícones: oculto em mobile (≤ 1200px) via CSS -->
|
||||||
<AppRail />
|
<AppRail />
|
||||||
@@ -142,6 +143,9 @@ onBeforeUnmount(() => {
|
|||||||
<AjudaDrawer />
|
<AjudaDrawer />
|
||||||
<Toast />
|
<Toast />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- ══ Global — fora de todos os branches, persiste em qualquer layout/rota ══ -->
|
||||||
|
<SupportDebugBanner />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const railSections = computed(() => {
|
|||||||
const model = menuStore.model || []
|
const model = menuStore.model || []
|
||||||
return model
|
return model
|
||||||
.filter(s => s.label && Array.isArray(s.items) && s.items.length)
|
.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 => ({
|
.map(s => ({
|
||||||
key: s.label,
|
key: s.label,
|
||||||
label: s.label,
|
label: s.label,
|
||||||
@@ -38,7 +39,21 @@ const initials = computed(() => {
|
|||||||
return (a + b).toUpperCase()
|
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 ─────────────────────────────────────────
|
// ── Seleção de seção ─────────────────────────────────────────
|
||||||
function selectSection (section) {
|
function selectSection (section) {
|
||||||
@@ -52,7 +67,6 @@ function selectSection (section) {
|
|||||||
|
|
||||||
function isActiveSectionOrChild (section) {
|
function isActiveSectionOrChild (section) {
|
||||||
if (layoutState.railSectionKey === section.key && layoutState.railPanelOpen) return true
|
if (layoutState.railSectionKey === section.key && layoutState.railPanelOpen) return true
|
||||||
// verifica se algum filho está ativo
|
|
||||||
const active = String(layoutState.activePath || '')
|
const active = String(layoutState.activePath || '')
|
||||||
return section.items.some(i => {
|
return section.items.some(i => {
|
||||||
const p = typeof i.to === 'string' ? i.to : ''
|
const p = typeof i.to === 'string' ? i.to : ''
|
||||||
@@ -77,21 +91,33 @@ async function signOut () {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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 ──────────────────────────────────────────── -->
|
<!-- ── Brand ──────────────────────────────────────────── -->
|
||||||
<div class="rail__brand">
|
<div class="w-full h-14 shrink-0 grid place-items-center border-b border-[var(--surface-border)]">
|
||||||
<span class="rail__psi">Ψ</span>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- ── Nav icons ──────────────────────────────────────── -->
|
<!-- ── 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
|
<button
|
||||||
v-for="section in railSections"
|
v-for="section in railSections"
|
||||||
:key="section.key"
|
:key="section.key"
|
||||||
class="rail__btn"
|
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="{ 'rail__btn--active': isActiveSectionOrChild(section) }"
|
: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: 400 }"
|
v-tooltip.right="{ value: section.label, showDelay: 0 }"
|
||||||
:aria-label="section.label"
|
:aria-label="section.label"
|
||||||
@click="selectSection(section)"
|
@click="selectSection(section)"
|
||||||
>
|
>
|
||||||
@@ -100,49 +126,48 @@ async function signOut () {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- ── Rodapé ─────────────────────────────────────────── -->
|
<!-- ── Rodapé ─────────────────────────────────────────── -->
|
||||||
<div class="rail__foot">
|
<div class="w-full flex flex-col items-center gap-1.5 py-2 pb-3 border-t border-[var(--surface-border)]">
|
||||||
<!-- Configurações de layout -->
|
|
||||||
<button
|
<button
|
||||||
class="rail__btn rail__btn--sm"
|
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: 'Meu Perfil', showDelay: 400 }"
|
v-tooltip.right="{ value: 'Configurações', showDelay: 0 }"
|
||||||
aria-label="Meu Perfil"
|
aria-label="Configurações"
|
||||||
@click="goTo('/account/profile')"
|
@click="goTo('/configuracoes')"
|
||||||
>
|
>
|
||||||
<i class="pi pi-fw pi-cog" />
|
<i class="pi pi-fw pi-cog" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Avatar / user -->
|
<!-- Avatar / user -->
|
||||||
<button
|
<button
|
||||||
class="rail__av-btn"
|
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: 400 }"
|
v-tooltip.right="{ value: userName, showDelay: 0 }"
|
||||||
:aria-label="userName"
|
:aria-label="userName"
|
||||||
@click="toggleUserPop"
|
@click="toggleUserPop"
|
||||||
>
|
>
|
||||||
<img v-if="avatarUrl" :src="avatarUrl" class="rail__av-img" :alt="userName" />
|
<img v-if="avatarUrl" :src="avatarUrl" class="w-full h-full object-cover" :alt="userName" />
|
||||||
<span v-else class="rail__av-init">{{ initials }}</span>
|
<span v-else class="text-[1rem] font-bold text-[var(--text-color)]">{{ initials }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Popover usuário ────────────────────────────────── -->
|
<!-- ── Popover usuário ────────────────────────────────── -->
|
||||||
<Popover ref="userPop" appendTo="body">
|
<Popover ref="userPop" appendTo="body">
|
||||||
<div class="rail-pop">
|
<div class="min-w-[210px] p-1 flex flex-col gap-0.5">
|
||||||
<div class="rail-pop__user">
|
<div class="flex items-center gap-2.5 px-2.5 py-2 pb-2.5">
|
||||||
<div class="rail-pop__av">
|
<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="rail-pop__av-img" />
|
<img v-if="avatarUrl" :src="avatarUrl" class="w-full h-full object-cover" />
|
||||||
<span v-else class="rail-pop__av-init">{{ initials }}</span>
|
<span v-else class="text-[1rem] font-bold text-[var(--text-color)]">{{ initials }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="rail-pop__name">{{ userName }}</div>
|
<div class="text-[0.83rem] font-semibold text-[var(--text-color)] truncate">{{ userName }}</div>
|
||||||
<div class="rail-pop__email">{{ sessionUser?.email }}</div>
|
<div class="text-[0.68rem] text-[var(--text-color-secondary)] truncate">{{ sessionUser?.email }}</div>
|
||||||
</div>
|
</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="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="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" />
|
<Button label="Sair" icon="pi pi-sign-out" severity="danger" text class="w-full justify-start" @click="signOut" />
|
||||||
</div>
|
</div>
|
||||||
@@ -151,78 +176,8 @@ async function signOut () {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* ─── Rail container ─────────────────────────────────────── */
|
/* Indicador lateral do botão ativo — pseudo-elemento não expressável em Tailwind */
|
||||||
.rail {
|
.rail-btn--active::before {
|
||||||
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 {
|
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: -10px;
|
left: -10px;
|
||||||
@@ -233,97 +188,4 @@ async function signOut () {
|
|||||||
border-radius: 0 3px 3px 0;
|
border-radius: 0 3px 3px 0;
|
||||||
background: var(--primary-color);
|
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 -->
|
<!-- src/layout/AppRailPanel.vue — Painel expansível do Layout 2 -->
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed, ref, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
|
||||||
import { useMenuStore } from '@/stores/menuStore'
|
import { useMenuStore } from '@/stores/menuStore'
|
||||||
import { useLayout } from './composables/layout'
|
import { useLayout } from './composables/layout'
|
||||||
import { useEntitlementsStore } from '@/stores/entitlementsStore'
|
import { useEntitlementsStore } from '@/stores/entitlementsStore'
|
||||||
|
|
||||||
const menuStore = useMenuStore()
|
const menuStore = useMenuStore()
|
||||||
@@ -19,9 +19,25 @@ const currentSection = computed(() => {
|
|||||||
return model.find(s => s.label === layoutState.railSectionKey) || null
|
return model.find(s => s.label === layoutState.railSectionKey) || null
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Items da seção (com suporte a children) ──────────────────
|
// Todos os grupos do menu
|
||||||
const sectionItems = computed(() => currentSection.value?.items || [])
|
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) {
|
function isLocked (item) {
|
||||||
if (!item.proBadge || !item.feature) return false
|
if (!item.proBadge || !item.feature) return false
|
||||||
try { return !entitlements.has(item.feature) } catch { return false }
|
try { return !entitlements.has(item.feature) } catch { return false }
|
||||||
@@ -57,59 +73,324 @@ function navigate (item) {
|
|||||||
function closePanel () {
|
function closePanel () {
|
||||||
layoutState.railPanelOpen = false
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Transition name="panel-slide">
|
<Transition name="panel-slide">
|
||||||
<aside
|
<aside
|
||||||
v-if="layoutState.railPanelOpen && currentSection"
|
v-if="layoutState.railPanelOpen"
|
||||||
class="rp"
|
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"
|
aria-label="Menu lateral"
|
||||||
>
|
>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="rp__head">
|
<div class="h-14 shrink-0 flex items-center justify-between px-4 border-b border-[var(--surface-border)]">
|
||||||
<span class="rp__title">{{ currentSection.label }}</span>
|
<span class="text-[0.9rem] font-bold tracking-tight text-[var(--text-color)]">
|
||||||
<button class="rp__close" aria-label="Fechar painel" @click="closePanel">
|
{{ 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" />
|
<i class="pi pi-times" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Items -->
|
<!-- Busca — só no Início -->
|
||||||
<nav class="rp__nav">
|
<div v-if="isHome" ref="searchWrapEl" class="shrink-0 px-2.5 pt-2.5 border-b border-[var(--surface-border)]">
|
||||||
<template v-for="item in sectionItems" :key="item.to || item.label">
|
<!-- Campo -->
|
||||||
<!-- Item com filhos (sub-seção) -->
|
<div class="relative">
|
||||||
<div v-if="item.items?.length" class="rp__group">
|
<div aria-hidden="true" style="position:absolute;left:-9999px;top:-9999px;width:1px;height:1px;overflow:hidden;">
|
||||||
<div class="rp__group-label">{{ item.label }}</div>
|
<input type="email" name="email" autocomplete="username" tabindex="-1" disabled />
|
||||||
<button
|
<input type="password" name="password" autocomplete="current-password" tabindex="-1" disabled />
|
||||||
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>
|
|
||||||
</div>
|
</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
|
<button
|
||||||
v-else
|
v-if="query.trim()"
|
||||||
class="rp__item"
|
type="button"
|
||||||
:class="{
|
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"
|
||||||
'rp__item--active': isActive(item),
|
@mousedown.prevent="clearSearch"
|
||||||
'rp__item--locked': isLocked(item)
|
aria-label="Limpar busca"
|
||||||
}"
|
|
||||||
@click="navigate(item)"
|
|
||||||
>
|
>
|
||||||
<i v-if="item.icon" :class="item.icon" class="rp__item-icon" />
|
<i class="pi pi-times" />
|
||||||
<span class="rp__item-label">{{ item.label }}</span>
|
|
||||||
<span v-if="isLocked(item)" class="rp__pro">PRO</span>
|
|
||||||
</button>
|
</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>
|
</template>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -117,134 +398,9 @@ function closePanel () {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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-enter-active,
|
||||||
.panel-slide-leave-active {
|
.panel-slide-leave-active {
|
||||||
transition: width 0.22s cubic-bezier(0.22, 1, 0.36, 1),
|
transition: width 0.22s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.18s ease;
|
||||||
opacity 0.18s ease;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.panel-slide-enter-from,
|
.panel-slide-enter-from,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<!-- src/layout/AppRailSidebar.vue — Drawer mobile para Layout Rail -->
|
<!-- src/layout/AppRailSidebar.vue — Drawer mobile para Layout Rail -->
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch, nextTick } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
|
||||||
import { useMenuStore } from '@/stores/menuStore'
|
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) {
|
function isSectionOpen (key) {
|
||||||
return openSections.value.includes(key)
|
return openSections.value.includes(key)
|
||||||
}
|
}
|
||||||
@@ -99,6 +247,115 @@ watch(() => route.path, () => hideMobileMenu())
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 -->
|
||||||
<nav class="rs__nav">
|
<nav class="rs__nav">
|
||||||
<template v-for="section in sections" :key="section.key">
|
<template v-for="section in sections" :key="section.key">
|
||||||
@@ -205,7 +462,123 @@ watch(() => route.path, () => hideMobileMenu())
|
|||||||
color: var(--text-color);
|
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 {
|
.rs__nav {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ const { canSee } = useRoleGuard()
|
|||||||
|
|
||||||
import { useAjuda } from '@/composables/useAjuda'
|
import { useAjuda } from '@/composables/useAjuda'
|
||||||
const { openDrawer: openAjudaDrawer, closeDrawer: closeAjudaDrawer, drawerOpen: ajudaDrawerOpen } = 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 () {
|
function toggleAjuda () {
|
||||||
ajudaDrawerOpen.value ? closeAjudaDrawer() : openAjudaDrawer()
|
ajudaDrawerOpen.value ? closeAjudaDrawer() : openAjudaDrawer()
|
||||||
}
|
}
|
||||||
@@ -585,6 +591,25 @@ onMounted(async () => {
|
|||||||
:baseZIndex="3000"
|
: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 -->
|
<!-- Ajuda -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -700,6 +725,27 @@ onMounted(async () => {
|
|||||||
.config-panel {
|
.config-panel {
|
||||||
z-index: 200;
|
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 {
|
.topbar-ctx-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const secoes = [
|
|||||||
{
|
{
|
||||||
key: 'bloqueios',
|
key: 'bloqueios',
|
||||||
label: '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',
|
icon: 'pi pi-ban',
|
||||||
to: '/configuracoes/bloqueios',
|
to: '/configuracoes/bloqueios',
|
||||||
tags: ['Feriados', 'Períodos', 'Recorrentes']
|
tags: ['Feriados', 'Períodos', 'Recorrentes']
|
||||||
@@ -32,7 +32,7 @@ const secoes = [
|
|||||||
{
|
{
|
||||||
key: 'agendador',
|
key: 'agendador',
|
||||||
label: 'Agendador Online',
|
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',
|
icon: 'pi pi-calendar-clock',
|
||||||
to: '/configuracoes/agendador',
|
to: '/configuracoes/agendador',
|
||||||
tags: ['PRO', 'Link', 'Pix', 'LGPD']
|
tags: ['PRO', 'Link', 'Pix', 'LGPD']
|
||||||
@@ -40,7 +40,7 @@ const secoes = [
|
|||||||
{
|
{
|
||||||
key: 'pagamento',
|
key: 'pagamento',
|
||||||
label: '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',
|
icon: 'pi pi-wallet',
|
||||||
to: '/configuracoes/pagamento',
|
to: '/configuracoes/pagamento',
|
||||||
tags: ['Pix', 'TED', 'Cartão', 'Convênio']
|
tags: ['Pix', 'TED', 'Cartão', 'Convênio']
|
||||||
@@ -48,7 +48,7 @@ const secoes = [
|
|||||||
{
|
{
|
||||||
key: 'precificacao',
|
key: 'precificacao',
|
||||||
label: 'Precificação',
|
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',
|
icon: 'pi pi-tag',
|
||||||
to: '/configuracoes/precificacao',
|
to: '/configuracoes/precificacao',
|
||||||
tags: ['Valores', 'Sessão', 'Compromisso']
|
tags: ['Valores', 'Sessão', 'Compromisso']
|
||||||
@@ -56,7 +56,7 @@ const secoes = [
|
|||||||
{
|
{
|
||||||
key: 'descontos',
|
key: 'descontos',
|
||||||
label: 'Descontos por Paciente',
|
label: 'Descontos por Paciente',
|
||||||
desc: 'Descontos recorrentes aplicados automaticamente por paciente.',
|
desc: 'Descontos recorrentes aplicados automaticamente.',
|
||||||
icon: 'pi pi-percentage',
|
icon: 'pi pi-percentage',
|
||||||
to: '/configuracoes/descontos',
|
to: '/configuracoes/descontos',
|
||||||
tags: ['Desconto', 'Paciente', 'Automático']
|
tags: ['Desconto', 'Paciente', 'Automático']
|
||||||
@@ -64,7 +64,7 @@ const secoes = [
|
|||||||
{
|
{
|
||||||
key: 'excecoes-financeiras',
|
key: 'excecoes-financeiras',
|
||||||
label: 'Exceções 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',
|
icon: 'pi pi-exclamation-triangle',
|
||||||
to: '/configuracoes/excecoes-financeiras',
|
to: '/configuracoes/excecoes-financeiras',
|
||||||
tags: ['Falta', 'Cancelamento', 'Cobrança']
|
tags: ['Falta', 'Cancelamento', 'Cobrança']
|
||||||
@@ -72,37 +72,11 @@ const secoes = [
|
|||||||
{
|
{
|
||||||
key: 'convenios',
|
key: 'convenios',
|
||||||
label: 'Convênios',
|
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',
|
icon: 'pi pi-id-card',
|
||||||
to: '/configuracoes/convenios',
|
to: '/configuracoes/convenios',
|
||||||
tags: ['Convênio', 'Plano de Saúde', 'Tabela']
|
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(() => {
|
const activeTo = computed(() => {
|
||||||
@@ -113,6 +87,8 @@ const activeTo = computed(() => {
|
|||||||
return hit?.to || '/configuracoes/agenda'
|
return hit?.to || '/configuracoes/agenda'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const activeSecao = computed(() => secoes.find(s => s.to === activeTo.value))
|
||||||
|
|
||||||
function ir(to) {
|
function ir(to) {
|
||||||
if (!to) return
|
if (!to) return
|
||||||
if (route.path !== to) router.push(to)
|
if (route.path !== to) router.push(to)
|
||||||
@@ -134,105 +110,87 @@ onBeforeUnmount(() => { _observer?.disconnect() })
|
|||||||
<!-- Sentinel -->
|
<!-- Sentinel -->
|
||||||
<div ref="headerSentinelRef" class="cfg-sentinel" />
|
<div ref="headerSentinelRef" class="cfg-sentinel" />
|
||||||
|
|
||||||
<!-- Hero sticky -->
|
<!-- Hero compacto — padrão Compromissos -->
|
||||||
<div ref="headerEl" class="cfg-hero mb-4" :class="{ 'cfg-hero--stuck': headerStuck }">
|
<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__blobs" aria-hidden="true">
|
||||||
<div class="cfg-hero__blob cfg-hero__blob--1" />
|
<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--2" />
|
||||||
<div class="cfg-hero__blob cfg-hero__blob--3" />
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="cfg-hero__inner">
|
||||||
<div class="cfg-hero__row1">
|
<!-- Brand -->
|
||||||
<div class="cfg-hero__brand">
|
<div class="cfg-hero__brand">
|
||||||
<div class="cfg-hero__icon"><i class="pi pi-cog text-lg" /></div>
|
<div class="cfg-hero__icon"><i class="pi pi-cog text-base" /></div>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0 lg:block">
|
||||||
<div class="cfg-hero__title">Configurações</div>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div class="hidden xl:flex items-center gap-2 shrink-0">
|
<!-- Ações -->
|
||||||
<Button label="Voltar" icon="pi pi-arrow-left" severity="secondary" outlined class="rounded-full" @click="router.back()" />
|
<div class="cfg-hero__actions">
|
||||||
</div>
|
<Button icon="pi pi-arrow-left" severity="secondary" outlined class="h-9 w-9 rounded-full" v-tooltip.bottom="'Voltar'" @click="router.back()" />
|
||||||
|
|
||||||
<div class="flex xl:hidden items-center shrink-0">
|
|
||||||
<Button icon="pi pi-arrow-left" severity="secondary" outlined class="h-9 w-9 rounded-full" @click="router.back()" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<!-- Layout: sidebar + conteúdo -->
|
||||||
<!-- SIDEBAR (seções) -->
|
<div class="flex flex-col xl:flex-row gap-3 px-3 md:px-4 pb-5 items-start">
|
||||||
<div class="col-span-12 lg:col-span-4 xl:col-span-3">
|
|
||||||
<Card class="h-full">
|
<!-- Sidebar: lista de seções (oculto no mobile — já temos os cards acima) -->
|
||||||
<template #title>
|
<div class="hidden xl:flex flex-col gap-1 w-[260px] shrink-0">
|
||||||
<div class="flex items-center gap-2">
|
<div class="cfg-sidebar-wrap">
|
||||||
<i class="pi pi-cog" />
|
<div class="cfg-sidebar-head">
|
||||||
<span>Seções</span>
|
<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>
|
</div>
|
||||||
</template>
|
<i class="pi pi-chevron-right cfg-nav-item__arrow" />
|
||||||
|
</button>
|
||||||
<template #content>
|
</div>
|
||||||
<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 />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Conteúdo da seção -->
|
||||||
|
<div class="flex-1 min-w-0 w-full">
|
||||||
|
<router-view />
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* ── Hero ─────────────────────────────────────────────── */
|
||||||
.cfg-sentinel { height: 1px; }
|
.cfg-sentinel { height: 1px; }
|
||||||
|
|
||||||
.cfg-hero {
|
.cfg-hero {
|
||||||
@@ -240,36 +198,163 @@ onBeforeUnmount(() => { _observer?.disconnect() })
|
|||||||
top: var(--layout-sticky-top, 56px);
|
top: var(--layout-sticky-top, 56px);
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 1.75rem;
|
border-radius: 6px;
|
||||||
border: 1px solid var(--surface-border);
|
border: 1px solid var(--surface-border);
|
||||||
background: var(--surface-card);
|
background: var(--surface-card);
|
||||||
padding: 1.25rem 1.5rem;
|
padding: 10px 12px;
|
||||||
}
|
}
|
||||||
.cfg-hero--stuck {
|
.cfg-hero--stuck {
|
||||||
margin-left: 0; margin-right: 0;
|
border-top-left-radius: 0;
|
||||||
border-top-left-radius: 0; border-top-right-radius: 0;
|
border-top-right-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cfg-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
|
.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 { position: absolute; border-radius: 50%; filter: blur(60px); }
|
||||||
.cfg-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(52,211,153,0.10); }
|
.cfg-hero__blob--1 { width: 16rem; height: 16rem; top: -4rem; right: -2rem; 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--2 { width: 18rem; height: 18rem; top: 0; left: -4rem; 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__row1 {
|
.cfg-hero__inner {
|
||||||
position: relative; z-index: 1;
|
position: relative; z-index: 1;
|
||||||
display: flex; align-items: center; gap: 1rem;
|
|
||||||
}
|
|
||||||
.cfg-hero__brand {
|
|
||||||
display: flex; align-items: center; gap: 0.75rem;
|
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 {
|
.cfg-hero__icon {
|
||||||
display: grid; place-items: center;
|
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);
|
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
|
||||||
color: var(--p-primary-500, #6366f1);
|
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__title { font-size: 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__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>
|
</style>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { computed, reactive } from 'vue'
|
import { computed, reactive, ref, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
|
||||||
// ── resolve variant salvo no localStorage ───────────────────
|
// ── resolve variant salvo no localStorage ───────────────────
|
||||||
function _loadVariant () {
|
function _loadVariant () {
|
||||||
@@ -55,6 +55,14 @@ function syncDarkFromDomOnce () {
|
|||||||
} catch {}
|
} 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 () {
|
export function useLayout () {
|
||||||
// ✅ garante coerência sempre que alguém usar useLayout()
|
// ✅ garante coerência sempre que alguém usar useLayout()
|
||||||
syncDarkFromDomOnce()
|
syncDarkFromDomOnce()
|
||||||
@@ -82,13 +90,13 @@ export function useLayout () {
|
|||||||
const isRailMobile = () => window.innerWidth <= 1200
|
const isRailMobile = () => window.innerWidth <= 1200
|
||||||
|
|
||||||
const toggleMenu = () => {
|
const toggleMenu = () => {
|
||||||
// No Rail, o botão hamburguer (≤1200px) controla a sidebar mobile
|
// No Rail, em desktop, o botão hamburguer controla a sidebar mobile do rail
|
||||||
if (layoutConfig.variant === 'rail') {
|
if (layoutConfig.variant === 'rail' && !_isMobileRef.value) {
|
||||||
layoutState.mobileMenuActive = !layoutState.mobileMenuActive
|
layoutState.mobileMenuActive = !layoutState.mobileMenuActive
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Layout clássico — comportamento original
|
// Layout clássico (ou mobile com qualquer variant) — comportamento original
|
||||||
if (isDesktop()) {
|
if (isDesktop()) {
|
||||||
if (layoutConfig.menuMode === 'static') {
|
if (layoutConfig.menuMode === 'static') {
|
||||||
layoutState.staticMenuInactive = !layoutState.staticMenuInactive
|
layoutState.staticMenuInactive = !layoutState.staticMenuInactive
|
||||||
@@ -160,6 +168,13 @@ export function useLayout () {
|
|||||||
const isDarkTheme = computed(() => layoutConfig.darkTheme)
|
const isDarkTheme = computed(() => layoutConfig.darkTheme)
|
||||||
const hasOpenOverlay = computed(() => layoutState.overlayMenuActive)
|
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 {
|
return {
|
||||||
layoutConfig,
|
layoutConfig,
|
||||||
layoutState,
|
layoutState,
|
||||||
@@ -173,6 +188,8 @@ export function useLayout () {
|
|||||||
setVariant,
|
setVariant,
|
||||||
isDesktop,
|
isDesktop,
|
||||||
isRailMobile,
|
isRailMobile,
|
||||||
|
isMobile,
|
||||||
|
effectiveVariant,
|
||||||
hasOpenOverlay
|
hasOpenOverlay
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -267,94 +267,81 @@ const loading = computed(() => loadingF.value || loadingB.value)
|
|||||||
<template>
|
<template>
|
||||||
<Toast />
|
<Toast />
|
||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-3">
|
||||||
|
|
||||||
<!-- ── Cabeçalho do ano ─────────────────────────────────── -->
|
<!-- Subheader degradê -->
|
||||||
<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 class="cfg-subheader">
|
||||||
<div>
|
<div class="cfg-subheader__icon"><i class="pi pi-ban" /></div>
|
||||||
<div class="font-semibold text-base">Bloqueios da agenda</div>
|
<div class="min-w-0 flex-1">
|
||||||
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">
|
<div class="cfg-subheader__title">Bloqueios</div>
|
||||||
Feriados e períodos em que não é possível agendar com pacientes.
|
<div class="cfg-subheader__sub">Feriados e períodos em que não é possível agendar com pacientes</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<!-- Nav de ano -->
|
||||||
<Button icon="pi pi-chevron-left" text rounded severity="secondary" @click="anoAnterior" />
|
<div class="flex items-center gap-1 shrink-0 relative z-10">
|
||||||
<span class="font-bold text-lg w-14 text-center">{{ ano }}</span>
|
<Button icon="pi pi-chevron-left" text rounded size="small" severity="secondary" @click="anoAnterior" />
|
||||||
<Button icon="pi pi-chevron-right" text rounded severity="secondary" @click="anoProximo" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Stats rápidos ────────────────────────────────────── -->
|
<!-- Stats + ações -->
|
||||||
<div class="grid grid-cols-3 gap-3">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 text-center">
|
<div class="blk-stat blk-stat--blue">
|
||||||
<div class="text-2xl font-bold text-blue-500">{{ nacionais.length }}</div>
|
<div class="blk-stat__value">{{ nacionais.length }}</div>
|
||||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Feriados nacionais</div>
|
<div class="blk-stat__label">Nacionais</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 text-center">
|
<div class="blk-stat blk-stat--orange">
|
||||||
<div class="text-2xl font-bold text-orange-500">{{ municipais.length }}</div>
|
<div class="blk-stat__value">{{ municipais.length }}</div>
|
||||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Feriados municipais</div>
|
<div class="blk-stat__label">Municipais</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 text-center">
|
<div class="blk-stat blk-stat--red">
|
||||||
<div class="text-2xl font-bold text-red-500">{{ bloqueios.length }}</div>
|
<div class="blk-stat__value">{{ bloqueios.length }}</div>
|
||||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Bloqueios</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Ações ─────────────────────────────────────────────── -->
|
<!-- Loading -->
|
||||||
<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 ────────────────────────────────────────────── -->
|
|
||||||
<div v-if="loading" class="flex items-center justify-center py-16">
|
<div v-if="loading" class="flex items-center justify-center py-16">
|
||||||
<i class="pi pi-spinner pi-spin text-2xl opacity-40" />
|
<i class="pi pi-spinner pi-spin text-2xl opacity-40" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
|
||||||
<!-- ── Feriados Nacionais (somente leitura) ─────────────── -->
|
<!-- Feriados Nacionais -->
|
||||||
<div class="blk-group">
|
<div class="blk-group">
|
||||||
<div class="blk-group__head">
|
<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>Feriados Nacionais</span>
|
||||||
<span class="blk-group__count">{{ nacionais.length }}</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>
|
||||||
|
|
||||||
<div class="blk-list">
|
<div class="blk-list">
|
||||||
<div v-for="f in nacionais" :key="f.data + f.nome" class="blk-item">
|
<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__date">{{ fmtDateShort(f.data) }}</div>
|
||||||
<div class="blk-item__title">{{ f.nome }}</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Feriados Municipais ──────────────────────────────── -->
|
<!-- Feriados Municipais -->
|
||||||
<div class="blk-group">
|
<div class="blk-group">
|
||||||
<div class="blk-group__head">
|
<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>Feriados Municipais</span>
|
||||||
<span class="blk-group__count">{{ municipais.length }}</span>
|
<span class="blk-group__count">{{ municipais.length }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!municipais.length" class="blk-empty">
|
<div v-if="!municipais.length" class="blk-empty">
|
||||||
Nenhum feriado municipal cadastrado para {{ ano }}.
|
Nenhum feriado municipal cadastrado para {{ ano }}.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="blk-list">
|
<div v-else class="blk-list">
|
||||||
<div v-for="f in municipais" :key="f.id" class="blk-item">
|
<div v-for="f in municipais" :key="f.id" class="blk-item">
|
||||||
<div class="blk-item__date">{{ fmtDate(f.data) }}</div>
|
<div class="blk-item__date">{{ fmtDate(f.data) }}</div>
|
||||||
@@ -367,23 +354,23 @@ const loading = computed(() => loadingF.value || loadingB.value)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Bloqueios ─────────────────────────────────────────── -->
|
<!-- Bloqueios -->
|
||||||
<div class="blk-group">
|
<div class="blk-group">
|
||||||
<div class="blk-group__head">
|
<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>Bloqueios</span>
|
||||||
<span class="blk-group__count">{{ bloqueios.length }}</span>
|
<span class="blk-group__count">{{ bloqueios.length }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!bloqueios.length" class="blk-empty">
|
<div v-if="!bloqueios.length" class="blk-empty">
|
||||||
Nenhum bloqueio cadastrado para {{ ano }}.
|
Nenhum bloqueio cadastrado para {{ ano }}.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="blk-list">
|
<div v-else class="blk-list">
|
||||||
<div v-for="b in bloqueios" :key="b.id" class="blk-item">
|
<div v-for="b in bloqueios" :key="b.id" class="blk-item">
|
||||||
<div class="blk-item__date">{{ fmtPeriodo(b) }}</div>
|
<div class="blk-item__date">{{ fmtPeriodo(b) }}</div>
|
||||||
<div class="blk-item__title">{{ b.titulo }}</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 v-if="b.observacao" class="blk-item__obs">{{ b.observacao }}</div>
|
||||||
<div class="blk-item__actions">
|
<div class="blk-item__actions">
|
||||||
<Button icon="pi pi-pencil" text rounded size="small" severity="secondary" @click="abrirEditBloqueio(b)" />
|
<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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ══ Dialog feriado municipal ══════════════════════════════ -->
|
<!-- Dialog feriado municipal -->
|
||||||
<Dialog
|
<Dialog v-model:visible="fdlgOpen" modal :draggable="false" pt:mask:class="backdrop-blur-xs" header="Cadastrar feriado municipal" :style="{ width: '420px', maxWidth: '95vw' }">
|
||||||
v-model:visible="fdlgOpen"
|
|
||||||
modal
|
|
||||||
:draggable="false"
|
|
||||||
header="Cadastrar feriado municipal"
|
|
||||||
:style="{ width: '420px' }"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col gap-4 pt-1">
|
<div class="flex flex-col gap-4 pt-1">
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="blk-label">Nome do feriado *</label>
|
<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…" />
|
<InputText v-model="fform.nome" class="w-full mt-1" placeholder="Ex.: Aniversário da cidade, Padroeiro…" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="blk-label">Data *</label>
|
<label class="blk-label">Data *</label>
|
||||||
<DatePicker
|
<DatePicker v-model="fform.data" showIcon fluid iconDisplay="input" dateFormat="dd/mm/yy" :manualInput="false" class="mt-1">
|
||||||
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>
|
<template #inputicon="sp"><i class="pi pi-calendar" @click="sp.clickCallback" /></template>
|
||||||
</DatePicker>
|
</DatePicker>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="blk-label">Observação <span class="opacity-60">(opcional)</span></label>
|
<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…" />
|
<Textarea v-model="fform.observacao" class="w-full mt-1" rows="2" autoResize placeholder="Nota interna…" />
|
||||||
</div>
|
</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">
|
||||||
<div v-if="fform.data && fform.nome && isDuplicata(dateToISO(fform.data), fform.nome)"
|
<i class="pi pi-exclamation-triangle" /> Já existe um feriado com esse nome nessa data.
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<Button label="Cancelar" severity="secondary" outlined @click="fdlgOpen = false" />
|
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" @click="fdlgOpen = false" />
|
||||||
<Button
|
<Button label="Cadastrar" icon="pi pi-check" class="rounded-full"
|
||||||
label="Cadastrar"
|
|
||||||
icon="pi pi-check"
|
|
||||||
:disabled="!fformValid || (fform.data && fform.nome && isDuplicata(dateToISO(fform.data), fform.nome))"
|
:disabled="!fformValid || (fform.data && fform.nome && isDuplicata(dateToISO(fform.data), fform.nome))"
|
||||||
:loading="fsaving"
|
:loading="fsaving" @click="salvarFeriado" />
|
||||||
@click="salvarFeriado"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<!-- ══ Dialog bloqueio add/edit ══════════════════════════════ -->
|
<!-- Dialog bloqueio -->
|
||||||
<Dialog
|
<Dialog v-model:visible="dlgOpen" modal :draggable="false" pt:mask:class="backdrop-blur-xs"
|
||||||
v-model:visible="dlgOpen"
|
|
||||||
modal
|
|
||||||
:draggable="false"
|
|
||||||
:header="dlgMode === 'edit' ? 'Editar bloqueio' : 'Novo bloqueio'"
|
: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 class="flex flex-col gap-4 pt-1">
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="blk-label">Título *</label>
|
<label class="blk-label">Título *</label>
|
||||||
<InputText v-model="form.titulo" class="w-full mt-1" placeholder="Ex.: Recesso, Férias, Licença…" />
|
<InputText v-model="form.titulo" class="w-full mt-1" placeholder="Ex.: Recesso, Férias, Licença…" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<label class="blk-label">Data início *</label>
|
<label class="blk-label">Data início *</label>
|
||||||
<DatePicker
|
<DatePicker v-model="form.data_inicio" showIcon fluid iconDisplay="input" dateFormat="dd/mm/yy" :manualInput="false" class="mt-1">
|
||||||
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>
|
<template #inputicon="sp"><i class="pi pi-calendar" @click="sp.clickCallback" /></template>
|
||||||
</DatePicker>
|
</DatePicker>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<label class="blk-label">Data fim <span class="opacity-60">(opcional)</span></label>
|
<label class="blk-label">Data fim <span class="opacity-60">(opcional)</span></label>
|
||||||
<DatePicker
|
<DatePicker v-model="form.data_fim" showIcon fluid iconDisplay="input" dateFormat="dd/mm/yy" :manualInput="false" :minDate="form.data_inicio || undefined" class="mt-1">
|
||||||
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>
|
<template #inputicon="sp"><i class="pi pi-calendar" @click="sp.clickCallback" /></template>
|
||||||
</DatePicker>
|
</DatePicker>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<label class="blk-label">Hora início <span class="opacity-60">(opcional)</span></label>
|
<label class="blk-label">Hora início <span class="opacity-60">(opcional)</span></label>
|
||||||
<DatePicker
|
<DatePicker v-model="form.hora_inicio" showIcon fluid iconDisplay="input" timeOnly hourFormat="24" :stepMinute="15" :manualInput="false" class="mt-1">
|
||||||
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>
|
<template #inputicon="sp"><i class="pi pi-clock" @click="sp.clickCallback" /></template>
|
||||||
</DatePicker>
|
</DatePicker>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<label class="blk-label">Hora fim</label>
|
<label class="blk-label">Hora fim <span class="opacity-60">(opcional)</span></label>
|
||||||
<DatePicker
|
<DatePicker v-model="form.hora_fim" showIcon fluid iconDisplay="input" timeOnly hourFormat="24" :stepMinute="15" :manualInput="false" class="mt-1">
|
||||||
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>
|
<template #inputicon="sp"><i class="pi pi-clock" @click="sp.clickCallback" /></template>
|
||||||
</DatePicker>
|
</DatePicker>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="blk-label">Observação <span class="opacity-60">(opcional)</span></label>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<Button label="Cancelar" icon="pi pi-times" severity="secondary" outlined @click="dlgOpen = false" />
|
<Button label="Cancelar" icon="pi pi-times" severity="secondary" outlined class="rounded-full" @click="dlgOpen = false" />
|
||||||
<Button
|
<Button :label="dlgMode === 'edit' ? 'Salvar' : 'Adicionar'" icon="pi pi-check" class="rounded-full"
|
||||||
:label="dlgMode === 'edit' ? 'Salvar' : 'Adicionar'"
|
:disabled="!formValid" :loading="saving" @click="salvarBloqueio" />
|
||||||
icon="pi pi-check"
|
|
||||||
:disabled="!formValid"
|
|
||||||
:loading="saving"
|
|
||||||
@click="salvarBloqueio"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.blk-group {
|
||||||
border-radius: 1.25rem;
|
border-radius: 6px;
|
||||||
border: 1px solid var(--surface-border);
|
border: 1px solid var(--surface-border);
|
||||||
background: var(--surface-card);
|
background: var(--surface-card);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.blk-group__head {
|
.blk-group__head {
|
||||||
display: flex;
|
display: flex; align-items: center; gap: 0.5rem;
|
||||||
align-items: center;
|
padding: 0.75rem 1rem;
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.875rem 1.25rem;
|
|
||||||
border-bottom: 1px solid var(--surface-border);
|
border-bottom: 1px solid var(--surface-border);
|
||||||
font-weight: 600;
|
font-weight: 600; font-size: 0.88rem;
|
||||||
font-size: 0.9rem;
|
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 {
|
.blk-group__count {
|
||||||
font-size: 0.75rem;
|
font-size: 0.7rem;
|
||||||
background: var(--surface-ground);
|
background: var(--surface-card);
|
||||||
border: 1px solid var(--surface-border);
|
border: 1px solid var(--surface-border);
|
||||||
border-radius: 999px;
|
border-radius: 999px; padding: 1px 8px;
|
||||||
padding: 1px 8px;
|
|
||||||
color: var(--text-color-secondary);
|
color: var(--text-color-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Itens ───────────────────────────────────────────────── */
|
/* ── Itens ──────────────────────────────────────── */
|
||||||
.blk-empty {
|
.blk-empty {
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: var(--text-color-secondary);
|
color: var(--text-color-secondary);
|
||||||
}
|
}
|
||||||
.blk-list {
|
.blk-list { display: flex; flex-direction: column; }
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.blk-item {
|
.blk-item {
|
||||||
display: flex;
|
display: flex; align-items: center; gap: 0.75rem;
|
||||||
align-items: center;
|
padding: 0.625rem 1rem;
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.75rem 1.25rem;
|
|
||||||
border-bottom: 1px solid var(--surface-border);
|
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:last-child { border-bottom: none; }
|
||||||
.blk-item:hover { background: var(--surface-hover); }
|
.blk-item:hover { background: var(--surface-hover); }
|
||||||
|
|
||||||
.blk-item__date {
|
.blk-item__date {
|
||||||
font-size: 0.8rem;
|
font-size: 0.75rem; color: var(--text-color-secondary);
|
||||||
color: var(--text-color-secondary);
|
white-space: nowrap; min-width: 5.5rem;
|
||||||
white-space: nowrap;
|
|
||||||
min-width: 5.5rem;
|
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
.blk-item__title {
|
.blk-item__title { flex: 1; font-weight: 500; font-size: 0.85rem; min-width: 0; }
|
||||||
flex: 1;
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.blk-item__obs {
|
.blk-item__obs {
|
||||||
font-size: 0.75rem;
|
font-size: 0.72rem; color: var(--text-color-secondary);
|
||||||
color: var(--text-color-secondary);
|
width: 100%; padding-left: 6.25rem; margin-top: -0.25rem;
|
||||||
width: 100%;
|
|
||||||
padding-left: 6.25rem;
|
|
||||||
margin-top: -0.25rem;
|
|
||||||
}
|
|
||||||
.blk-item__actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.25rem;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
}
|
||||||
|
.blk-item__actions { display: flex; gap: 0.25rem; margin-left: auto; }
|
||||||
|
|
||||||
/* ── Dialog ──────────────────────────────────────────────── */
|
/* ── Dialog labels ──────────────────────────────── */
|
||||||
.blk-label {
|
.blk-label { font-size: 0.75rem; color: var(--text-color-secondary); font-weight: 500; }
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--text-color-secondary);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
@@ -796,6 +796,15 @@ const jornadaEndDate = computed({
|
|||||||
<!-- ══ COLUNA ESQUERDA: CARDS ══════════════════════════════ -->
|
<!-- ══ COLUNA ESQUERDA: CARDS ══════════════════════════════ -->
|
||||||
<div class="flex flex-col gap-3 xl:w-[58%]">
|
<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 ─────────────────────────────────── -->
|
<!-- ── CARD 1: JORNADA ─────────────────────────────────── -->
|
||||||
<div class="cfg-card" :class="{ 'cfg-card--open': expandedCard === 'jornada' }">
|
<div class="cfg-card" :class="{ 'cfg-card--open': expandedCard === 'jornada' }">
|
||||||
|
|
||||||
@@ -952,7 +961,7 @@ const jornadaEndDate = computed({
|
|||||||
<div
|
<div
|
||||||
v-for="d in selectedDays"
|
v-for="d in selectedDays"
|
||||||
:key="d.value"
|
: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>
|
<span class="w-10 text-sm font-medium">{{ d.short }}</span>
|
||||||
<div class="w-32">
|
<div class="w-32">
|
||||||
@@ -1060,7 +1069,7 @@ const jornadaEndDate = computed({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Campos manuais (personalizado) -->
|
<!-- 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="cfg-label mb-3">Personalizado</div>
|
||||||
<div class="flex flex-row gap-6">
|
<div class="flex flex-row gap-6">
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
@@ -1121,7 +1130,7 @@ const jornadaEndDate = computed({
|
|||||||
<div class="border-t border-[var(--surface-border)] pt-4">
|
<div class="border-t border-[var(--surface-border)] pt-4">
|
||||||
|
|
||||||
<!-- Aviso slots órfãos -->
|
<!-- 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" />
|
<i class="pi pi-exclamation-triangle mt-0.5 shrink-0" />
|
||||||
<span>
|
<span>
|
||||||
Há slots configurados para <b>{{ orphanSlotDays.join(', ') }}</b>, mas esses dias não estão mais na sua jornada.
|
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>
|
</div>
|
||||||
|
|
||||||
<!-- Toggle ativo -->
|
<!-- 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>
|
||||||
<div class="font-medium">Permitir que pacientes agendem online</div>
|
<div class="font-medium">Permitir que pacientes agendem online</div>
|
||||||
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">
|
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">
|
||||||
@@ -1172,7 +1181,7 @@ const jornadaEndDate = computed({
|
|||||||
<template v-if="previewDay != null">
|
<template v-if="previewDay != null">
|
||||||
|
|
||||||
<!-- Área cinza: ações rápidas + slots -->
|
<!-- Á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">
|
<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.
|
Nenhum slot disponível para este dia. Configure a jornada primeiro.
|
||||||
</div>
|
</div>
|
||||||
@@ -1242,7 +1251,7 @@ const jornadaEndDate = computed({
|
|||||||
|
|
||||||
<!-- ══ COLUNA DIREITA: PREVIEW ═════════════════════════════ -->
|
<!-- ══ COLUNA DIREITA: PREVIEW ═════════════════════════════ -->
|
||||||
<div class="xl:w-[42%] xl:sticky xl:top-4 xl:self-start">
|
<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 -->
|
<!-- Header do preview -->
|
||||||
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border)]">
|
<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>
|
<style scoped>
|
||||||
/* ── Cards ─────────────────────────────────────────────────── */
|
/* ── Cards ─────────────────────────────────────────────────── */
|
||||||
.cfg-card {
|
.cfg-card {
|
||||||
border-radius: 1.25rem;
|
border-radius: 6px;
|
||||||
border: 1px solid var(--surface-border);
|
border: 1px solid var(--surface-border);
|
||||||
background: var(--surface-card);
|
background: var(--surface-card);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -1508,4 +1517,52 @@ const jornadaEndDate = computed({
|
|||||||
.toggle-switch--on .toggle-switch__thumb {
|
.toggle-switch--on .toggle-switch__thumb {
|
||||||
transform: translateX(1.25rem);
|
transform: translateX(1.25rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Subheader de seção ──────────────────────────────── */
|
||||||
|
.cfg-subheader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--primary-color, #6366f1) 30%, transparent);
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
color-mix(in srgb, var(--primary-color, #6366f1) 12%, var(--surface-card)) 0%,
|
||||||
|
color-mix(in srgb, var(--primary-color, #6366f1) 4%, var(--surface-card)) 60%,
|
||||||
|
var(--surface-card) 100%
|
||||||
|
);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
/* Brilho sutil no canto */
|
||||||
|
.cfg-subheader::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -20px; right: -20px;
|
||||||
|
width: 80px; height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: color-mix(in srgb, var(--primary-color, #6366f1) 15%, transparent);
|
||||||
|
filter: blur(20px);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.cfg-subheader__icon {
|
||||||
|
display: grid; place-items: center;
|
||||||
|
width: 2rem; height: 2rem;
|
||||||
|
border-radius: 6px; flex-shrink: 0;
|
||||||
|
background: color-mix(in srgb, var(--primary-color, #6366f1) 20%, transparent);
|
||||||
|
color: var(--primary-color, #6366f1);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.cfg-subheader__title {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--primary-color, #6366f1);
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.cfg-subheader__sub {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -17,7 +17,7 @@ const hasLinkPersonalizado = computed(() => entitlements.can('agendador.link_per
|
|||||||
// ── Estado ─────────────────────────────────────────────────────
|
// ── Estado ─────────────────────────────────────────────────────
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const ownerId = ref(null)
|
const ownerId = ref(null)
|
||||||
const expandedCard = ref(null)
|
const expandedCard = ref(new Set())
|
||||||
const savingCard = ref(null)
|
const savingCard = ref(null)
|
||||||
|
|
||||||
// ── Upload de imagens ────────────────────────────────────────────
|
// ── Upload de imagens ────────────────────────────────────────────
|
||||||
@@ -62,8 +62,8 @@ async function onFileSelected (event, field) {
|
|||||||
|
|
||||||
// ── Expand / Collapse all ────────────────────────────────────────
|
// ── Expand / Collapse all ────────────────────────────────────────
|
||||||
const CARDS = ['identidade', 'perfil', 'fluxo', 'pagamento', 'triagem', 'textos']
|
const CARDS = ['identidade', 'perfil', 'fluxo', 'pagamento', 'triagem', 'textos']
|
||||||
function expandAll () { expandedCard.value = CARDS[0] } // abre o primeiro como ponto de entrada
|
function expandAll () { expandedCard.value = new Set(CARDS) }
|
||||||
function collapseAll () { expandedCard.value = null }
|
function collapseAll () { expandedCard.value = new Set() }
|
||||||
|
|
||||||
// ── Defaults ───────────────────────────────────────────────────
|
// ── Defaults ───────────────────────────────────────────────────
|
||||||
const DEFAULT_CFG = {
|
const DEFAULT_CFG = {
|
||||||
@@ -405,7 +405,7 @@ async function saveCard (cardKey) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
toast.add({ severity: 'success', summary: 'Salvo', life: 2500 })
|
toast.add({ severity: 'success', summary: 'Salvo', life: 2500 })
|
||||||
expandedCard.value = null
|
expandedCard.value = new Set()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 4000 })
|
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 4000 })
|
||||||
} finally {
|
} finally {
|
||||||
@@ -474,7 +474,10 @@ function buildPayload (cardKey) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleCard (key) {
|
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)
|
onMounted(load)
|
||||||
@@ -490,43 +493,27 @@ onMounted(load)
|
|||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
|
||||||
<!-- ── HEADER SECUNDÁRIO ─────────────────────────────────── -->
|
<!-- Subheader -->
|
||||||
<div class="flex items-center justify-between gap-3 px-1">
|
<div class="cfg-subheader">
|
||||||
<div>
|
<div class="cfg-subheader__icon"><i class="pi pi-calendar-clock" /></div>
|
||||||
<div class="text-base font-semibold">Configurações do Agendador</div>
|
<div class="min-w-0">
|
||||||
<div class="text-xs text-surface-400 mt-0.5">Personalize a aparência, fluxo e comportamento do seu agendador público.</div>
|
<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>
|
||||||
<div class="flex items-center gap-2 shrink-0">
|
<div class="cfg-subheader__actions">
|
||||||
<Button
|
<Button size="small" icon="pi pi-arrows-v" label="Expandir" severity="secondary" outlined class="rounded-full" @click="expandAll" />
|
||||||
size="small"
|
<Button size="small" icon="pi pi-minus" label="Contrair" severity="secondary" outlined class="rounded-full" @click="collapseAll" />
|
||||||
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── CARD: STATUS / ATIVAR ──────────────────────────────── -->
|
<!-- ── CARD: STATUS / ATIVAR ──────────────────────────────── -->
|
||||||
<Card>
|
<div class="agd-card">
|
||||||
<template #content>
|
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
|
|
||||||
<!-- Cabeçalho PRO -->
|
<!-- Cabeçalho PRO -->
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex items-start justify-between gap-4">
|
||||||
<div class="flex items-center gap-3">
|
<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'">
|
: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" />
|
<i class="pi pi-calendar-clock text-xl" />
|
||||||
</div>
|
</div>
|
||||||
@@ -586,9 +573,9 @@ onMounted(load)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Link personalizado bloqueado -->
|
<!-- 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">
|
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" />
|
<i class="pi pi-lock text-base" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<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.
|
Você controla quem pode agendar e quais horários ficam disponíveis.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- ── CARD: IDENTIDADE VISUAL ────────────────────────────── -->
|
<!-- ── CARD: IDENTIDADE VISUAL ────────────────────────────── -->
|
||||||
<Card class="overflow-hidden">
|
<div class="agd-accordion" :class="{ 'agd-accordion--open': expandedCard.has('identidade') }">
|
||||||
<template #content>
|
<button
|
||||||
<!-- Cabeçalho do card -->
|
type="button"
|
||||||
<button
|
class="agd-accordion__header"
|
||||||
type="button"
|
@click="toggleCard('identidade')"
|
||||||
class="w-full flex items-center justify-between gap-3 text-left"
|
>
|
||||||
@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 class="flex items-center gap-3">
|
</div>
|
||||||
<div class="grid place-items-center w-9 h-9 rounded-xl bg-purple-100 dark:bg-purple-900/30 text-purple-600 shrink-0">
|
<div class="min-w-0 flex-1 text-left">
|
||||||
<i class="pi pi-palette" />
|
<div class="agd-accordion__title">Identidade Visual</div>
|
||||||
</div>
|
<div v-if="!expandedCard.has('identidade')" class="agd-accordion__summary">{{ resumoIdentidade }}</div>
|
||||||
<div>
|
</div>
|
||||||
<div class="font-semibold leading-none">Identidade Visual</div>
|
<i class="pi agd-accordion__chevron"
|
||||||
<div v-if="expandedCard !== 'identidade'" class="text-xs text-surface-400 mt-1">{{ resumoIdentidade }}</div>
|
:class="expandedCard.has('identidade') ? 'pi-chevron-up' : 'pi-chevron-down'" />
|
||||||
</div>
|
</button>
|
||||||
</div>
|
|
||||||
<i class="pi transition-transform duration-200 text-surface-400 shrink-0"
|
|
||||||
:class="expandedCard === 'identidade' ? 'pi-angle-up' : 'pi-angle-down'" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Conteúdo expandido -->
|
<div v-if="expandedCard.has('identidade')" class="agd-accordion__body">
|
||||||
<template v-if="expandedCard === 'identidade'">
|
|
||||||
<Divider />
|
|
||||||
<div class="flex flex-col gap-5">
|
<div class="flex flex-col gap-5">
|
||||||
|
|
||||||
<!-- Nome de exibição (aqui pois é parte da identidade) -->
|
<!-- Nome de exibição (aqui pois é parte da identidade) -->
|
||||||
@@ -660,7 +640,7 @@ onMounted(load)
|
|||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<ColorPicker v-model="cfg.cor_primaria" format="hex" />
|
<ColorPicker v-model="cfg.cor_primaria" format="hex" />
|
||||||
<InputText v-model="cfg.cor_primaria" placeholder="#4b6bff" class="w-32 font-mono" maxlength="7" />
|
<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 }" />
|
:style="{ background: cfg.cor_primaria }" />
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-surface-400 mt-1">Botões e destaques do agendador.</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')" />
|
@change="e => onFileSelected(e, 'header')" />
|
||||||
</div>
|
</div>
|
||||||
<InputText v-model="cfg.imagem_header_url" placeholder="ou cole uma URL pública..." class="w-full text-xs" />
|
<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" />
|
<img :src="cfg.imagem_header_url" alt="Header" class="w-full h-full object-cover" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -728,7 +708,7 @@ onMounted(load)
|
|||||||
@change="e => onFileSelected(e, 'fundo')" />
|
@change="e => onFileSelected(e, 'fundo')" />
|
||||||
</div>
|
</div>
|
||||||
<InputText v-model="cfg.imagem_fundo_url" placeholder="ou cole uma URL pública..." class="w-full text-xs" />
|
<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" />
|
<img :src="cfg.imagem_fundo_url" alt="Fundo" class="w-full h-full object-cover" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -744,33 +724,28 @@ onMounted(load)
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- ── CARD: PERFIL PÚBLICO ───────────────────────────────── -->
|
<!-- ── CARD: PERFIL PÚBLICO ───────────────────────────────── -->
|
||||||
<Card>
|
<div class="agd-accordion" :class="{ 'agd-accordion--open': expandedCard.has('perfil') }">
|
||||||
<template #content>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
class="agd-accordion__header"
|
||||||
class="w-full flex items-center justify-between gap-3 text-left"
|
@click="toggleCard('perfil')"
|
||||||
@click="toggleCard('perfil')"
|
>
|
||||||
>
|
<div class="agd-accordion__icon bg-blue-100 dark:bg-blue-900/30 text-blue-600">
|
||||||
<div class="flex items-center gap-3">
|
<i class="pi pi-map-marker" />
|
||||||
<div class="grid place-items-center w-9 h-9 rounded-xl bg-blue-100 dark:bg-blue-900/30 text-blue-600 shrink-0">
|
</div>
|
||||||
<i class="pi pi-map-marker" />
|
<div class="min-w-0 flex-1 text-left">
|
||||||
</div>
|
<div class="agd-accordion__title">Perfil Público</div>
|
||||||
<div>
|
<div v-if="!expandedCard.has('perfil')" class="agd-accordion__summary">{{ resumoPerfil }}</div>
|
||||||
<div class="font-semibold leading-none">Perfil Público</div>
|
</div>
|
||||||
<div v-if="expandedCard !== 'perfil'" class="text-xs text-surface-400 mt-1">{{ resumoPerfil }}</div>
|
<i class="pi agd-accordion__chevron"
|
||||||
</div>
|
:class="expandedCard.has('perfil') ? 'pi-chevron-up' : 'pi-chevron-down'" />
|
||||||
</div>
|
</button>
|
||||||
<i class="pi transition-transform duration-200 text-surface-400 shrink-0"
|
|
||||||
:class="expandedCard === 'perfil' ? 'pi-angle-up' : 'pi-angle-down'" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<template v-if="expandedCard === 'perfil'">
|
<div v-if="expandedCard.has('perfil')" class="agd-accordion__body">
|
||||||
<Divider />
|
|
||||||
<div class="flex flex-col gap-5">
|
<div class="flex flex-col gap-5">
|
||||||
|
|
||||||
<!-- Endereço -->
|
<!-- Endereço -->
|
||||||
@@ -780,7 +755,7 @@ onMounted(load)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Botão Como Chegar -->
|
<!-- 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>
|
||||||
<div class="font-medium text-sm">Botão "Como chegar"</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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- ── CARD: FLUXO DE AGENDAMENTO ─────────────────────────── -->
|
<!-- ── CARD: FLUXO DE AGENDAMENTO ─────────────────────────── -->
|
||||||
<Card>
|
<div class="agd-accordion" :class="{ 'agd-accordion--open': expandedCard.has('fluxo') }">
|
||||||
<template #content>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
class="agd-accordion__header"
|
||||||
class="w-full flex items-center justify-between gap-3 text-left"
|
@click="toggleCard('fluxo')"
|
||||||
@click="toggleCard('fluxo')"
|
>
|
||||||
>
|
<div class="agd-accordion__icon bg-cyan-100 dark:bg-cyan-900/30 text-cyan-600">
|
||||||
<div class="flex items-center gap-3">
|
<i class="pi pi-sitemap" />
|
||||||
<div class="grid place-items-center w-9 h-9 rounded-xl bg-cyan-100 dark:bg-cyan-900/30 text-cyan-600 shrink-0">
|
</div>
|
||||||
<i class="pi pi-sitemap" />
|
<div class="min-w-0 flex-1 text-left">
|
||||||
</div>
|
<div class="agd-accordion__title">Fluxo de Agendamento</div>
|
||||||
<div>
|
<div v-if="!expandedCard.has('fluxo')" class="agd-accordion__summary">{{ resumoFluxo }}</div>
|
||||||
<div class="font-semibold leading-none">Fluxo de Agendamento</div>
|
</div>
|
||||||
<div v-if="expandedCard !== 'fluxo'" class="text-xs text-surface-400 mt-1">{{ resumoFluxo }}</div>
|
<i class="pi agd-accordion__chevron"
|
||||||
</div>
|
:class="expandedCard.has('fluxo') ? 'pi-chevron-up' : 'pi-chevron-down'" />
|
||||||
</div>
|
</button>
|
||||||
<i class="pi transition-transform duration-200 text-surface-400 shrink-0"
|
|
||||||
:class="expandedCard === 'fluxo' ? 'pi-angle-up' : 'pi-angle-down'" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<template v-if="expandedCard === 'fluxo'">
|
<div v-if="expandedCard.has('fluxo')" class="agd-accordion__body">
|
||||||
<Divider />
|
|
||||||
<div class="flex flex-col gap-5">
|
<div class="flex flex-col gap-5">
|
||||||
|
|
||||||
<!-- Modo de aprovação -->
|
<!-- Modo de aprovação -->
|
||||||
@@ -840,7 +810,7 @@ onMounted(load)
|
|||||||
<div
|
<div
|
||||||
v-for="opt in modoOptions"
|
v-for="opt in modoOptions"
|
||||||
:key="opt.value"
|
: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
|
:class="cfg.modo_aprovacao === opt.value
|
||||||
? 'border-primary bg-primary/5 dark:bg-primary/10'
|
? 'border-primary bg-primary/5 dark:bg-primary/10'
|
||||||
: 'border-surface-200 dark:border-surface-700 hover:border-surface-300'"
|
: 'border-surface-200 dark:border-surface-700 hover:border-surface-300'"
|
||||||
@@ -957,33 +927,28 @@ onMounted(load)
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- ── CARD: PAGAMENTO ────────────────────────────────────── -->
|
<!-- ── CARD: PAGAMENTO ────────────────────────────────────── -->
|
||||||
<Card>
|
<div class="agd-accordion" :class="{ 'agd-accordion--open': expandedCard.has('pagamento') }">
|
||||||
<template #content>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
class="agd-accordion__header"
|
||||||
class="w-full flex items-center justify-between gap-3 text-left"
|
@click="toggleCard('pagamento')"
|
||||||
@click="toggleCard('pagamento')"
|
>
|
||||||
>
|
<div class="agd-accordion__icon bg-green-100 dark:bg-green-900/30 text-green-600">
|
||||||
<div class="flex items-center gap-3">
|
<i class="pi pi-credit-card" />
|
||||||
<div class="grid place-items-center w-9 h-9 rounded-xl bg-green-100 dark:bg-green-900/30 text-green-600 shrink-0">
|
</div>
|
||||||
<i class="pi pi-credit-card" />
|
<div class="min-w-0 flex-1 text-left">
|
||||||
</div>
|
<div class="agd-accordion__title">Pagamento</div>
|
||||||
<div>
|
<div v-if="!expandedCard.has('pagamento')" class="agd-accordion__summary">{{ resumoPagamento }}</div>
|
||||||
<div class="font-semibold leading-none">Pagamento</div>
|
</div>
|
||||||
<div v-if="expandedCard !== 'pagamento'" class="text-xs text-surface-400 mt-1">{{ resumoPagamento }}</div>
|
<i class="pi agd-accordion__chevron"
|
||||||
</div>
|
:class="expandedCard.has('pagamento') ? 'pi-chevron-up' : 'pi-chevron-down'" />
|
||||||
</div>
|
</button>
|
||||||
<i class="pi transition-transform duration-200 text-surface-400 shrink-0"
|
|
||||||
:class="expandedCard === 'pagamento' ? 'pi-angle-up' : 'pi-angle-down'" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<template v-if="expandedCard === 'pagamento'">
|
<div v-if="expandedCard.has('pagamento')" class="agd-accordion__body">
|
||||||
<Divider />
|
|
||||||
<div class="flex flex-col gap-5">
|
<div class="flex flex-col gap-5">
|
||||||
|
|
||||||
<!-- Modo de pagamento -->
|
<!-- Modo de pagamento -->
|
||||||
@@ -994,13 +959,13 @@ onMounted(load)
|
|||||||
v-for="modo in modosPagamento"
|
v-for="modo in modosPagamento"
|
||||||
:key="modo.value"
|
:key="modo.value"
|
||||||
type="button"
|
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
|
:class="cfg.pagamento_modo === modo.value
|
||||||
? 'border-primary bg-primary/5 ring-1 ring-primary/30'
|
? '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'"
|
: '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"
|
@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'">
|
: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]" />
|
<i :class="['pi', modo.icon]" />
|
||||||
</div>
|
</div>
|
||||||
@@ -1023,7 +988,7 @@ onMounted(load)
|
|||||||
<RouterLink to="/configuracoes/pagamento" class="underline">Configurações › Pagamento</RouterLink>.
|
<RouterLink to="/configuracoes/pagamento" class="underline">Configurações › Pagamento</RouterLink>.
|
||||||
</p>
|
</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" />
|
<i class="pi pi-exclamation-triangle mr-1" />
|
||||||
Nenhuma forma de pagamento configurada ainda.
|
Nenhuma forma de pagamento configurada ainda.
|
||||||
<RouterLink to="/configuracoes/pagamento" class="underline font-medium ml-1">Configurar agora</RouterLink>
|
<RouterLink to="/configuracoes/pagamento" class="underline font-medium ml-1">Configurar agora</RouterLink>
|
||||||
@@ -1033,7 +998,7 @@ onMounted(load)
|
|||||||
<label
|
<label
|
||||||
v-for="m in metodosDisponiveis"
|
v-for="m in metodosDisponiveis"
|
||||||
:key="m.key"
|
: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="[
|
:class="[
|
||||||
!m.ativo ? 'opacity-40 cursor-not-allowed border-surface-border bg-surface-50 dark:bg-surface-800' :
|
!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'
|
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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- ── CARD: TRIAGEM & CONFORMIDADE ───────────────────────── -->
|
<!-- ── CARD: TRIAGEM & CONFORMIDADE ───────────────────────── -->
|
||||||
<Card>
|
<div class="agd-accordion" :class="{ 'agd-accordion--open': expandedCard.has('triagem') }">
|
||||||
<template #content>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
class="agd-accordion__header"
|
||||||
class="w-full flex items-center justify-between gap-3 text-left"
|
@click="toggleCard('triagem')"
|
||||||
@click="toggleCard('triagem')"
|
>
|
||||||
>
|
<div class="agd-accordion__icon bg-orange-100 dark:bg-orange-900/30 text-orange-600">
|
||||||
<div class="flex items-center gap-3">
|
<i class="pi pi-shield" />
|
||||||
<div class="grid place-items-center w-9 h-9 rounded-xl bg-orange-100 dark:bg-orange-900/30 text-orange-600 shrink-0">
|
</div>
|
||||||
<i class="pi pi-shield" />
|
<div class="min-w-0 flex-1 text-left">
|
||||||
</div>
|
<div class="agd-accordion__title">Triagem & Conformidade</div>
|
||||||
<div>
|
<div v-if="!expandedCard.has('triagem')" class="agd-accordion__summary">{{ resumoTriagem }}</div>
|
||||||
<div class="font-semibold leading-none">Triagem & Conformidade</div>
|
</div>
|
||||||
<div v-if="expandedCard !== 'triagem'" class="text-xs text-surface-400 mt-1">{{ resumoTriagem }}</div>
|
<i class="pi agd-accordion__chevron"
|
||||||
</div>
|
:class="expandedCard.has('triagem') ? 'pi-chevron-up' : 'pi-chevron-down'" />
|
||||||
</div>
|
</button>
|
||||||
<i class="pi transition-transform duration-200 text-surface-400 shrink-0"
|
|
||||||
:class="expandedCard === 'triagem' ? 'pi-angle-up' : 'pi-angle-down'" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<template v-if="expandedCard === 'triagem'">
|
<div v-if="expandedCard.has('triagem')" class="agd-accordion__body">
|
||||||
<Divider />
|
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
|
|
||||||
<div class="text-sm font-semibold text-surface-600">Campos extras no formulário</div>
|
<div class="text-sm font-semibold text-surface-600">Campos extras no formulário</div>
|
||||||
|
|
||||||
<!-- Triagem: motivo -->
|
<!-- 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>
|
||||||
<div class="font-medium text-sm">Motivo da consulta</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>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Triagem: como conheceu -->
|
<!-- 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>
|
||||||
<div class="font-medium text-sm">Como nos conheceu?</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>
|
<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>
|
<div class="text-sm font-semibold text-surface-600">Segurança & LGPD</div>
|
||||||
|
|
||||||
<!-- Verificação de email -->
|
<!-- 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>
|
||||||
<div class="font-medium text-sm">Verificação de e-mail</div>
|
<div class="font-medium text-sm">Verificação de e-mail</div>
|
||||||
<div class="text-xs text-surface-400 mt-0.5">
|
<div class="text-xs text-surface-400 mt-0.5">
|
||||||
@@ -1190,7 +1150,7 @@ onMounted(load)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Aceite LGPD -->
|
<!-- 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>
|
||||||
<div class="font-medium text-sm">Aceite obrigatório de termos (LGPD)</div>
|
<div class="font-medium text-sm">Aceite obrigatório de termos (LGPD)</div>
|
||||||
<div class="text-xs text-surface-400 mt-0.5">
|
<div class="text-xs text-surface-400 mt-0.5">
|
||||||
@@ -1209,33 +1169,28 @@ onMounted(load)
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- ── CARD: TEXTOS DA JORNADA ────────────────────────────── -->
|
<!-- ── CARD: TEXTOS DA JORNADA ────────────────────────────── -->
|
||||||
<Card>
|
<div class="agd-accordion" :class="{ 'agd-accordion--open': expandedCard.has('textos') }">
|
||||||
<template #content>
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
class="agd-accordion__header"
|
||||||
class="w-full flex items-center justify-between gap-3 text-left"
|
@click="toggleCard('textos')"
|
||||||
@click="toggleCard('textos')"
|
>
|
||||||
>
|
<div class="agd-accordion__icon bg-pink-100 dark:bg-pink-900/30 text-pink-600">
|
||||||
<div class="flex items-center gap-3">
|
<i class="pi pi-file-edit" />
|
||||||
<div class="grid place-items-center w-9 h-9 rounded-xl bg-pink-100 dark:bg-pink-900/30 text-pink-600 shrink-0">
|
</div>
|
||||||
<i class="pi pi-file-edit" />
|
<div class="min-w-0 flex-1 text-left">
|
||||||
</div>
|
<div class="agd-accordion__title">Textos da Jornada</div>
|
||||||
<div>
|
<div v-if="!expandedCard.has('textos')" class="agd-accordion__summary">{{ resumoTextos }}</div>
|
||||||
<div class="font-semibold leading-none">Textos da Jornada</div>
|
</div>
|
||||||
<div v-if="expandedCard !== 'textos'" class="text-xs text-surface-400 mt-1">{{ resumoTextos }}</div>
|
<i class="pi agd-accordion__chevron"
|
||||||
</div>
|
:class="expandedCard.has('textos') ? 'pi-chevron-up' : 'pi-chevron-down'" />
|
||||||
</div>
|
</button>
|
||||||
<i class="pi transition-transform duration-200 text-surface-400 shrink-0"
|
|
||||||
:class="expandedCard === 'textos' ? 'pi-angle-up' : 'pi-angle-down'" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<template v-if="expandedCard === 'textos'">
|
<div v-if="expandedCard.has('textos')" class="agd-accordion__body">
|
||||||
<Divider />
|
|
||||||
<div class="flex flex-col gap-5">
|
<div class="flex flex-col gap-5">
|
||||||
|
|
||||||
<!-- Mensagem de boas-vindas -->
|
<!-- Mensagem de boas-vindas -->
|
||||||
@@ -1289,28 +1244,119 @@ onMounted(load)
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</Card>
|
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* ── Upload zone ──────────────────────────────────── */
|
||||||
.agd-upload-zone {
|
.agd-upload-zone {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.625rem;
|
gap: 0.625rem;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border: 1.5px dashed var(--surface-border);
|
border: 1.5px dashed var(--surface-border);
|
||||||
border-radius: 0.875rem;
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: border-color 0.15s, background 0.15s;
|
transition: border-color 0.15s, background 0.15s;
|
||||||
background: var(--surface-ground);
|
background: var(--surface-ground);
|
||||||
}
|
}
|
||||||
.agd-upload-zone:hover {
|
.agd-upload-zone:hover {
|
||||||
border-color: var(--p-primary-500, #6366f1);
|
border-color: var(--primary-color, #6366f1);
|
||||||
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 5%, transparent);
|
background: color-mix(in srgb, var(--primary-color, #6366f1) 5%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Subheader degradê ────────────────────────────── */
|
||||||
|
.cfg-subheader {
|
||||||
|
display: flex; align-items: center; gap: 0.65rem;
|
||||||
|
padding: 0.875rem 1rem; border-radius: 6px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--primary-color, #6366f1) 30%, transparent);
|
||||||
|
background: linear-gradient(135deg,
|
||||||
|
color-mix(in srgb, var(--primary-color, #6366f1) 12%, var(--surface-card)) 0%,
|
||||||
|
color-mix(in srgb, var(--primary-color, #6366f1) 4%, var(--surface-card)) 60%,
|
||||||
|
var(--surface-card) 100%);
|
||||||
|
position: relative; overflow: hidden;
|
||||||
|
}
|
||||||
|
.cfg-subheader::before {
|
||||||
|
content: ''; position: absolute;
|
||||||
|
top: -20px; right: -20px;
|
||||||
|
width: 80px; height: 80px; border-radius: 50%;
|
||||||
|
background: color-mix(in srgb, var(--primary-color, #6366f1) 15%, transparent);
|
||||||
|
filter: blur(20px); pointer-events: none;
|
||||||
|
}
|
||||||
|
.cfg-subheader__icon {
|
||||||
|
display: grid; place-items: center;
|
||||||
|
width: 2rem; height: 2rem; border-radius: 6px; flex-shrink: 0;
|
||||||
|
background: color-mix(in srgb, var(--primary-color, #6366f1) 20%, transparent);
|
||||||
|
color: var(--primary-color, #6366f1); font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.cfg-subheader__title { font-size: 0.95rem; font-weight: 700; letter-spacing: -0.01em; color: var(--primary-color, #6366f1); }
|
||||||
|
.cfg-subheader__sub { font-size: 0.75rem; color: var(--text-color-secondary); opacity: 0.85; }
|
||||||
|
.cfg-subheader__actions { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; flex-shrink: 0; position: relative; z-index: 1; }
|
||||||
|
|
||||||
|
/* ── Card status (sem accordion) ─────────────────── */
|
||||||
|
.agd-card {
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface-card);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Accordion cards ──────────────────────────────── */
|
||||||
|
.agd-accordion {
|
||||||
|
border: 1px solid var(--surface-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface-card);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.agd-accordion--open {
|
||||||
|
border-color: color-mix(in srgb, var(--primary-color, #6366f1) 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agd-accordion__header {
|
||||||
|
display: flex; align-items: center; gap: 0.75rem;
|
||||||
|
width: 100%; padding: 0.875rem 1rem;
|
||||||
|
background: transparent; border: none; cursor: pointer;
|
||||||
|
transition: background 0.12s;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.agd-accordion__header:hover { background: var(--surface-hover); }
|
||||||
|
.agd-accordion--open .agd-accordion__header {
|
||||||
|
background: color-mix(in srgb, var(--primary-color, #6366f1) 4%, var(--surface-card));
|
||||||
|
border-bottom: 1px solid var(--surface-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.agd-accordion__icon {
|
||||||
|
display: grid; place-items: center;
|
||||||
|
width: 2rem; height: 2rem; border-radius: 6px; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.agd-accordion__title {
|
||||||
|
font-size: 0.88rem; font-weight: 700;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
.agd-accordion--open .agd-accordion__title {
|
||||||
|
color: var(--primary-color, #6366f1);
|
||||||
|
}
|
||||||
|
.agd-accordion__summary {
|
||||||
|
font-size: 0.72rem; color: var(--text-color-secondary);
|
||||||
|
opacity: 0.75; margin-top: 1px;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.agd-accordion__chevron {
|
||||||
|
font-size: 0.7rem; color: var(--text-color-secondary);
|
||||||
|
opacity: 0.5; flex-shrink: 0;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.agd-accordion--open .agd-accordion__chevron {
|
||||||
|
color: var(--primary-color, #6366f1); opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.agd-accordion__body {
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex; flex-direction: column; gap: 1.25rem;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -239,32 +239,19 @@ onMounted(async () => {
|
|||||||
<template>
|
<template>
|
||||||
<Toast />
|
<Toast />
|
||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-3">
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Subheader -->
|
||||||
<Card>
|
<div class="cfg-subheader">
|
||||||
<template #content>
|
<div class="cfg-subheader__icon"><i class="pi pi-id-card" /></div>
|
||||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
<div class="min-w-0">
|
||||||
<div class="flex items-center gap-3">
|
<div class="cfg-subheader__title">Convênios</div>
|
||||||
<div class="cfg-icon-box">
|
<div class="cfg-subheader__sub">Convênios e planos de saúde que você atende</div>
|
||||||
<i class="pi pi-id-card text-lg" />
|
</div>
|
||||||
</div>
|
<div class="cfg-subheader__actions">
|
||||||
<div>
|
<Button label="Novo convênio" icon="pi pi-plus" size="small" :disabled="pageLoading || addingNew" class="rounded-full" @click="addingNew = true; cancelEdit()" />
|
||||||
<div class="text-900 font-semibold text-lg">Convênios</div>
|
</div>
|
||||||
<div class="text-600 text-sm">
|
</div>
|
||||||
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>
|
|
||||||
|
|
||||||
<!-- Loading -->
|
<!-- Loading -->
|
||||||
<div v-if="pageLoading || loading" class="flex justify-center py-10">
|
<div v-if="pageLoading || loading" class="flex justify-center py-10">
|
||||||
@@ -273,15 +260,13 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
|
||||||
<!-- Formulário novo convênio -->
|
<!-- Form novo convênio -->
|
||||||
<Card v-if="addingNew">
|
<div v-if="addingNew" class="cfg-wrap">
|
||||||
<template #title>
|
<div class="cfg-wrap__head">
|
||||||
<div class="flex items-center gap-2">
|
<div class="cfg-wrap__icon"><i class="pi pi-plus" /></div>
|
||||||
<i class="pi pi-plus-circle text-primary-500" />
|
<span class="cfg-wrap__title">Novo convênio</span>
|
||||||
<span>Novo convênio</span>
|
</div>
|
||||||
</div>
|
<div class="cfg-wrap__body">
|
||||||
</template>
|
|
||||||
<template #content>
|
|
||||||
<div class="grid grid-cols-12 gap-3">
|
<div class="grid grid-cols-12 gap-3">
|
||||||
<div class="col-span-12 sm:col-span-6">
|
<div class="col-span-12 sm:col-span-6">
|
||||||
<FloatLabel variant="on">
|
<FloatLabel variant="on">
|
||||||
@@ -296,30 +281,30 @@ onMounted(async () => {
|
|||||||
</FloatLabel>
|
</FloatLabel>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 justify-end mt-4">
|
<div class="flex gap-2 justify-end mt-3">
|
||||||
<Button label="Cancelar" severity="secondary" outlined @click="addingNew = false; newForm = emptyForm()" />
|
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" @click="addingNew = false; newForm = emptyForm()" />
|
||||||
<Button label="Salvar" icon="pi pi-check" :loading="savingNew" @click="saveNew" />
|
<Button label="Salvar" icon="pi pi-check" class="rounded-full" :loading="savingNew" @click="saveNew" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
|
|
||||||
<!-- Lista vazia -->
|
<!-- Lista vazia -->
|
||||||
<Card v-if="!plans.length && !addingNew">
|
<div v-if="!plans.length && !addingNew" class="cfg-empty">
|
||||||
<template #content>
|
<i class="pi pi-id-card text-3xl opacity-25" />
|
||||||
<div class="text-center py-6 text-color-secondary">
|
<div class="text-sm font-medium">Nenhum convênio cadastrado</div>
|
||||||
<i class="pi pi-id-card text-4xl opacity-30 mb-3 block" />
|
<div class="text-xs opacity-70">Clique em "Novo convênio" para começar.</div>
|
||||||
<div class="font-medium mb-1">Nenhum convênio cadastrado</div>
|
</div>
|
||||||
<div class="text-sm">Clique em "Novo convênio" para começar.</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Lista de convênios -->
|
<!-- Lista de convênios -->
|
||||||
<Card v-for="plan in plans" :key="plan.id" :class="{ 'opacity-60': !plan.active }">
|
<div
|
||||||
<template #content>
|
v-for="plan in plans"
|
||||||
|
:key="plan.id"
|
||||||
<!-- Modo edição do plano -->
|
class="cfg-wrap"
|
||||||
<template v-if="editingId === plan.id">
|
: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="grid grid-cols-12 gap-3">
|
||||||
<div class="col-span-12 sm:col-span-6">
|
<div class="col-span-12 sm:col-span-6">
|
||||||
<FloatLabel variant="on">
|
<FloatLabel variant="on">
|
||||||
@@ -334,169 +319,138 @@ onMounted(async () => {
|
|||||||
</FloatLabel>
|
</FloatLabel>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 justify-end mt-4">
|
<div class="flex gap-2 justify-end mt-3">
|
||||||
<Button label="Cancelar" severity="secondary" outlined @click="cancelEdit" />
|
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" @click="cancelEdit" />
|
||||||
<Button label="Salvar" icon="pi pi-check" :loading="savingEdit" @click="saveEdit" />
|
<Button label="Salvar" icon="pi pi-check" class="rounded-full" :loading="savingEdit" @click="saveEdit" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Modo leitura -->
|
<!-- Modo leitura -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- Cabeçalho do plano -->
|
|
||||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
<!-- Cabeçalho do plano -->
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
<div class="cnv-plan-head">
|
||||||
<div class="cfg-icon-box-sm shrink-0">
|
<div class="flex items-center gap-2.5 min-w-0 flex-1">
|
||||||
<i class="pi pi-id-card" />
|
<div class="cfg-wrap__icon shrink-0"><i class="pi pi-id-card" /></div>
|
||||||
</div>
|
<div class="min-w-0">
|
||||||
<div class="min-w-0">
|
<div class="font-semibold text-sm">{{ plan.name }}</div>
|
||||||
<div class="font-semibold text-900">{{ plan.name }}</div>
|
<div v-if="plan.notes" class="text-xs text-[var(--text-color-secondary)] opacity-70 truncate">{{ plan.notes }}</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)" />
|
|
||||||
</div>
|
</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 -->
|
<!-- Painel procedimentos expandível -->
|
||||||
<div v-if="expandedPlanId === plan.id" class="mt-4 border-t border-surface pt-4">
|
<div v-if="expandedPlanId === plan.id" class="cnv-procedures">
|
||||||
|
|
||||||
<!-- Lista de procedimentos (ativos e inativos) -->
|
<!-- Lista de procedimentos -->
|
||||||
<div v-if="plan.insurance_plan_services?.length" class="mb-3 flex flex-col gap-1">
|
<div v-if="plan.insurance_plan_services?.length" class="cnv-proc-list">
|
||||||
<template v-for="ps in plan.insurance_plan_services" :key="ps.id">
|
<template v-for="ps in plan.insurance_plan_services" :key="ps.id">
|
||||||
|
|
||||||
<!-- Modo edição inline do procedimento -->
|
<!-- 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 v-if="editingServiceId === ps.id" class="cnv-proc-edit">
|
||||||
<div class="flex-1 min-w-[140px]">
|
<div class="grid grid-cols-12 gap-2 flex-1">
|
||||||
<label class="text-xs text-color-secondary mb-1 block">Nome</label>
|
<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" />
|
<InputText v-model="editServiceForm.name" class="w-full" size="small" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-36">
|
<div class="col-span-12 sm:col-span-6">
|
||||||
<label class="text-xs text-color-secondary mb-1 block">Valor (R$)</label>
|
<label class="cnv-label">Valor (R$)</label>
|
||||||
<InputNumber
|
<InputNumber v-model="editServiceForm.value" mode="currency" currency="BRL" locale="pt-BR" :min="0" :minFractionDigits="2" class="w-full" size="small" />
|
||||||
v-model="editServiceForm.value"
|
|
||||||
mode="currency" currency="BRL" locale="pt-BR"
|
|
||||||
:min="0" :minFractionDigits="2"
|
|
||||||
class="w-full" size="small"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Button label="Cancelar" severity="secondary" outlined size="small" @click="cancelEditService" />
|
|
||||||
<Button label="Salvar" icon="pi pi-check" size="small" :loading="savingServiceEdit" @click="saveServiceEdit" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex gap-2 justify-end mt-2">
|
||||||
<!-- Modo leitura do procedimento -->
|
<Button label="Cancelar" severity="secondary" outlined size="small" class="rounded-full" @click="cancelEditService" />
|
||||||
<div
|
<Button label="Salvar" icon="pi pi-check" size="small" class="rounded-full" :loading="savingServiceEdit" @click="saveServiceEdit" />
|
||||||
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-2 items-end">
|
<!-- Leitura do procedimento -->
|
||||||
<div class="flex-1 min-w-[140px]">
|
<div v-else class="cnv-proc-row" :class="{ 'opacity-50': !ps.active }">
|
||||||
<label class="text-xs text-color-secondary mb-1 block">Nome do procedimento *</label>
|
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||||
<InputText v-model="newServiceForm.name" placeholder="Ex: Consulta" class="w-full" size="small" />
|
<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>
|
||||||
<div class="w-36">
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
<label class="text-xs text-color-secondary mb-1 block">Valor (R$) *</label>
|
<span class="text-sm font-semibold text-[var(--primary-color)]">{{ fmtBRL(ps.value) }}</span>
|
||||||
<InputNumber
|
<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)" />
|
||||||
v-model="newServiceForm.value"
|
<Button icon="pi pi-pencil" severity="secondary" text size="small" v-tooltip.top="'Editar'" @click="startEditService(ps)" />
|
||||||
mode="currency" currency="BRL" locale="pt-BR"
|
<Button icon="pi pi-trash" severity="danger" text size="small" v-tooltip.top="'Remover'" @click="deleteService(ps.id)" />
|
||||||
: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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
</template>
|
||||||
v-if="addingServicePlanId !== plan.id"
|
</div>
|
||||||
label="Adicionar procedimento"
|
<div v-else-if="addingServicePlanId !== plan.id" class="text-sm text-[var(--text-color-secondary)] italic px-1 py-2">
|
||||||
icon="pi pi-plus"
|
Nenhum procedimento cadastrado.
|
||||||
severity="secondary"
|
|
||||||
outlined
|
|
||||||
size="small"
|
|
||||||
class="mt-2"
|
|
||||||
@click="startAddService(plan.id)"
|
|
||||||
/>
|
|
||||||
</div>
|
</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>
|
</template>
|
||||||
</Card>
|
</div>
|
||||||
|
|
||||||
<Message severity="info" :closable="false">
|
<Message severity="info" :closable="false">
|
||||||
<span class="text-sm">
|
<span class="text-sm">
|
||||||
@@ -509,44 +463,129 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.cfg-icon-box {
|
/* ── Subheader degradê ────────────────────────────── */
|
||||||
display: grid;
|
.cfg-subheader {
|
||||||
place-items: center;
|
display: flex; align-items: center; gap: 0.65rem;
|
||||||
width: 2.5rem;
|
padding: 0.875rem 1rem; border-radius: 6px;
|
||||||
height: 2.5rem;
|
border: 1px solid color-mix(in srgb, var(--primary-color,#6366f1) 30%, transparent);
|
||||||
border-radius: 0.875rem;
|
background: linear-gradient(135deg,
|
||||||
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
|
color-mix(in srgb, var(--primary-color,#6366f1) 12%, var(--surface-card)) 0%,
|
||||||
color: var(--p-primary-500, #6366f1);
|
color-mix(in srgb, var(--primary-color,#6366f1) 4%, var(--surface-card)) 60%,
|
||||||
flex-shrink: 0;
|
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 {
|
/* ── Cabeçalho do plano ───────────────────────────── */
|
||||||
display: grid;
|
.cnv-plan-head {
|
||||||
place-items: center;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
width: 2rem;
|
gap: 0.75rem; padding: 0.75rem 1rem; flex-wrap: wrap;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 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 {
|
.svc-quick-card {
|
||||||
display: flex;
|
display: flex; flex-direction: column; gap: 0.1rem;
|
||||||
flex-direction: column;
|
padding: 0.375rem 0.625rem; border-radius: 6px;
|
||||||
gap: 0.125rem;
|
border: 1px solid var(--surface-border);
|
||||||
padding: 0.375rem 0.625rem;
|
background: var(--surface-ground);
|
||||||
border-radius: 0.5rem;
|
text-align: left; cursor: pointer;
|
||||||
border: 1px solid var(--p-surface-200, #e5e7eb);
|
transition: border-color 0.12s, background 0.12s;
|
||||||
background: var(--p-surface-50, #f9fafb);
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: border-color 0.15s, background 0.15s;
|
|
||||||
}
|
}
|
||||||
.svc-quick-card:hover {
|
.svc-quick-card:hover {
|
||||||
border-color: var(--p-primary-400, #818cf8);
|
border-color: var(--primary-color,#6366f1);
|
||||||
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 6%, transparent);
|
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-name { font-size: 0.72rem; font-weight: 600; color: var(--text-color); }
|
||||||
.svc-quick-price { font-size: 0.7rem; color: var(--p-text-muted-color); }
|
.svc-quick-price { font-size: 0.68rem; color: var(--text-color-secondary); }
|
||||||
</style>
|
</style>
|
||||||
@@ -184,32 +184,19 @@ onMounted(async () => {
|
|||||||
<template>
|
<template>
|
||||||
<Toast />
|
<Toast />
|
||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-3">
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Subheader -->
|
||||||
<Card>
|
<div class="cfg-subheader">
|
||||||
<template #content>
|
<div class="cfg-subheader__icon"><i class="pi pi-percentage" /></div>
|
||||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
<div class="min-w-0">
|
||||||
<div class="flex items-center gap-3">
|
<div class="cfg-subheader__title">Descontos por Paciente</div>
|
||||||
<div class="cfg-icon-box">
|
<div class="cfg-subheader__sub">Descontos recorrentes aplicados automaticamente por paciente</div>
|
||||||
<i class="pi pi-percentage text-lg" />
|
</div>
|
||||||
</div>
|
<div class="cfg-subheader__actions">
|
||||||
<div>
|
<Button label="Novo desconto" icon="pi pi-plus" size="small" :disabled="pageLoading || addingNew" class="rounded-full" @click="addingNew = true; cancelEdit()" />
|
||||||
<div class="text-900 font-semibold text-lg">Descontos por Paciente</div>
|
</div>
|
||||||
<div class="text-600 text-sm">
|
</div>
|
||||||
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>
|
|
||||||
|
|
||||||
<!-- Loading -->
|
<!-- Loading -->
|
||||||
<div v-if="pageLoading || loading" class="flex justify-center py-10">
|
<div v-if="pageLoading || loading" class="flex justify-center py-10">
|
||||||
@@ -218,309 +205,144 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
|
||||||
<!-- Lista de descontos -->
|
<!-- Lista + form -->
|
||||||
<Card v-if="discounts.length || addingNew">
|
<div v-if="discounts.length || addingNew" class="cfg-wrap">
|
||||||
<template #content>
|
<div class="cfg-wrap__head">
|
||||||
<div class="flex flex-col gap-3">
|
<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 -->
|
<template v-for="disc in discounts" :key="disc.id">
|
||||||
<div v-if="editingId === disc.id" class="discount-row editing">
|
|
||||||
<div class="grid grid-cols-12 gap-3 flex-1">
|
|
||||||
|
|
||||||
<!-- Paciente (desabilitado na edição) -->
|
<!-- Edição inline -->
|
||||||
<div class="col-span-12 sm:col-span-4">
|
<div v-if="editingId === disc.id" class="dsc-form-row dsc-form-row--editing">
|
||||||
<FloatLabel variant="on">
|
<div class="grid grid-cols-12 gap-3">
|
||||||
<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 -->
|
|
||||||
<div class="col-span-12 sm:col-span-4">
|
<div class="col-span-12 sm:col-span-4">
|
||||||
<FloatLabel variant="on">
|
<FloatLabel variant="on">
|
||||||
<Select
|
<Select v-model="editForm.patient_id" inputId="edit-patient" :options="patients" optionLabel="nome_completo" optionValue="id" disabled class="w-full" />
|
||||||
v-model="newForm.patient_id"
|
<label for="edit-patient">Paciente</label>
|
||||||
inputId="new-patient"
|
|
||||||
:options="patients"
|
|
||||||
optionLabel="nome_completo"
|
|
||||||
optionValue="id"
|
|
||||||
filter
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
<label for="new-patient">Paciente *</label>
|
|
||||||
</FloatLabel>
|
</FloatLabel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Desconto % -->
|
|
||||||
<div class="col-span-6 sm:col-span-2">
|
<div class="col-span-6 sm:col-span-2">
|
||||||
<FloatLabel variant="on">
|
<FloatLabel variant="on">
|
||||||
<InputNumber
|
<InputNumber v-model="editForm.discount_pct" inputId="edit-pct" :min="0" :max="100" :minFractionDigits="0" :maxFractionDigits="2" suffix="%" fluid />
|
||||||
v-model="newForm.discount_pct"
|
<label for="edit-pct">Desconto %</label>
|
||||||
inputId="new-pct"
|
|
||||||
:min="0"
|
|
||||||
:max="100"
|
|
||||||
:minFractionDigits="0"
|
|
||||||
:maxFractionDigits="2"
|
|
||||||
suffix="%"
|
|
||||||
fluid
|
|
||||||
/>
|
|
||||||
<label for="new-pct">Desconto %</label>
|
|
||||||
</FloatLabel>
|
</FloatLabel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Desconto R$ -->
|
|
||||||
<div class="col-span-6 sm:col-span-2">
|
<div class="col-span-6 sm:col-span-2">
|
||||||
<FloatLabel variant="on">
|
<FloatLabel variant="on">
|
||||||
<InputNumber
|
<InputNumber v-model="editForm.discount_flat" inputId="edit-flat" mode="currency" currency="BRL" locale="pt-BR" :min="0" fluid />
|
||||||
v-model="newForm.discount_flat"
|
<label for="edit-flat">Desconto R$</label>
|
||||||
inputId="new-flat"
|
|
||||||
mode="currency"
|
|
||||||
currency="BRL"
|
|
||||||
locale="pt-BR"
|
|
||||||
:min="0"
|
|
||||||
fluid
|
|
||||||
/>
|
|
||||||
<label for="new-flat">Desconto R$</label>
|
|
||||||
</FloatLabel>
|
</FloatLabel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Vigência: de -->
|
|
||||||
<div class="col-span-6 sm:col-span-2">
|
<div class="col-span-6 sm:col-span-2">
|
||||||
<FloatLabel variant="on">
|
<FloatLabel variant="on">
|
||||||
<DatePicker
|
<DatePicker v-model="editForm.active_from" inputId="edit-from" dateFormat="dd/mm/yy" showButtonBar fluid />
|
||||||
v-model="newForm.active_from"
|
<label for="edit-from">Vigência: de</label>
|
||||||
inputId="new-from"
|
|
||||||
dateFormat="dd/mm/yy"
|
|
||||||
showButtonBar
|
|
||||||
fluid
|
|
||||||
/>
|
|
||||||
<label for="new-from">Vigência: de</label>
|
|
||||||
</FloatLabel>
|
</FloatLabel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Vigência: até -->
|
|
||||||
<div class="col-span-6 sm:col-span-2">
|
<div class="col-span-6 sm:col-span-2">
|
||||||
<FloatLabel variant="on">
|
<FloatLabel variant="on">
|
||||||
<DatePicker
|
<DatePicker v-model="editForm.active_to" inputId="edit-to" dateFormat="dd/mm/yy" showButtonBar fluid />
|
||||||
v-model="newForm.active_to"
|
<label for="edit-to">Vigência: até</label>
|
||||||
inputId="new-to"
|
|
||||||
dateFormat="dd/mm/yy"
|
|
||||||
showButtonBar
|
|
||||||
fluid
|
|
||||||
/>
|
|
||||||
<label for="new-to">Vigência: até</label>
|
|
||||||
</FloatLabel>
|
</FloatLabel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Motivo -->
|
|
||||||
<div class="col-span-12">
|
<div class="col-span-12">
|
||||||
<FloatLabel variant="on">
|
<FloatLabel variant="on">
|
||||||
<InputText
|
<InputText v-model="editForm.reason" inputId="edit-reason" class="w-full" />
|
||||||
v-model="newForm.reason"
|
<label for="edit-reason">Motivo (opcional)</label>
|
||||||
inputId="new-reason"
|
|
||||||
class="w-full"
|
|
||||||
/>
|
|
||||||
<label for="new-reason">Motivo (opcional)</label>
|
|
||||||
</FloatLabel>
|
</FloatLabel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 mt-1">
|
<div class="flex gap-2 justify-end mt-3">
|
||||||
<Button
|
<Button label="Cancelar" icon="pi pi-times" size="small" severity="secondary" outlined class="rounded-full" @click="cancelEdit" />
|
||||||
icon="pi pi-check"
|
<Button label="Salvar" icon="pi pi-check" size="small" :loading="savingEdit" class="rounded-full" @click="saveEdit" />
|
||||||
label="Adicionar"
|
|
||||||
size="small"
|
|
||||||
:loading="savingNew"
|
|
||||||
@click="saveNew"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
icon="pi pi-times"
|
|
||||||
size="small"
|
|
||||||
severity="secondary"
|
|
||||||
outlined
|
|
||||||
@click="addingNew = false; newForm = emptyForm()"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</template>
|
|
||||||
</Card>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Estado vazio -->
|
<!-- Estado vazio -->
|
||||||
<Card v-else>
|
<div v-else class="cfg-empty">
|
||||||
<template #content>
|
<i class="pi pi-percentage text-3xl opacity-25" />
|
||||||
<div class="flex flex-col items-center gap-3 py-6 text-center">
|
<div class="text-sm font-medium">Nenhum desconto cadastrado ainda.</div>
|
||||||
<i class="pi pi-percentage text-4xl text-400" />
|
<Button label="Adicionar primeiro desconto" icon="pi pi-plus" outlined size="small" class="rounded-full" @click="addingNew = true" />
|
||||||
<div class="text-600">Nenhum desconto cadastrado ainda.</div>
|
</div>
|
||||||
<Button
|
|
||||||
label="Adicionar primeiro desconto"
|
|
||||||
icon="pi pi-plus"
|
|
||||||
outlined
|
|
||||||
@click="addingNew = true"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Dica -->
|
<!-- Dica -->
|
||||||
<Message severity="info" :closable="false">
|
<Message severity="info" :closable="false">
|
||||||
@@ -535,56 +357,103 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.cfg-icon-box {
|
/* ── Subheader degradê ────────────────────────────── */
|
||||||
display: grid;
|
.cfg-subheader {
|
||||||
place-items: center;
|
display: flex; align-items: center; gap: 0.65rem;
|
||||||
width: 2.5rem;
|
padding: 0.875rem 1rem; border-radius: 6px;
|
||||||
height: 2.5rem;
|
border: 1px solid color-mix(in srgb, var(--primary-color,#6366f1) 30%, transparent);
|
||||||
border-radius: 0.875rem;
|
background: linear-gradient(135deg,
|
||||||
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
|
color-mix(in srgb, var(--primary-color,#6366f1) 12%, var(--surface-card)) 0%,
|
||||||
color: var(--p-primary-500, #6366f1);
|
color-mix(in srgb, var(--primary-color,#6366f1) 4%, var(--surface-card)) 60%,
|
||||||
flex-shrink: 0;
|
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 {
|
/* ── Card wrap ────────────────────────────────────── */
|
||||||
display: flex;
|
.cfg-wrap {
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 0.75rem;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
border: 1px solid var(--surface-border);
|
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);
|
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 {
|
/* ── Lista de descontos ───────────────────────────── */
|
||||||
border-color: var(--p-primary-300, #a5b4fc);
|
.dsc-list { display: flex; flex-direction: column; }
|
||||||
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 4%, var(--surface-ground));
|
|
||||||
}
|
|
||||||
|
|
||||||
.discount-row.new-row {
|
/* Linha de leitura */
|
||||||
border-style: dashed;
|
.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 {
|
/* Badge de valor */
|
||||||
flex: 1;
|
.dsc-badge {
|
||||||
min-width: 8rem;
|
font-size: 0.75rem; font-weight: 600;
|
||||||
}
|
color: var(--primary-color,#6366f1);
|
||||||
|
background: color-mix(in srgb, var(--primary-color,#6366f1) 10%, transparent);
|
||||||
.discount-meta {
|
padding: 0.15rem 0.5rem; border-radius: 6px;
|
||||||
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;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Form de adição/edição */
|
||||||
|
.dsc-form-row {
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid var(--surface-border);
|
||||||
|
}
|
||||||
|
.dsc-form-row:last-child { border-bottom: none; }
|
||||||
|
.dsc-form-row--editing {
|
||||||
|
background: color-mix(in srgb, var(--primary-color,#6366f1) 3%, var(--surface-card));
|
||||||
|
border-left: 3px solid color-mix(in srgb, var(--primary-color,#6366f1) 50%, transparent);
|
||||||
|
}
|
||||||
|
.dsc-form-row--new {
|
||||||
|
background: var(--surface-ground);
|
||||||
|
border-top: 1px dashed var(--surface-border);
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Empty state ──────────────────────────────────── */
|
||||||
|
.cfg-empty {
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
gap: 0.75rem; padding: 2.5rem 1rem; text-align: center;
|
||||||
|
color: var(--text-color-secondary);
|
||||||
|
border: 1px dashed var(--surface-border);
|
||||||
|
border-radius: 6px; background: var(--surface-ground);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -145,24 +145,16 @@ onMounted(async () => {
|
|||||||
<template>
|
<template>
|
||||||
<Toast />
|
<Toast />
|
||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-3">
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Subheader -->
|
||||||
<Card>
|
<div class="cfg-subheader">
|
||||||
<template #content>
|
<div class="cfg-subheader__icon"><i class="pi pi-exclamation-triangle" /></div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="min-w-0">
|
||||||
<div class="cfg-icon-box">
|
<div class="cfg-subheader__title">Exceções Financeiras</div>
|
||||||
<i class="pi pi-exclamation-triangle text-lg" />
|
<div class="cfg-subheader__sub">Defina o que cobrar em cancelamentos, faltas e situações excepcionais</div>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Loading -->
|
<!-- Loading -->
|
||||||
<div v-if="pageLoading || loading" class="flex justify-center py-10">
|
<div v-if="pageLoading || loading" class="flex justify-center py-10">
|
||||||
@@ -172,154 +164,99 @@ onMounted(async () => {
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
|
|
||||||
<!-- Um card por tipo de exceção -->
|
<!-- Um card por tipo de exceção -->
|
||||||
<Card v-for="et in exceptionTypes" :key="et.value">
|
<div v-for="et in exceptionTypes" :key="et.value" class="cfg-wrap">
|
||||||
<template #content>
|
|
||||||
|
|
||||||
<!-- Modo leitura -->
|
<!-- Cabeçalho do card -->
|
||||||
<template v-if="editingType !== et.value">
|
<div class="cfg-wrap__head">
|
||||||
<div class="exception-row">
|
<div class="cfg-wrap__icon"><i class="pi pi-exclamation-triangle" /></div>
|
||||||
<div class="exception-info">
|
<span class="cfg-wrap__title">{{ et.label }}</span>
|
||||||
<div class="font-semibold text-900 text-base">{{ et.label }}</div>
|
<div class="ml-auto flex items-center gap-2 shrink-0">
|
||||||
|
<template v-if="recordFor(et.value)">
|
||||||
<template v-if="recordFor(et.value)">
|
<Tag
|
||||||
<div class="text-sm text-600 mt-1">
|
:value="chargeModeLabel(recordFor(et.value)?.charge_mode)"
|
||||||
{{ summaryFor(recordFor(et.value)) }}
|
:severity="chargeModeSeverity[recordFor(et.value)?.charge_mode] ?? 'secondary'"
|
||||||
<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)"
|
|
||||||
/>
|
/>
|
||||||
|
<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>
|
</div>
|
||||||
</template>
|
</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 -->
|
<!-- Modo edição -->
|
||||||
<template v-else>
|
<div v-else class="exc-edit">
|
||||||
<div class="exception-row editing">
|
<!-- Modo de cobrança -->
|
||||||
<div class="flex flex-col gap-4 flex-1">
|
<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>
|
<div class="grid grid-cols-12 gap-3">
|
||||||
|
<!-- Taxa fixa -->
|
||||||
<!-- Modo de cobrança -->
|
<div v-if="showChargeValue" class="col-span-12 sm:col-span-4">
|
||||||
<div>
|
<FloatLabel variant="on">
|
||||||
<label class="text-sm text-600 block mb-2">Modo de cobrança</label>
|
<InputNumber v-model="editForm.charge_value" inputId="edit-charge-value" mode="currency" currency="BRL" locale="pt-BR" :min="0" fluid />
|
||||||
<SelectButton
|
<label for="edit-charge-value">Taxa fixa (R$)</label>
|
||||||
v-model="editForm.charge_mode"
|
</FloatLabel>
|
||||||
: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>
|
</div>
|
||||||
</template>
|
|
||||||
|
|
||||||
</template>
|
<!-- Percentual -->
|
||||||
</Card>
|
<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 -->
|
<!-- Dica -->
|
||||||
<Message severity="info" :closable="false">
|
<Message severity="info" :closable="false">
|
||||||
@@ -334,33 +271,69 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.cfg-icon-box {
|
/* ── Subheader degradê ────────────────────────────── */
|
||||||
display: grid;
|
.cfg-subheader {
|
||||||
place-items: center;
|
display: flex; align-items: center; gap: 0.65rem;
|
||||||
width: 2.5rem;
|
padding: 0.875rem 1rem; border-radius: 6px;
|
||||||
height: 2.5rem;
|
border: 1px solid color-mix(in srgb, var(--primary-color,#6366f1) 30%, transparent);
|
||||||
border-radius: 0.875rem;
|
background: linear-gradient(135deg,
|
||||||
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
|
color-mix(in srgb, var(--primary-color,#6366f1) 12%, var(--surface-card)) 0%,
|
||||||
color: var(--p-primary-500, #6366f1);
|
color-mix(in srgb, var(--primary-color,#6366f1) 4%, var(--surface-card)) 60%,
|
||||||
flex-shrink: 0;
|
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 {
|
/* ── Edição ───────────────────────────────────────── */
|
||||||
display: flex;
|
.exc-edit {
|
||||||
align-items: flex-start;
|
|
||||||
gap: 1rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.exception-row.editing {
|
|
||||||
border: 1px solid var(--p-primary-300, #a5b4fc);
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
padding: 1rem;
|
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 {
|
/* ── Label ────────────────────────────────────────── */
|
||||||
flex: 1;
|
.exc-label {
|
||||||
min-width: 10rem;
|
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 loading = ref(true)
|
||||||
const ownerId = ref(null)
|
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)
|
const savingCard = ref(null)
|
||||||
|
|
||||||
// ── Defaults ────────────────────────────────────────────────────
|
// ── Defaults ────────────────────────────────────────────────────
|
||||||
@@ -84,8 +85,13 @@ const bancos = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
// ── Toggle cards ─────────────────────────────────────────────────
|
// ── Toggle cards ─────────────────────────────────────────────────
|
||||||
|
function expandAll () { expandedCard.value = new Set(CARDS) }
|
||||||
|
function collapseAll () { expandedCard.value = new Set() }
|
||||||
function toggleCard (key) {
|
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 ─────────────────────────────────────────────────────────
|
// ── Load ─────────────────────────────────────────────────────────
|
||||||
@@ -173,30 +179,40 @@ onMounted(load)
|
|||||||
<template>
|
<template>
|
||||||
<Toast />
|
<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…
|
<i class="pi pi-spin pi-spinner" /> Carregando…
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="flex flex-col gap-4">
|
<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 ──────────────────────────────────────────────────── -->
|
<!-- 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)]'">
|
:class="cfg.pix_ativo ? 'border-green-300' : 'border-[var(--surface-border)]'">
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<button
|
<button type="button" class="pag-accordion__header" @click="toggleCard('pix')"
|
||||||
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')"
|
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="w-10 h-10 rounded-xl flex items-center justify-center shrink-0"
|
<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-slate-100 text-slate-400'">
|
: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" />
|
<i class="pi pi-qrcode text-lg" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="font-semibold text-slate-800">Pix</div>
|
<div class="font-semibold text-[var(--text-color)]">Pix</div>
|
||||||
<div class="text-sm text-slate-500">
|
<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' }}
|
{{ cfg.pix_ativo && cfg.pix_chave ? `Chave: ${cfg.pix_chave}` : 'Pagamento instantâneo via chave Pix' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -204,21 +220,21 @@ onMounted(load)
|
|||||||
<div class="flex items-center gap-3 shrink-0">
|
<div class="flex items-center gap-3 shrink-0">
|
||||||
<Tag v-if="cfg.pix_ativo" value="Ativo" severity="success" />
|
<Tag v-if="cfg.pix_ativo" value="Ativo" severity="success" />
|
||||||
<Tag v-else value="Inativo" severity="secondary" />
|
<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>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Body -->
|
<!-- 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">
|
<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" />
|
<ToggleSwitch v-model="cfg.pix_ativo" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="cfg.pix_ativo">
|
<template v-if="cfg.pix_ativo">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<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
|
<Select
|
||||||
v-model="cfg.pix_tipo"
|
v-model="cfg.pix_tipo"
|
||||||
:options="pixTipoOptions"
|
:options="pixTipoOptions"
|
||||||
@@ -228,13 +244,13 @@ onMounted(load)
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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' }}
|
{{ pixTipoLabel[cfg.pix_tipo] || 'Chave' }}
|
||||||
</label>
|
</label>
|
||||||
<InputText v-model="cfg.pix_chave" class="w-full" :placeholder="pixTipoLabel[cfg.pix_tipo]" />
|
<InputText v-model="cfg.pix_chave" class="w-full" :placeholder="pixTipoLabel[cfg.pix_tipo]" />
|
||||||
</div>
|
</div>
|
||||||
<div class="md:col-span-2">
|
<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" />
|
<InputText v-model="cfg.pix_nome_titular" class="w-full" placeholder="Nome que aparece na chave" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -252,22 +268,19 @@ onMounted(load)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Depósito bancário ───────────────────────────────────── -->
|
<!-- 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)]'">
|
:class="cfg.deposito_ativo ? 'border-blue-300' : 'border-[var(--surface-border)]'">
|
||||||
|
|
||||||
<button
|
<button type="button" class="pag-accordion__header" @click="toggleCard('deposito')"
|
||||||
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')"
|
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="w-10 h-10 rounded-xl flex items-center justify-center shrink-0"
|
<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-slate-100 text-slate-400'">
|
: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" />
|
<i class="pi pi-building-columns text-lg" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="font-semibold text-slate-800">Depósito / TED</div>
|
<div class="font-semibold text-[var(--text-color)]">Depósito / TED</div>
|
||||||
<div class="text-sm text-slate-500">
|
<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' }}
|
{{ 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>
|
||||||
</div>
|
</div>
|
||||||
@@ -275,20 +288,20 @@ onMounted(load)
|
|||||||
<div class="flex items-center gap-3 shrink-0">
|
<div class="flex items-center gap-3 shrink-0">
|
||||||
<Tag v-if="cfg.deposito_ativo" value="Ativo" severity="success" />
|
<Tag v-if="cfg.deposito_ativo" value="Ativo" severity="success" />
|
||||||
<Tag v-else value="Inativo" severity="secondary" />
|
<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>
|
</div>
|
||||||
</button>
|
</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">
|
<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" />
|
<ToggleSwitch v-model="cfg.deposito_ativo" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="cfg.deposito_ativo">
|
<template v-if="cfg.deposito_ativo">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<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
|
<Select
|
||||||
v-model="cfg.deposito_banco"
|
v-model="cfg.deposito_banco"
|
||||||
:options="bancos"
|
:options="bancos"
|
||||||
@@ -300,7 +313,7 @@ onMounted(load)
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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
|
<Select
|
||||||
v-model="cfg.deposito_tipo_conta"
|
v-model="cfg.deposito_tipo_conta"
|
||||||
:options="tipoConta"
|
:options="tipoConta"
|
||||||
@@ -310,19 +323,19 @@ onMounted(load)
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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" />
|
<InputText v-model="cfg.deposito_agencia" class="w-full" placeholder="0000" />
|
||||||
</div>
|
</div>
|
||||||
<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" />
|
<InputText v-model="cfg.deposito_conta" class="w-full" placeholder="00000-0" />
|
||||||
</div>
|
</div>
|
||||||
<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" />
|
<InputText v-model="cfg.deposito_titular" class="w-full" placeholder="Nome completo ou razão social" />
|
||||||
</div>
|
</div>
|
||||||
<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" />
|
<InputText v-model="cfg.deposito_cpf_cnpj" class="w-full" placeholder="000.000.000-00" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -340,36 +353,33 @@ onMounted(load)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Dinheiro ─────────────────────────────────────────────── -->
|
<!-- 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)]'">
|
:class="cfg.dinheiro_ativo ? 'border-yellow-300' : 'border-[var(--surface-border)]'">
|
||||||
|
|
||||||
<button
|
<button type="button" class="pag-accordion__header" @click="toggleCard('dinheiro')"
|
||||||
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')"
|
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="w-10 h-10 rounded-xl flex items-center justify-center shrink-0"
|
<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-slate-100 text-slate-400'">
|
: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" />
|
<i class="pi pi-wallet text-lg" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="font-semibold text-slate-800">Dinheiro (espécie)</div>
|
<div class="font-semibold text-[var(--text-color)]">Dinheiro (espécie)</div>
|
||||||
<div class="text-sm text-slate-500">Pagamento presencial em dinheiro</div>
|
<div class="text-sm text-[var(--text-color-secondary)]">Pagamento presencial em dinheiro</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3 shrink-0">
|
<div class="flex items-center gap-3 shrink-0">
|
||||||
<Tag v-if="cfg.dinheiro_ativo" value="Ativo" severity="success" />
|
<Tag v-if="cfg.dinheiro_ativo" value="Ativo" severity="success" />
|
||||||
<Tag v-else value="Inativo" severity="secondary" />
|
<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>
|
</div>
|
||||||
</button>
|
</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 class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<span class="font-medium text-slate-700">Habilitar pagamento em dinheiro</span>
|
<span class="font-medium text-[var(--text-color)]">Habilitar pagamento em dinheiro</span>
|
||||||
<p class="text-sm text-slate-500 mt-1">
|
<p class="text-sm text-[var(--text-color-secondary)] mt-1">
|
||||||
Aceitar pagamento em espécie nas sessões presenciais.
|
Aceitar pagamento em espécie nas sessões presenciais.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -388,36 +398,33 @@ onMounted(load)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Cartão ───────────────────────────────────────────────── -->
|
<!-- 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)]'">
|
:class="cfg.cartao_ativo ? 'border-purple-300' : 'border-[var(--surface-border)]'">
|
||||||
|
|
||||||
<button
|
<button type="button" class="pag-accordion__header" @click="toggleCard('cartao')"
|
||||||
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')"
|
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="w-10 h-10 rounded-xl flex items-center justify-center shrink-0"
|
<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-slate-100 text-slate-400'">
|
: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" />
|
<i class="pi pi-credit-card text-lg" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="font-semibold text-slate-800">Cartão (maquininha)</div>
|
<div class="font-semibold text-[var(--text-color)]">Cartão (maquininha)</div>
|
||||||
<div class="text-sm text-slate-500">Crédito e débito presencial</div>
|
<div class="text-sm text-[var(--text-color-secondary)]">Crédito e débito presencial</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3 shrink-0">
|
<div class="flex items-center gap-3 shrink-0">
|
||||||
<Tag v-if="cfg.cartao_ativo" value="Ativo" severity="success" />
|
<Tag v-if="cfg.cartao_ativo" value="Ativo" severity="success" />
|
||||||
<Tag v-else value="Inativo" severity="secondary" />
|
<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>
|
</div>
|
||||||
</button>
|
</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 class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<span class="font-medium text-slate-700">Habilitar pagamento por cartão</span>
|
<span class="font-medium text-[var(--text-color)]">Habilitar pagamento por cartão</span>
|
||||||
<p class="text-sm text-slate-500 mt-1">
|
<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.
|
Aceitar cartão de crédito e débito via maquininha nas sessões presenciais.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -426,7 +433,7 @@ onMounted(load)
|
|||||||
|
|
||||||
<template v-if="cfg.cartao_ativo">
|
<template v-if="cfg.cartao_ativo">
|
||||||
<div>
|
<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
|
<InputText
|
||||||
v-model="cfg.cartao_instrucao"
|
v-model="cfg.cartao_instrucao"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
@@ -447,22 +454,19 @@ onMounted(load)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Plano de Saúde / Convênio ────────────────────────────── -->
|
<!-- 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)]'">
|
:class="cfg.convenio_ativo ? 'border-teal-300' : 'border-[var(--surface-border)]'">
|
||||||
|
|
||||||
<button
|
<button type="button" class="pag-accordion__header" @click="toggleCard('convenio')"
|
||||||
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')"
|
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="w-10 h-10 rounded-xl flex items-center justify-center shrink-0"
|
<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-slate-100 text-slate-400'">
|
: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" />
|
<i class="pi pi-heart text-lg" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="font-semibold text-slate-800">Plano de saúde / Convênio</div>
|
<div class="font-semibold text-[var(--text-color)]">Plano de saúde / Convênio</div>
|
||||||
<div class="text-sm text-slate-500">
|
<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' }}
|
{{ cfg.convenio_ativo && cfg.convenio_lista ? cfg.convenio_lista.slice(0, 60) + (cfg.convenio_lista.length > 60 ? '…' : '') : 'Atendimento por convênio' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -470,15 +474,15 @@ onMounted(load)
|
|||||||
<div class="flex items-center gap-3 shrink-0">
|
<div class="flex items-center gap-3 shrink-0">
|
||||||
<Tag v-if="cfg.convenio_ativo" value="Ativo" severity="success" />
|
<Tag v-if="cfg.convenio_ativo" value="Ativo" severity="success" />
|
||||||
<Tag v-else value="Inativo" severity="secondary" />
|
<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>
|
</div>
|
||||||
</button>
|
</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 class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<span class="font-medium text-slate-700">Aceitar plano de saúde / convênio</span>
|
<span class="font-medium text-[var(--text-color)]">Aceitar plano de saúde / convênio</span>
|
||||||
<p class="text-sm text-slate-500 mt-1">
|
<p class="text-sm text-[var(--text-color-secondary)] mt-1">
|
||||||
Habilite para informar quais convênios são aceitos.
|
Habilite para informar quais convênios são aceitos.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -487,7 +491,7 @@ onMounted(load)
|
|||||||
|
|
||||||
<template v-if="cfg.convenio_ativo">
|
<template v-if="cfg.convenio_ativo">
|
||||||
<div>
|
<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
|
<Textarea
|
||||||
v-model="cfg.convenio_lista"
|
v-model="cfg.convenio_lista"
|
||||||
rows="3"
|
rows="3"
|
||||||
@@ -495,7 +499,7 @@ onMounted(load)
|
|||||||
placeholder="Ex: Unimed, Bradesco Saúde, Amil, SulAmérica..."
|
placeholder="Ex: Unimed, Bradesco Saúde, Amil, SulAmérica..."
|
||||||
autoResize
|
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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -511,26 +515,23 @@ onMounted(load)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Observações gerais ───────────────────────────────────── -->
|
<!-- Observações gerais ───────────────────────────────────── -->
|
||||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
|
<div class="pag-accordion">
|
||||||
|
|
||||||
<button
|
<button type="button" class="pag-accordion__header" @click="toggleCard('observacoes')"
|
||||||
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')"
|
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<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" />
|
<i class="pi pi-comment text-lg" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="font-semibold text-slate-800">Observações ao paciente</div>
|
<div class="font-semibold text-[var(--text-color)]">Observações ao paciente</div>
|
||||||
<div class="text-sm text-slate-500">Texto exibido junto às formas de pagamento</div>
|
<div class="text-sm text-[var(--text-color-secondary)]">Texto exibido junto às formas de pagamento</div>
|
||||||
</div>
|
</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>
|
</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
|
<Textarea
|
||||||
v-model="cfg.observacoes_pagamento"
|
v-model="cfg.observacoes_pagamento"
|
||||||
rows="4"
|
rows="4"
|
||||||
@@ -551,3 +552,87 @@ onMounted(load)
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
<template>
|
||||||
<Toast />
|
<Toast />
|
||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-3">
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Subheader -->
|
||||||
<Card>
|
<div class="cfg-subheader">
|
||||||
<template #content>
|
<div class="cfg-subheader__icon"><i class="pi pi-tag" /></div>
|
||||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
<div class="min-w-0">
|
||||||
<div class="flex items-center gap-3">
|
<div class="cfg-subheader__title">Precificação</div>
|
||||||
<div class="cfg-icon-box">
|
<div class="cfg-subheader__sub">Valor padrão da sessão e preços por tipo de compromisso</div>
|
||||||
<i class="pi pi-tag text-lg" />
|
</div>
|
||||||
</div>
|
<div class="cfg-subheader__actions">
|
||||||
<div>
|
<Button label="Novo serviço" icon="pi pi-plus" size="small" :disabled="pageLoading || addingNew" class="rounded-full" @click="addingNew = true; cancelEdit()" />
|
||||||
<div class="text-900 font-semibold text-lg">Serviços e Precificação</div>
|
</div>
|
||||||
<div class="text-600 text-sm">
|
</div>
|
||||||
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>
|
|
||||||
|
|
||||||
<!-- Loading -->
|
<!-- Loading -->
|
||||||
<div v-if="pageLoading || loading" class="flex justify-center py-10">
|
<div v-if="pageLoading || loading" class="flex justify-center py-10">
|
||||||
@@ -183,20 +170,16 @@ onMounted(async () => {
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
|
|
||||||
<Message v-if="isDynamic" severity="info" :closable="false">
|
<Message v-if="isDynamic" severity="info" :closable="false">
|
||||||
<span class="text-sm">
|
<span class="text-sm">Modo <b>dinâmico</b> ativo — a duração da sessão é definida pelo serviço selecionado.</span>
|
||||||
Modo <b>dinâmico</b> ativo — a duração da sessão é definida pelo serviço selecionado.
|
|
||||||
</span>
|
|
||||||
</Message>
|
</Message>
|
||||||
|
|
||||||
<!-- Formulário novo serviço -->
|
<!-- Form novo serviço -->
|
||||||
<Card v-if="addingNew">
|
<div v-if="addingNew" class="cfg-wrap">
|
||||||
<template #title>
|
<div class="cfg-wrap__head">
|
||||||
<div class="flex items-center gap-2">
|
<div class="cfg-wrap__icon"><i class="pi pi-plus" /></div>
|
||||||
<i class="pi pi-plus-circle text-primary-500" />
|
<span class="cfg-wrap__title">Novo serviço</span>
|
||||||
<span>Novo serviço</span>
|
</div>
|
||||||
</div>
|
<div class="svc-form">
|
||||||
</template>
|
|
||||||
<template #content>
|
|
||||||
<div class="grid grid-cols-12 gap-3">
|
<div class="grid grid-cols-12 gap-3">
|
||||||
<div class="col-span-12 sm:col-span-4">
|
<div class="col-span-12 sm:col-span-4">
|
||||||
<FloatLabel variant="on">
|
<FloatLabel variant="on">
|
||||||
@@ -206,16 +189,7 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-span-12 sm:col-span-3">
|
<div class="col-span-12 sm:col-span-3">
|
||||||
<FloatLabel variant="on">
|
<FloatLabel variant="on">
|
||||||
<InputNumber
|
<InputNumber v-model="newForm.price" inputId="new-price" mode="currency" currency="BRL" locale="pt-BR" :min="0" :minFractionDigits="2" fluid />
|
||||||
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>
|
<label for="new-price">Preço (R$) *</label>
|
||||||
</FloatLabel>
|
</FloatLabel>
|
||||||
</div>
|
</div>
|
||||||
@@ -232,30 +206,59 @@ onMounted(async () => {
|
|||||||
</FloatLabel>
|
</FloatLabel>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 justify-end mt-4">
|
<div class="flex gap-2 justify-end mt-3">
|
||||||
<Button label="Cancelar" severity="secondary" outlined @click="addingNew = false; newForm = emptyForm()" />
|
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" @click="addingNew = false; newForm = emptyForm()" />
|
||||||
<Button label="Salvar" icon="pi pi-check" :loading="savingNew" @click="saveNew" />
|
<Button label="Salvar" icon="pi pi-check" class="rounded-full" :loading="savingNew" @click="saveNew" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
|
|
||||||
<!-- Lista vazia -->
|
<!-- Lista vazia -->
|
||||||
<Card v-if="!services.length && !addingNew">
|
<div v-if="!services.length && !addingNew" class="cfg-empty">
|
||||||
<template #content>
|
<i class="pi pi-tag text-3xl opacity-25" />
|
||||||
<div class="text-center py-6 text-color-secondary">
|
<div class="text-sm font-medium">Nenhum serviço cadastrado</div>
|
||||||
<i class="pi pi-tag text-4xl opacity-30 mb-3 block" />
|
<div class="text-xs opacity-70">Clique em "Novo serviço" para começar.</div>
|
||||||
<div class="font-medium mb-1">Nenhum serviço cadastrado</div>
|
</div>
|
||||||
<div class="text-sm">Clique em "Novo serviço" para começar.</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Lista de serviços -->
|
<!-- Lista de serviços -->
|
||||||
<Card v-for="svc in services" :key="svc.id" :class="{ 'opacity-60': !svc.active }">
|
<div v-for="svc in services" :key="svc.id" class="cfg-wrap" :class="{ 'opacity-60': !svc.active }">
|
||||||
<template #content>
|
|
||||||
|
|
||||||
<!-- Modo edição -->
|
<!-- Modo leitura: head clicável -->
|
||||||
<template v-if="editingId === svc.id">
|
<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="grid grid-cols-12 gap-3">
|
||||||
<div class="col-span-12 sm:col-span-4">
|
<div class="col-span-12 sm:col-span-4">
|
||||||
<FloatLabel variant="on">
|
<FloatLabel variant="on">
|
||||||
@@ -265,16 +268,7 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-span-12 sm:col-span-3">
|
<div class="col-span-12 sm:col-span-3">
|
||||||
<FloatLabel variant="on">
|
<FloatLabel variant="on">
|
||||||
<InputNumber
|
<InputNumber v-model="editForm.price" :inputId="`edit-price-${svc.id}`" mode="currency" currency="BRL" locale="pt-BR" :min="0" :minFractionDigits="2" fluid />
|
||||||
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>
|
<label :for="`edit-price-${svc.id}`">Preço (R$) *</label>
|
||||||
</FloatLabel>
|
</FloatLabel>
|
||||||
</div>
|
</div>
|
||||||
@@ -291,51 +285,17 @@ onMounted(async () => {
|
|||||||
</FloatLabel>
|
</FloatLabel>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 justify-end mt-4">
|
<div class="flex gap-2 justify-end mt-3">
|
||||||
<Button label="Cancelar" severity="secondary" outlined @click="cancelEdit" />
|
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" @click="cancelEdit" />
|
||||||
<Button label="Salvar" icon="pi pi-check" :loading="savingEdit" @click="saveEdit" />
|
<Button label="Salvar" icon="pi pi-check" class="rounded-full" :loading="savingEdit" @click="saveEdit" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
|
|
||||||
<!-- 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>
|
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
</Card>
|
|
||||||
|
</div>
|
||||||
|
|
||||||
<Message severity="info" :closable="false">
|
<Message severity="info" :closable="false">
|
||||||
<span class="text-sm">
|
<span class="text-sm">Os serviços cadastrados aqui aparecem para seleção ao criar ou editar um evento na agenda.</span>
|
||||||
Os serviços cadastrados aqui aparecem para seleção ao criar ou editar um evento na agenda.
|
|
||||||
</span>
|
|
||||||
</Message>
|
</Message>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
@@ -343,25 +303,84 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.cfg-icon-box {
|
/* ── Subheader degradê ────────────────────────────── */
|
||||||
display: grid;
|
.cfg-subheader {
|
||||||
place-items: center;
|
display: flex; align-items: center; gap: 0.65rem;
|
||||||
width: 2.5rem;
|
padding: 0.875rem 1rem; border-radius: 6px;
|
||||||
height: 2.5rem;
|
border: 1px solid color-mix(in srgb, var(--primary-color,#6366f1) 30%, transparent);
|
||||||
border-radius: 0.875rem;
|
background: linear-gradient(135deg,
|
||||||
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
|
color-mix(in srgb, var(--primary-color,#6366f1) 12%, var(--surface-card)) 0%,
|
||||||
color: var(--p-primary-500, #6366f1);
|
color-mix(in srgb, var(--primary-color,#6366f1) 4%, var(--surface-card)) 60%,
|
||||||
flex-shrink: 0;
|
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 {
|
/* ── Empty state ──────────────────────────────────── */
|
||||||
display: grid;
|
.cfg-empty {
|
||||||
place-items: center;
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
width: 2rem;
|
gap: 0.75rem; padding: 2.5rem 1rem; text-align: center;
|
||||||
height: 2rem;
|
color: var(--text-color-secondary);
|
||||||
border-radius: 0.625rem;
|
border: 1px dashed var(--surface-border);
|
||||||
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 10%, transparent);
|
border-radius: 6px; background: var(--surface-ground);
|
||||||
color: var(--p-primary-500, #6366f1);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
35
src/main.js
35
src/main.js
@@ -1,7 +1,7 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
|
import { pinia } from '@/plugins/pinia' // ← singleton criado antes do router
|
||||||
import { setOnSignedOut, initSession, listenAuthChanges, refreshSession } from '@/app/session'
|
import { setOnSignedOut, initSession, listenAuthChanges, refreshSession } from '@/app/session'
|
||||||
|
|
||||||
import Aura from '@primeuix/themes/aura'
|
import Aura from '@primeuix/themes/aura'
|
||||||
@@ -61,11 +61,7 @@ async function applyUserThemeEarly () {
|
|||||||
if (error || !settings?.theme_mode) return
|
if (error || !settings?.theme_mode) return
|
||||||
|
|
||||||
const isDark = settings.theme_mode === 'dark'
|
const isDark = settings.theme_mode === 'dark'
|
||||||
|
document.documentElement.classList.toggle('app-dark', isDark)
|
||||||
// o PrimeVue usa o selector .app-dark
|
|
||||||
const root = document.documentElement
|
|
||||||
root.classList.toggle('app-dark', isDark)
|
|
||||||
|
|
||||||
localStorage.setItem('ui_theme_mode', settings.theme_mode)
|
localStorage.setItem('ui_theme_mode', settings.theme_mode)
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
@@ -80,30 +76,20 @@ window.__fromVisibilityRefresh = false
|
|||||||
window.__appBootstrapped = false
|
window.__appBootstrapped = false
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
||||||
// 🛟 ao voltar da aba: refresh leve, sem martelar e sem rodar antes do app subir
|
|
||||||
let lastVisibilityRefreshAt = 0
|
let lastVisibilityRefreshAt = 0
|
||||||
|
|
||||||
document.addEventListener('visibilitychange', async () => {
|
document.addEventListener('visibilitychange', async () => {
|
||||||
if (document.visibilityState !== 'visible') return
|
if (document.visibilityState !== 'visible') return
|
||||||
|
|
||||||
// só depois do app montar (evita refresh no meio do bootstrap)
|
|
||||||
if (!window.__appBootstrapped) return
|
if (!window.__appBootstrapped) return
|
||||||
|
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
|
|
||||||
// no máximo 1 refresh a cada 10s
|
|
||||||
if (now - lastVisibilityRefreshAt < 10_000) return
|
if (now - lastVisibilityRefreshAt < 10_000) return
|
||||||
|
|
||||||
// se já tem refresh em andamento, não entra
|
|
||||||
if (window.__sessionRefreshing) return
|
if (window.__sessionRefreshing) return
|
||||||
|
|
||||||
// (opcional) se não houver user, não precisa refresh
|
|
||||||
try {
|
try {
|
||||||
const { data } = await supabase.auth.getUser()
|
const { data } = await supabase.auth.getUser()
|
||||||
if (!data?.user) return
|
if (!data?.user) return
|
||||||
} catch {
|
} catch {}
|
||||||
// se falhar getUser, deixa tentar refreshSession mesmo assim
|
|
||||||
}
|
|
||||||
|
|
||||||
lastVisibilityRefreshAt = now
|
lastVisibilityRefreshAt = now
|
||||||
console.log('[VISIBILITY] Aba voltou -> refreshSession()')
|
console.log('[VISIBILITY] Aba voltou -> refreshSession()')
|
||||||
@@ -114,8 +100,6 @@ document.addEventListener('visibilitychange', async () => {
|
|||||||
|
|
||||||
await refreshSession()
|
await refreshSession()
|
||||||
|
|
||||||
// 🔔 avisa o app inteiro SOMENTE em áreas TENANT.
|
|
||||||
// Portal (/portal) e área global (/account) NÃO devem rehidratar tenantStore/menu.
|
|
||||||
try {
|
try {
|
||||||
const path = router.currentRoute.value?.path || ''
|
const path = router.currentRoute.value?.path || ''
|
||||||
const isTenantArea =
|
const isTenantArea =
|
||||||
@@ -130,9 +114,7 @@ document.addEventListener('visibilitychange', async () => {
|
|||||||
} else {
|
} else {
|
||||||
console.log('[VISIBILITY] refresh ok (skip event) - area não-tenant:', path)
|
console.log('[VISIBILITY] refresh ok (skip event) - area não-tenant:', path)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {}
|
||||||
// se algo der errado, não dispare evento global por segurança
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
window.__fromVisibilityRefresh = false
|
window.__fromVisibilityRefresh = false
|
||||||
window.__sessionRefreshing = false
|
window.__sessionRefreshing = false
|
||||||
@@ -147,17 +129,14 @@ async function bootstrap () {
|
|||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
const pinia = createPinia()
|
// ✅ usa o pinia singleton — o mesmo que o router/guards já conhecem
|
||||||
app.use(pinia)
|
app.use(pinia)
|
||||||
|
|
||||||
app.use(router)
|
app.use(router)
|
||||||
|
|
||||||
// ✅ garante router pronto antes de montar
|
|
||||||
await router.isReady()
|
await router.isReady()
|
||||||
|
|
||||||
// ✅ PrimeVue global config (tema + locale pt-BR)
|
|
||||||
app.use(PrimeVue, {
|
app.use(PrimeVue, {
|
||||||
locale: ptBR, // 🔥 isso traduz Calendar/DatePicker
|
locale: ptBR,
|
||||||
theme: {
|
theme: {
|
||||||
preset: Aura,
|
preset: Aura,
|
||||||
options: { darkModeSelector: '.app-dark' }
|
options: { darkModeSelector: '.app-dark' }
|
||||||
@@ -167,7 +146,6 @@ async function bootstrap () {
|
|||||||
app.use(ToastService)
|
app.use(ToastService)
|
||||||
app.use(ConfirmationService)
|
app.use(ConfirmationService)
|
||||||
|
|
||||||
// Registro global de componentes PrimeVue frequentes
|
|
||||||
app.component('Button', Button)
|
app.component('Button', Button)
|
||||||
app.component('InputText', InputText)
|
app.component('InputText', InputText)
|
||||||
app.component('Tag', Tag)
|
app.component('Tag', Tag)
|
||||||
@@ -186,7 +164,6 @@ async function bootstrap () {
|
|||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|
||||||
// ✅ marca boot completo
|
|
||||||
window.__appBootstrapped = true
|
window.__appBootstrapped = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,8 @@ export default function saasMenu (sessionCtx, opts = {}) {
|
|||||||
to: '/saas/docs',
|
to: '/saas/docs',
|
||||||
...docsBadge
|
...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 therapistRoutes from './routes.therapist';
|
||||||
import supervisorRoutes from './routes.supervisor';
|
import supervisorRoutes from './routes.supervisor';
|
||||||
import editorRoutes from './routes.editor';
|
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';
|
import { applyGuards } from './guards';
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
...(Array.isArray(publicRoutes) ? publicRoutes : [publicRoutes]),
|
...(Array.isArray(publicRoutes) ? publicRoutes : [publicRoutes]),
|
||||||
...(Array.isArray(authRoutes) ? authRoutes : [authRoutes]),
|
...(Array.isArray(authRoutes) ? authRoutes : [authRoutes]),
|
||||||
...(Array.isArray(miscRoutes) ? miscRoutes : [miscRoutes]),
|
...(Array.isArray(miscRoutes) ? miscRoutes : [miscRoutes]),
|
||||||
...(Array.isArray(billingRoutes) ? billingRoutes : [billingRoutes]),
|
...(Array.isArray(billingRoutes) ? billingRoutes : [billingRoutes]),
|
||||||
...(Array.isArray(saasRoutes) ? saasRoutes : [saasRoutes]),
|
...(Array.isArray(saasRoutes) ? saasRoutes : [saasRoutes]),
|
||||||
...(Array.isArray(meRoutes) ? meRoutes : [meRoutes]),
|
...(Array.isArray(meRoutes) ? meRoutes : [meRoutes]),
|
||||||
...(Array.isArray(adminRoutes) ? adminRoutes : [adminRoutes]),
|
...(Array.isArray(adminRoutes) ? adminRoutes : [adminRoutes]),
|
||||||
...(Array.isArray(therapistRoutes) ? therapistRoutes : [therapistRoutes]),
|
...(Array.isArray(therapistRoutes) ? therapistRoutes : [therapistRoutes]),
|
||||||
...(Array.isArray(supervisorRoutes) ? supervisorRoutes : [supervisorRoutes]),
|
...(Array.isArray(supervisorRoutes) ? supervisorRoutes : [supervisorRoutes]),
|
||||||
...(Array.isArray(editorRoutes) ? editorRoutes : [editorRoutes]),
|
...(Array.isArray(editorRoutes) ? editorRoutes : [editorRoutes]),
|
||||||
...(Array.isArray(portalRoutes) ? portalRoutes : [portalRoutes]),
|
...(Array.isArray(portalRoutes) ? portalRoutes : [portalRoutes]),
|
||||||
...(Array.isArray(configuracoesRoutes) ? configuracoesRoutes : [configuracoesRoutes]),
|
...(Array.isArray(configuracoesRoutes) ? configuracoesRoutes : [configuracoesRoutes]),
|
||||||
...(Array.isArray(featuresRoutes) ? featuresRoutes : [featuresRoutes]),
|
...(Array.isArray(featuresRoutes) ? featuresRoutes : [featuresRoutes]),
|
||||||
|
|
||||||
// ✅ compat: rota antiga /login → /auth/login (evita 404 se algum trecho legado usar /login)
|
// ✅ compat: rota antiga /login → /auth/login
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: '/login',
|
||||||
redirect: (to) => ({
|
redirect: (to) => ({
|
||||||
path: '/auth/login',
|
path: '/auth/login',
|
||||||
query: to.query || {}
|
query: to.query || {}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
// inserido no routes.misc { path: '/:pathMatch(.*)*', name: 'notfound', component: () => import('@/views/pages/NotFound.vue') }
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
routes,
|
routes,
|
||||||
scrollBehavior(to, from, savedPosition) {
|
scrollBehavior(to, from, savedPosition) {
|
||||||
// volta/avançar do navegador mantém posição
|
|
||||||
if (savedPosition) return savedPosition;
|
if (savedPosition) return savedPosition;
|
||||||
|
|
||||||
// qualquer navegação normal NÃO altera o scroll
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -58,10 +55,7 @@ const router = createRouter({
|
|||||||
/* 🔎 DEBUG: listar todas as rotas registradas */
|
/* 🔎 DEBUG: listar todas as rotas registradas */
|
||||||
console.log(
|
console.log(
|
||||||
'[ROUTES]',
|
'[ROUTES]',
|
||||||
router
|
router.getRoutes().map((r) => r.path).sort()
|
||||||
.getRoutes()
|
|
||||||
.map((r) => r.path)
|
|
||||||
.sort()
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// ===== DEBUG NAV + TRACE (remover depois) =====
|
// ===== DEBUG NAV + TRACE (remover depois) =====
|
||||||
@@ -69,19 +63,11 @@ const _push = router.push.bind(router);
|
|||||||
router.push = async (loc) => {
|
router.push = async (loc) => {
|
||||||
console.log('[router.push]', loc);
|
console.log('[router.push]', loc);
|
||||||
console.trace('[push caller]');
|
console.trace('[push caller]');
|
||||||
|
|
||||||
const res = await _push(loc);
|
const res = await _push(loc);
|
||||||
|
if (isNavigationFailure(res, NavigationFailureType.duplicated)) console.warn('[NAV FAIL] duplicated', res);
|
||||||
if (isNavigationFailure(res, NavigationFailureType.duplicated)) {
|
else if (isNavigationFailure(res, NavigationFailureType.cancelled)) console.warn('[NAV FAIL] cancelled', res);
|
||||||
console.warn('[NAV FAIL] duplicated', res);
|
else if (isNavigationFailure(res, NavigationFailureType.aborted)) console.warn('[NAV FAIL] aborted', res);
|
||||||
} else if (isNavigationFailure(res, NavigationFailureType.cancelled)) {
|
else if (isNavigationFailure(res, NavigationFailureType.redirected)) console.warn('[NAV FAIL] redirected', res);
|
||||||
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;
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -89,22 +75,20 @@ const _replace = router.replace.bind(router);
|
|||||||
router.replace = async (loc) => {
|
router.replace = async (loc) => {
|
||||||
console.log('[router.replace]', loc);
|
console.log('[router.replace]', loc);
|
||||||
console.trace('[replace caller]');
|
console.trace('[replace caller]');
|
||||||
|
|
||||||
const res = await _replace(loc);
|
const res = await _replace(loc);
|
||||||
|
if (isNavigationFailure(res, NavigationFailureType.cancelled)) console.warn('[NAV FAIL replace] cancelled', res);
|
||||||
if (isNavigationFailure(res, NavigationFailureType.cancelled)) {
|
else if (isNavigationFailure(res, NavigationFailureType.aborted)) console.warn('[NAV FAIL replace] aborted', res);
|
||||||
console.warn('[NAV FAIL replace] cancelled', res);
|
else if (isNavigationFailure(res, NavigationFailureType.redirected)) console.warn('[NAV FAIL replace] redirected', 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;
|
return res;
|
||||||
};
|
};
|
||||||
|
|
||||||
router.onError((e) => console.error('[router.onError]', e));
|
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) => {
|
router.beforeEach((to, from) => {
|
||||||
console.log('[beforeEach]', from.fullPath, '->', to.fullPath);
|
console.log('[beforeEach]', from.fullPath, '->', to.fullPath);
|
||||||
return true;
|
return true;
|
||||||
@@ -116,7 +100,6 @@ router.afterEach((to, from, failure) => {
|
|||||||
});
|
});
|
||||||
// ===== /DEBUG NAV + TRACE =====
|
// ===== /DEBUG NAV + TRACE =====
|
||||||
|
|
||||||
// ✅ mantém seus guards, mas agora a landing tem meta.public
|
|
||||||
applyGuards(router);
|
applyGuards(router);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
@@ -81,6 +81,11 @@ export default {
|
|||||||
name: 'saas-support',
|
name: 'saas-support',
|
||||||
component: () => import('@/views/pages/saas/SaasSupportPage.vue'),
|
component: () => import('@/views/pages/saas/SaasSupportPage.vue'),
|
||||||
meta: { requiresAuth: true, saasAdmin: true }
|
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)
|
const recoverySent = ref(false)
|
||||||
|
|
||||||
// carrossel
|
// carrossel
|
||||||
const slides = [
|
const SLIDES_FALLBACK = [
|
||||||
{
|
{
|
||||||
title: 'Gestão clínica simplificada',
|
title: 'Gestão clínica simplificada',
|
||||||
body: 'Agendamentos, prontuários e sessões em um único painel. Foco no que importa: seus pacientes.',
|
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)
|
const currentSlide = ref(0)
|
||||||
let slideInterval = null
|
let slideInterval = null
|
||||||
|
|
||||||
@@ -62,7 +64,7 @@ function goToSlide (i) {
|
|||||||
|
|
||||||
function startCarousel () {
|
function startCarousel () {
|
||||||
slideInterval = setInterval(() => {
|
slideInterval = setInterval(() => {
|
||||||
currentSlide.value = (currentSlide.value + 1) % slides.length
|
currentSlide.value = (currentSlide.value + 1) % slides.value.length
|
||||||
}, 4500)
|
}, 4500)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +72,21 @@ function stopCarousel () {
|
|||||||
if (slideInterval) clearInterval(slideInterval)
|
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(() => {
|
const canSubmit = computed(() => {
|
||||||
return !!email.value?.trim() && !!password.value && !loading.value && !loadingRecovery.value
|
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 preEmail = sessionStorage.getItem('login_prefill_email')
|
||||||
const prePass = sessionStorage.getItem('login_prefill_password')
|
const prePass = sessionStorage.getItem('login_prefill_password')
|
||||||
|
|
||||||
@@ -332,12 +351,12 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<h2 class="text-3xl xl:text-4xl font-bold text-white leading-tight">
|
<div class="text-3xl xl:text-4xl font-bold text-white leading-tight prose prose-invert prose-xl max-w-none"
|
||||||
{{ slides[currentSlide].title }}
|
v-html="slides[currentSlide].title"
|
||||||
</h2>
|
/>
|
||||||
<p class="text-base xl:text-lg text-white/70 leading-relaxed max-w-sm">
|
<div class="text-base xl:text-lg text-white/70 leading-relaxed max-w-sm prose prose-invert max-w-none"
|
||||||
{{ slides[currentSlide].body }}
|
v-html="slides[currentSlide].body"
|
||||||
</p>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
|||||||
@@ -149,25 +149,36 @@ async function sendResetEmail () {
|
|||||||
<Toast />
|
<Toast />
|
||||||
|
|
||||||
<!-- Sentinel -->
|
<!-- Sentinel -->
|
||||||
<div ref="headerSentinelRef" class="sec-sentinel" />
|
<div ref="headerSentinelRef" class="h-px" />
|
||||||
|
|
||||||
<!-- Hero sticky -->
|
<!-- 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
|
||||||
<div class="sec-hero__blobs" aria-hidden="true">
|
ref="headerEl"
|
||||||
<div class="sec-hero__blob sec-hero__blob--1" />
|
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"
|
||||||
<div class="sec-hero__blob sec-hero__blob--2" />
|
: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>
|
||||||
|
|
||||||
<div class="sec-hero__row1">
|
<div class="relative z-10 flex items-center gap-4">
|
||||||
<div class="sec-hero__brand">
|
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||||
<div class="sec-hero__icon"><i class="pi pi-shield text-lg" /></div>
|
<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="min-w-0">
|
||||||
<div class="sec-hero__title">Segurança</div>
|
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Segurança</div>
|
||||||
<div class="sec-hero__sub">Gerencie o acesso e a senha da sua conta</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>
|
||||||
</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" />
|
<span class="h-2 w-2 rounded-full bg-emerald-500 animate-pulse" />
|
||||||
Sessão ativa
|
Sessão ativa
|
||||||
</span>
|
</span>
|
||||||
@@ -178,18 +189,18 @@ async function sendResetEmail () {
|
|||||||
<div class="w-full max-w-2xl space-y-4">
|
<div class="w-full max-w-2xl space-y-4">
|
||||||
|
|
||||||
<!-- Card principal -->
|
<!-- 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 -->
|
<!-- Seção: Trocar senha -->
|
||||||
<div class="px-6 py-5 border-b border-[var(--surface-border)]">
|
<div class="px-6 py-5 border-b border-[var(--surface-border)]">
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-semibold text-[var(--text-color)]">Trocar senha</p>
|
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Trocar senha</div>
|
||||||
<p class="text-xs text-[var(--text-color-secondary)] mt-0.5">
|
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">
|
||||||
Confirme sua senha atual e defina uma nova.
|
Confirme sua senha atual e defina uma nova.
|
||||||
</p>
|
</div>
|
||||||
</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" />
|
<span class="h-1.5 w-1.5 rounded-full bg-emerald-400" />
|
||||||
sessão ativa
|
sessão ativa
|
||||||
</span>
|
</span>
|
||||||
@@ -202,8 +213,8 @@ async function sendResetEmail () {
|
|||||||
<i class="pi pi-check text-emerald-500 text-2xl" />
|
<i class="pi pi-check text-emerald-500 text-2xl" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="font-semibold text-[var(--text-color)]">Senha atualizada!</p>
|
<div class="font-semibold text-[var(--text-color)]">Senha atualizada!</div>
|
||||||
<p class="text-sm text-[var(--text-color-secondary)] mt-1">Redirecionando para o login…</p>
|
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">Redirecionando para o login…</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -212,9 +223,7 @@ async function sendResetEmail () {
|
|||||||
|
|
||||||
<!-- Senha atual -->
|
<!-- Senha atual -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-semibold text-[var(--text-color)] mb-1.5">
|
<div class="text-[1rem] font-semibold text-[var(--text-color)] mb-1.5">Senha atual</div>
|
||||||
Senha atual
|
|
||||||
</label>
|
|
||||||
<Password
|
<Password
|
||||||
v-model="currentPassword"
|
v-model="currentPassword"
|
||||||
placeholder="Digite sua senha atual"
|
placeholder="Digite sua senha atual"
|
||||||
@@ -224,18 +233,16 @@ async function sendResetEmail () {
|
|||||||
inputClass="w-full"
|
inputClass="w-full"
|
||||||
:disabled="loading || loadingReset"
|
: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ê.
|
Necessária para confirmar que é você.
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="h-px bg-[var(--surface-border)]" />
|
<div class="h-px bg-[var(--surface-border)]" />
|
||||||
|
|
||||||
<!-- Nova senha -->
|
<!-- Nova senha -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-semibold text-[var(--text-color)] mb-1.5">
|
<div class="text-[1rem] font-semibold text-[var(--text-color)] mb-1.5">Nova senha</div>
|
||||||
Nova senha
|
|
||||||
</label>
|
|
||||||
<Password
|
<Password
|
||||||
v-model="newPassword"
|
v-model="newPassword"
|
||||||
placeholder="Mínimo 8 caracteres"
|
placeholder="Mínimo 8 caracteres"
|
||||||
@@ -256,18 +263,16 @@ async function sendResetEmail () {
|
|||||||
:class="i <= strengthScore ? strengthColor : 'bg-[var(--surface-border)]'"
|
:class="i <= strengthScore ? strengthColor : 'bg-[var(--surface-border)]'"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xs" :class="strengthTextColor">{{ strengthLabel }}</span>
|
<span class="text-[1rem]" :class="strengthTextColor">{{ strengthLabel }}</span>
|
||||||
</div>
|
</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.
|
Critérios: 8+ caracteres, maiúscula, minúscula e número.
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Confirmar senha -->
|
<!-- Confirmar senha -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-semibold text-[var(--text-color)] mb-1.5">
|
<div class="text-[1rem] font-semibold text-[var(--text-color)] mb-1.5">Confirmar nova senha</div>
|
||||||
Confirmar nova senha
|
|
||||||
</label>
|
|
||||||
<Password
|
<Password
|
||||||
v-model="confirmPassword"
|
v-model="confirmPassword"
|
||||||
placeholder="Repita a nova senha"
|
placeholder="Repita a nova senha"
|
||||||
@@ -277,7 +282,9 @@ async function sendResetEmail () {
|
|||||||
inputClass="w-full"
|
inputClass="w-full"
|
||||||
:disabled="loading || loadingReset"
|
: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'"
|
:class="matchOk ? 'text-emerald-500' : 'text-yellow-500'"
|
||||||
>
|
>
|
||||||
<i :class="matchOk ? 'pi pi-check' : 'pi pi-times'" />
|
<i :class="matchOk ? 'pi pi-check' : 'pi pi-times'" />
|
||||||
@@ -286,11 +293,11 @@ async function sendResetEmail () {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Aviso -->
|
<!-- Aviso -->
|
||||||
<div class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] px-4 py-3 flex items-start gap-3">
|
<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)] text-sm mt-0.5 flex-shrink-0" />
|
<i class="pi pi-info-circle text-[var(--text-color-secondary)] mt-0.5 flex-shrink-0" />
|
||||||
<p class="text-xs text-[var(--text-color-secondary)] leading-relaxed">
|
<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.
|
Ao trocar sua senha, você será desconectado de todos os dispositivos por segurança.
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Ações -->
|
<!-- Ações -->
|
||||||
@@ -317,25 +324,25 @@ async function sendResetEmail () {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Card informativo: dicas -->
|
<!-- 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">
|
<div class="flex items-center gap-2 mb-3">
|
||||||
<i class="pi pi-lightbulb text-sm text-[var(--text-color-secondary)]" />
|
<i class="pi pi-lightbulb text-[var(--text-color-secondary)]" />
|
||||||
<span class="text-sm font-semibold text-[var(--text-color)]">Boas práticas</span>
|
<span class="text-[1rem] font-semibold text-[var(--text-color)]">Boas práticas</span>
|
||||||
</div>
|
</div>
|
||||||
<ul class="space-y-2">
|
<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" />
|
<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.
|
Use pelo menos 8 caracteres com maiúscula, minúscula e número.
|
||||||
</li>
|
</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" />
|
<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).
|
Evite datas, nomes e sequências óbvias (1234, qwerty).
|
||||||
</li>
|
</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" />
|
<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.
|
Se estiver em computador compartilhado, encerre a sessão depois.
|
||||||
</li>
|
</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" />
|
<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.
|
Não reutilize a mesma senha de outros serviços.
|
||||||
</li>
|
</li>
|
||||||
@@ -347,41 +354,4 @@ async function sendResetEmail () {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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>
|
</style>
|
||||||
|
|||||||
@@ -422,211 +422,268 @@ onMounted(fetchMeuPlanoClinic)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-4 md:p-6">
|
<Toast />
|
||||||
<Toast />
|
|
||||||
|
|
||||||
<!-- Topbar padrão -->
|
<!-- Sentinel -->
|
||||||
<div class="mb-4 rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 shadow-sm">
|
<div class="h-px" />
|
||||||
<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>
|
HERO sticky
|
||||||
<small class="text-color-secondary mt-1">
|
═══════════════════════════════════════════════════════ -->
|
||||||
Plano da clínica (tenant) e recursos habilitados.
|
<section
|
||||||
</small>
|
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>
|
||||||
|
<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">
|
<!-- Ações desktop -->
|
||||||
<Button
|
<div class="hidden sm:flex items-center gap-1 flex-shrink-0 ml-auto">
|
||||||
label="Alterar plano"
|
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full flex-shrink-0" :loading="loading" title="Atualizar" @click="fetchMeuPlanoClinic" />
|
||||||
icon="pi pi-arrow-up-right"
|
<Button label="Alterar plano" icon="pi pi-arrow-up-right" class="rounded-full" @click="goUpgradeClinic" />
|
||||||
:loading="loading"
|
</div>
|
||||||
@click="goUpgradeClinic"
|
|
||||||
/>
|
<!-- Ações mobile -->
|
||||||
<Button
|
<div class="flex sm:hidden items-center gap-1 flex-shrink-0 ml-auto">
|
||||||
label="Atualizar"
|
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full flex-shrink-0" :loading="loading" @click="fetchMeuPlanoClinic" />
|
||||||
icon="pi pi-refresh"
|
<Button label="Upgrade" icon="pi pi-arrow-up-right" size="small" class="rounded-full" @click="goUpgradeClinic" />
|
||||||
severity="secondary"
|
</div>
|
||||||
outlined
|
</div>
|
||||||
:loading="loading"
|
</section>
|
||||||
@click="fetchMeuPlanoClinic"
|
|
||||||
/>
|
<!-- ══════════════════════════════════════════════════════
|
||||||
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Card resumo -->
|
<!-- Empty state: sem assinatura -->
|
||||||
<Card class="rounded-[2rem] overflow-hidden">
|
<div
|
||||||
<template #content>
|
v-else-if="!subscription"
|
||||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
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="min-w-0">
|
>
|
||||||
<div class="text-xl md:text-2xl font-semibold leading-tight">
|
<div class="relative">
|
||||||
{{ planName }}
|
<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)]">
|
||||||
</div>
|
<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">
|
<!-- Conteúdo com assinatura -->
|
||||||
<span v-if="priceLabel">{{ priceLabel }}</span>
|
<div v-else class="flex flex-col gap-3">
|
||||||
<span v-else>Preço não encontrado para este intervalo.</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-3 flex flex-wrap gap-2">
|
<!-- ── Assinatura atual ──────────────────────────── -->
|
||||||
<Tag :value="statusLabelPrettyComputed" :severity="statusSeverity(subscription?.status)" />
|
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
|
||||||
<Tag
|
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
|
||||||
v-if="subscription?.cancel_at_period_end"
|
<div class="flex items-center gap-2">
|
||||||
severity="warning"
|
<i class="pi pi-credit-card text-[var(--text-color-secondary)] opacity-60" />
|
||||||
value="Cancelamento agendado"
|
<span class="font-semibold text-[1rem]">Assinatura atual</span>
|
||||||
rounded
|
|
||||||
/>
|
|
||||||
<Tag
|
|
||||||
v-else-if="subscription"
|
|
||||||
severity="success"
|
|
||||||
value="Renovação automática"
|
|
||||||
rounded
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-3 text-sm text-color-secondary">
|
|
||||||
<b>Período:</b> {{ periodLabel }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="cancelHint" class="mt-2 text-sm text-color-secondary">
|
|
||||||
{{ cancelHint }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="plan?.description" class="mt-3 text-sm opacity-80 max-w-3xl">
|
|
||||||
{{ plan.description }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
<div v-if="subscription" class="flex flex-col items-end gap-2">
|
<Tag :value="statusLabelPrettyComputed" :severity="statusSeverity(subscription?.status)" />
|
||||||
<small class="text-color-secondary">subscription_id</small>
|
<Tag v-if="subscription?.cancel_at_period_end" severity="warning" value="Cancelamento agendado" />
|
||||||
<code class="text-xs opacity-80 break-all">
|
<Tag v-else severity="success" value="Renovação automática" />
|
||||||
{{ subscription.id }}
|
|
||||||
</code>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!subscription" class="mt-4 rounded-2xl border border-[var(--surface-border)] p-4 text-sm text-color-secondary">
|
<div class="px-4 py-3 flex flex-wrap gap-x-8 gap-y-3">
|
||||||
Nenhuma assinatura encontrada para este tenant.
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<span class="text-[0.68rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-60">Plano</span>
|
||||||
|
<span class="text-[0.9rem] font-semibold text-[var(--text-color)]">{{ planName }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<span class="text-[0.68rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-60">Valor</span>
|
||||||
|
<span class="text-[1rem] text-[var(--text-color)]">{{ priceLabel || 'Preço não encontrado para este intervalo.' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<span class="text-[0.68rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-60">Período</span>
|
||||||
|
<span class="text-[1rem] text-[var(--text-color)]">{{ periodLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="cancelHint" class="flex flex-col gap-0.5 w-full">
|
||||||
|
<span class="text-[0.68rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-60">Atenção</span>
|
||||||
|
<span class="text-[1rem] text-[var(--text-color-secondary)]">{{ cancelHint }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="plan?.description" class="flex flex-col gap-0.5 w-full">
|
||||||
|
<span class="text-[0.68rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-60">Descrição</span>
|
||||||
|
<span class="text-[1rem] text-[var(--text-color-secondary)]">{{ plan.description }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<span class="text-[0.68rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-60">ID da assinatura</span>
|
||||||
|
<code class="text-[0.75rem] text-[var(--text-color-secondary)] break-all font-mono select-all">{{ subscription.id }}</code>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</Card>
|
|
||||||
|
|
||||||
<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 ─────────────────────── -->
|
||||||
<!-- ✅ Features agrupadas -->
|
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
|
||||||
<Card class="rounded-[2rem] overflow-hidden">
|
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
|
||||||
<template #title>Seu plano inclui</template>
|
<div class="flex items-center gap-2">
|
||||||
<template #content>
|
<i class="pi pi-check-circle text-[var(--text-color-secondary)] opacity-60" />
|
||||||
<div v-if="!subscription" class="text-color-secondary">
|
<span class="font-semibold text-[1rem]">Seu plano inclui</span>
|
||||||
Sem assinatura.
|
</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>
|
||||||
|
|
||||||
<div v-else-if="!features.length" class="text-color-secondary">
|
<div class="p-4">
|
||||||
Nenhuma feature vinculada a este plano.
|
<div v-if="!features.length" class="text-[1rem] text-[var(--text-color-secondary)]">
|
||||||
</div>
|
Nenhuma feature vinculada a este plano.
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-5">
|
<div v-else class="flex flex-col gap-5">
|
||||||
<div
|
<div v-for="g in groupedFeatures" :key="g.module">
|
||||||
v-for="g in groupedFeatures"
|
<!-- Cabeçalho do módulo -->
|
||||||
:key="g.module"
|
<div class="flex items-center gap-2 mb-2">
|
||||||
class="rounded-2xl border border-[var(--surface-border)] overflow-hidden"
|
<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)]" />
|
||||||
<div class="px-4 py-3 bg-[var(--surface-50)] border-b border-[var(--surface-border)] flex items-center justify-between">
|
<span class="text-[1rem] text-[var(--text-color-secondary)]">{{ g.items.length }}</span>
|
||||||
<div class="font-semibold">
|
|
||||||
{{ moduleLabel(g.module) }}
|
|
||||||
</div>
|
</div>
|
||||||
<Tag :value="`${g.items.length}`" severity="secondary" rounded />
|
<!-- Grid de features -->
|
||||||
</div>
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1">
|
||||||
|
<div
|
||||||
<div class="p-4">
|
|
||||||
<ul class="m-0 p-0 list-none space-y-3">
|
|
||||||
<li
|
|
||||||
v-for="f in g.items"
|
v-for="f in g.items"
|
||||||
:key="f.key"
|
: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-circle text-emerald-500 text-[1rem] mt-0.5 flex-shrink-0" />
|
||||||
<i class="pi pi-check mt-1 text-sm text-color-secondary"></i>
|
<div class="min-w-0">
|
||||||
<div class="min-w-0">
|
<div class="text-[1rem] font-medium truncate text-[var(--text-color)]">{{ f.key }}</div>
|
||||||
<div class="font-medium break-words">{{ f.key }}</div>
|
<div v-if="f.description" class="text-[1rem] text-[var(--text-color-secondary)] leading-snug truncate">{{ f.description }}</div>
|
||||||
<div class="text-sm text-color-secondary mt-1" v-if="f.description">
|
|
||||||
{{ f.description }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<div class="text-sm text-color-secondary">
|
|
||||||
{{ fmtDate(ev.created_at) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-60">
|
||||||
<div class="mt-2 text-xs text-color-secondary" v-if="ev.metadata">
|
Agrupamento automático por prefixo da key (ex.: <b>agenda.*</b>, <b>patients.*</b>).
|
||||||
<pre class="m-0 whitespace-pre-wrap break-words">{{ prettyMeta(ev.metadata) }}</pre>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 text-xs text-color-secondary">
|
<!-- ── Histórico ──────────────────────────────── -->
|
||||||
Mostrando até 50 eventos (mais recentes).
|
<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>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -302,236 +302,261 @@ onBeforeUnmount(() => { _observer?.disconnect() })
|
|||||||
<Toast />
|
<Toast />
|
||||||
|
|
||||||
<!-- Sentinel -->
|
<!-- 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 }">
|
HERO sticky
|
||||||
<div class="mplan-hero__blobs" aria-hidden="true">
|
═══════════════════════════════════════════════════════ -->
|
||||||
<div class="mplan-hero__blob mplan-hero__blob--1" />
|
<section
|
||||||
<div class="mplan-hero__blob mplan-hero__blob--2" />
|
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>
|
</div>
|
||||||
|
|
||||||
<!-- Row 1 -->
|
<div class="relative z-[1] flex items-center gap-3">
|
||||||
<div class="mplan-hero__row1">
|
<!-- Brand -->
|
||||||
<div class="mplan-hero__brand">
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
<div class="mplan-hero__icon"><i class="pi pi-credit-card text-lg" /></div>
|
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||||
<div class="min-w-0">
|
<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="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)" />
|
<Tag v-if="subscription" :value="statusLabel(subscription.status)" :severity="statusSeverity(subscription.status)" />
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Desktop (≥1200px) -->
|
<!-- Ações desktop (≥ xl) -->
|
||||||
<div class="hidden xl:flex items-center gap-2 shrink-0">
|
<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 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" />
|
<Button label="Alterar plano" icon="pi pi-arrow-up-right" class="rounded-full" @click="goUpgrade" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile (<1200px) -->
|
<!-- Ações mobile (< xl) -->
|
||||||
<div class="flex xl:hidden items-center gap-2 shrink-0">
|
<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 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" />
|
<Button label="Alterar plano" icon="pi pi-arrow-up-right" size="small" class="rounded-full" @click="goUpgrade" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Divider -->
|
<!-- ══════════════════════════════════════════════════════
|
||||||
<Divider class="mplan-hero__divider my-2" />
|
QUICK-STATS
|
||||||
|
═══════════════════════════════════════════════════════ -->
|
||||||
<!-- Row 2: resumo rápido (oculto no mobile) -->
|
<div class="flex flex-wrap gap-2 px-3 md:px-4 mb-3">
|
||||||
<div class="mplan-hero__row2">
|
<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 v-if="loading" class="flex items-center gap-2 text-sm text-[var(--text-color-secondary)]">
|
<div class="text-[1.1rem] font-bold leading-none text-[var(--text-color)] truncate">{{ planName }}</div>
|
||||||
<i class="pi pi-spin pi-spinner text-xs" /> Carregando…
|
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Plano atual</div>
|
||||||
</div>
|
</div>
|
||||||
<template v-else-if="subscription">
|
<div
|
||||||
<div class="flex flex-wrap items-center gap-3">
|
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] transition-colors duration-150"
|
||||||
<span class="font-semibold text-sm text-[var(--text-color)]">{{ planName }}</span>
|
:class="subscription?.status === 'active' ? 'border-green-500/25 bg-green-500/5' : 'border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]'"
|
||||||
<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">
|
<div
|
||||||
Período: {{ periodLabel }}
|
class="text-[1.1rem] font-bold leading-none truncate"
|
||||||
</span>
|
:class="subscription?.status === 'active' ? 'text-green-500' : 'text-[var(--text-color)]'"
|
||||||
<Tag v-if="subscription.cancel_at_period_end" severity="warning" value="Cancelamento agendado" />
|
>{{ subscription ? statusLabel(subscription.status) : '—' }}</div>
|
||||||
<Tag v-else severity="success" value="Renovação automática" />
|
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">Status</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
<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 v-else class="text-sm text-[var(--text-color-secondary)]">Nenhuma assinatura ativa.</div>
|
<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>
|
||||||
</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 -->
|
<!-- Sem assinatura -->
|
||||||
<div v-if="!loading && !subscription" class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-10 text-center">
|
<div
|
||||||
<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)]">
|
v-else-if="!subscription"
|
||||||
<i class="pi pi-credit-card text-xl" />
|
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-semibold">Nenhuma assinatura encontrada</div>
|
<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="font-bold text-[1rem] text-[var(--text-color)] mb-1">Nenhuma assinatura encontrada</div>
|
||||||
<div class="mt-4">
|
<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>
|
||||||
<Button label="Ver planos" icon="pi pi-arrow-up-right" class="rounded-full" @click="goUpgrade" />
|
|
||||||
</div>
|
</div>
|
||||||
|
<Button label="Ver planos" icon="pi pi-arrow-up-right" class="rounded-full mt-1" @click="goUpgrade" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<template v-else>
|
||||||
|
|
||||||
<!-- Seu plano inclui: features compactas -->
|
<!-- ── Assinatura atual ──────────────────────────── -->
|
||||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
|
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
|
||||||
<div class="px-5 py-4 border-b border-[var(--surface-border)] flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
|
||||||
<div>
|
<div class="flex items-center gap-2">
|
||||||
<div class="font-semibold text-[var(--text-color)]">Seu plano inclui</div>
|
<i class="pi pi-credit-card text-[var(--text-color-secondary)] opacity-60" />
|
||||||
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5">Recursos disponíveis na sua assinatura atual</div>
|
<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>
|
</div>
|
||||||
<Tag v-if="features.length" :value="`${features.length}`" severity="secondary" />
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="px-4 py-3 flex flex-wrap gap-x-8 gap-y-3">
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<span class="text-[0.68rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-60">Plano</span>
|
||||||
|
<span class="text-[0.9rem] font-semibold text-[var(--text-color)]">{{ planName }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<span class="text-[0.68rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-60">Valor</span>
|
||||||
|
<span class="text-[1rem] text-[var(--text-color)]">{{ priceLabel || 'Preço não encontrado para este intervalo.' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<span class="text-[0.68rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-60">Período</span>
|
||||||
|
<span class="text-[1rem] text-[var(--text-color)]">{{ periodLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="plan?.description" class="flex flex-col gap-0.5 w-full">
|
||||||
|
<span class="text-[0.68rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-60">Descrição</span>
|
||||||
|
<span class="text-[1rem] text-[var(--text-color-secondary)]">{{ plan.description }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<span class="text-[0.68rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-60">ID da assinatura</span>
|
||||||
|
<code class="text-[0.75rem] text-[var(--text-color-secondary)] break-all font-mono select-all">{{ subscription.id }}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="p-5">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||||
<div v-if="!subscription" class="text-sm text-[var(--text-color-secondary)]">Sem assinatura.</div>
|
|
||||||
<div v-else-if="!features.length" class="text-sm text-[var(--text-color-secondary)]">Nenhuma feature vinculada a este plano.</div>
|
|
||||||
|
|
||||||
<div v-else class="space-y-5">
|
<!-- ── Seu plano inclui ───────────────────────── -->
|
||||||
<div v-for="g in groupedFeatures" :key="g.module">
|
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
|
||||||
<!-- Cabeçalho do módulo -->
|
<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 mb-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-xs font-bold uppercase tracking-wider text-[var(--text-color-secondary)] opacity-50">
|
<i class="pi pi-check-circle text-[var(--text-color-secondary)] opacity-60" />
|
||||||
{{ moduleLabel(g.module) }}
|
<span class="font-semibold text-[1rem]">Seu plano inclui</span>
|
||||||
</span>
|
</div>
|
||||||
<div class="flex-1 h-px bg-[var(--surface-border)]" />
|
<span
|
||||||
<span class="text-xs text-[var(--text-color-secondary)]">{{ g.items.length }}</span>
|
v-if="features.length"
|
||||||
</div>
|
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="p-4">
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1">
|
<div v-if="!features.length" class="text-[1rem] text-[var(--text-color-secondary)]">Nenhuma feature vinculada a este plano.</div>
|
||||||
<div
|
|
||||||
v-for="f in g.items"
|
<div v-else class="flex flex-col gap-5">
|
||||||
:key="f.key"
|
<div v-for="g in groupedFeatures" :key="g.module">
|
||||||
class="flex items-start gap-2 py-1 px-2 rounded-lg hover:bg-[var(--surface-ground)] transition-colors"
|
<div class="flex items-center gap-2 mb-2">
|
||||||
:title="f.description || f.key"
|
<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)]" />
|
||||||
<i class="pi pi-check-circle text-emerald-500 text-sm mt-0.5 shrink-0" />
|
<span class="text-[1rem] text-[var(--text-color-secondary)]">{{ g.items.length }}</span>
|
||||||
<div class="min-w-0">
|
</div>
|
||||||
<div class="text-sm font-medium truncate text-[var(--text-color)]">{{ f.key }}</div>
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1">
|
||||||
<div v-if="f.description" class="text-xs text-[var(--text-color-secondary)] leading-snug truncate">{{ f.description }}</div>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Histórico -->
|
<!-- ── Histórico ──────────────────────────────── -->
|
||||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
|
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
|
||||||
<div class="px-5 py-4 border-b border-[var(--surface-border)] flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
|
||||||
<div>
|
<div class="flex items-center gap-2">
|
||||||
<div class="font-semibold text-[var(--text-color)]">Histórico</div>
|
<i class="pi pi-history text-[var(--text-color-secondary)] opacity-60" />
|
||||||
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5">Últimos 50 eventos da assinatura</div>
|
<span class="font-semibold text-[1rem]">Histórico</span>
|
||||||
</div>
|
</div>
|
||||||
<Tag v-if="events.length" :value="`${events.length}`" severity="secondary" />
|
<span
|
||||||
</div>
|
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"
|
||||||
<div class="p-5">
|
>{{ events.length }}</span>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-2">
|
<div class="p-4">
|
||||||
<div
|
<div v-if="!events.length" class="py-8 text-center">
|
||||||
v-for="ev in events"
|
<i class="pi pi-history text-2xl text-[var(--text-color-secondary)] opacity-20 mb-2 block" />
|
||||||
:key="ev.id"
|
<div class="text-[1rem] text-[var(--text-color-secondary)]">Sem eventos registrados.</div>
|
||||||
class="rounded-xl border border-[var(--surface-border)] p-3 hover:bg-[var(--surface-ground)] transition-colors"
|
</div>
|
||||||
>
|
|
||||||
<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 v-if="ev.old_plan_id || ev.new_plan_id" class="mt-1.5 text-xs text-[var(--text-color-secondary)] flex items-center gap-1.5 flex-wrap">
|
<div v-else class="flex flex-col gap-2">
|
||||||
<span class="font-medium text-[var(--text-color)]">{{ planKeyOrName(ev.old_plan_id) }}</span>
|
<div
|
||||||
<i class="pi pi-arrow-right text-xs opacity-50" />
|
v-for="ev in events"
|
||||||
<span class="font-medium text-[var(--text-color)]">{{ planKeyOrName(ev.new_plan_id) }}</span>
|
:key="ev.id"
|
||||||
</div>
|
class="rounded-md border border-[var(--surface-border,#e2e8f0)] p-3 hover:bg-[var(--surface-ground,#f8fafc)] transition-colors duration-100"
|
||||||
|
>
|
||||||
<div v-if="ev.reason" class="mt-1 text-xs text-[var(--text-color-secondary)] opacity-70">{{ ev.reason }}</div>
|
<div class="flex items-start justify-between gap-3 flex-wrap">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
<div v-if="ev.metadata" class="mt-1.5">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
<pre class="m-0 text-xs text-[var(--text-color-secondary)] whitespace-pre-wrap break-words opacity-60">{{ prettyMeta(ev.metadata) }}</pre>
|
<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>
|
||||||
|
<div class="text-[1rem] text-[var(--text-color-secondary)] flex-shrink-0">{{ fmtDate(ev.created_at) }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="text-xs text-[var(--text-color-secondary)] shrink-0">{{ fmtDate(ev.created_at) }}</div>
|
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-60 mt-1">
|
||||||
|
Mostrando até 50 eventos (mais recentes).
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Rodapé: subscription ID -->
|
|
||||||
<div v-if="subscription" class="text-xs text-[var(--text-color-secondary)] flex items-center gap-2 flex-wrap">
|
|
||||||
<span>ID da assinatura:</span>
|
|
||||||
<code class="font-mono select-all">{{ subscription.id }}</code>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.mplan-sentinel { height: 1px; }
|
/* (intencionalmente vazio) */
|
||||||
|
|
||||||
.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; }
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -254,169 +254,221 @@ onMounted(loadData)
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-4 md:p-6">
|
<Toast />
|
||||||
<Toast />
|
|
||||||
|
|
||||||
<!-- ✅ Topbar padrão -->
|
<!-- Sentinel -->
|
||||||
<div class="mb-4 rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 shadow-sm">
|
<div class="h-px" />
|
||||||
<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>
|
HERO sticky
|
||||||
<small class="text-color-secondary mt-1">
|
═══════════════════════════════════════════════════════ -->
|
||||||
Escolha seu plano pessoal (Modelo A).
|
<section
|
||||||
</small>
|
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>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 flex-wrap justify-end">
|
<!-- Busca desktop -->
|
||||||
<Button
|
<div class="hidden md:flex flex-1 min-w-[180px] max-w-xs">
|
||||||
label="Voltar"
|
<IconField class="w-full">
|
||||||
icon="pi pi-arrow-left"
|
<InputIcon class="pi pi-search" />
|
||||||
severity="secondary"
|
<InputText v-model="q" placeholder="Buscar plano..." class="w-full" autocomplete="off" :disabled="loading || saving" />
|
||||||
outlined
|
</IconField>
|
||||||
:disabled="saving"
|
</div>
|
||||||
@click="goBack"
|
|
||||||
/>
|
<!-- Ações -->
|
||||||
<Button
|
<div class="flex items-center gap-1 flex-shrink-0 ml-auto">
|
||||||
label="Atualizar"
|
<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" />
|
||||||
icon="pi pi-refresh"
|
<Button label="Voltar" icon="pi pi-arrow-left" severity="secondary" outlined class="rounded-full" :disabled="saving" @click="goBack" />
|
||||||
severity="secondary"
|
|
||||||
outlined
|
|
||||||
:loading="loading"
|
|
||||||
:disabled="saving"
|
|
||||||
@click="loadData"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</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
|
<Tag
|
||||||
v-if="currentSub"
|
v-if="currentSub"
|
||||||
:value="`Plano atual: ${currentSub.plan_key} • ${intervalLabel(currentSub.interval)} • ${currentSub.status}`"
|
:value="`Atual: ${currentSub.plan_key} · ${intervalLabel(currentSub.interval)}`"
|
||||||
severity="success"
|
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">
|
</div>
|
||||||
<small class="text-color-secondary">Exibição de preço</small>
|
</section>
|
||||||
<SelectButton
|
|
||||||
v-model="billingInterval"
|
<!-- ══════════════════════════════════════════════════════
|
||||||
:options="intervalOptions"
|
QUICK-STATS
|
||||||
optionLabel="label"
|
═══════════════════════════════════════════════════════ -->
|
||||||
optionValue="value"
|
<div class="flex flex-wrap gap-2 px-3 md:px-4 mb-3">
|
||||||
:disabled="loading || saving"
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ✅ Busca padrão FloatLabel -->
|
<!-- Empty state -->
|
||||||
<Card class="rounded-[2rem] overflow-hidden mb-4">
|
<div
|
||||||
<template #content>
|
v-else-if="!filteredPlans.length"
|
||||||
<div class="flex flex-wrap items-center gap-3 justify-between">
|
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="flex flex-col">
|
>
|
||||||
<div class="font-semibold">Planos disponíveis</div>
|
<div class="relative">
|
||||||
<small class="text-color-secondary">
|
<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)]">
|
||||||
Filtre por nome/key/descrição e selecione.
|
<i class="pi pi-box text-3xl opacity-30" />
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
<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">
|
||||||
</Card>
|
<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 />
|
<!-- Grid de planos -->
|
||||||
|
<div v-else class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-3">
|
||||||
<!-- ✅ Cards estilo vitrine -->
|
<div
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4 mt-4">
|
|
||||||
<Card
|
|
||||||
v-for="p in filteredPlans"
|
v-for="p in filteredPlans"
|
||||||
:key="p.id"
|
:key="p.id"
|
||||||
:class="[
|
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)]"
|
||||||
'rounded-[2rem] overflow-hidden border border-[var(--surface-border)]',
|
:class="currentSub?.plan_id === p.id
|
||||||
currentSub?.plan_id === p.id ? 'ring-1 ring-emerald-500/25 md:-translate-y-1 md:scale-[1.01]' : ''
|
? 'border-emerald-400/40 ring-1 ring-emerald-500/20'
|
||||||
]"
|
: 'border-[var(--surface-border,#e2e8f0)]'"
|
||||||
>
|
>
|
||||||
<template #title>
|
<!-- Cabeçalho do card -->
|
||||||
<div class="flex items-center justify-between gap-2">
|
<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="min-w-0">
|
||||||
<div class="font-semibold truncate">{{ p.name || p.key }}</div>
|
<div class="font-bold text-[0.9rem] text-[var(--text-color)] truncate">{{ p.name || p.key }}</div>
|
||||||
<small class="text-color-secondary">{{ p.key }}</small>
|
<div class="text-[1rem] text-[var(--text-color-secondary)]">{{ p.key }}</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Tag v-if="currentSub?.plan_id === p.id" severity="success" value="Atual" rounded />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
<Tag v-if="currentSub?.plan_id === p.id" severity="success" value="Atual" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<template #content>
|
<!-- Corpo do card -->
|
||||||
<div class="text-sm text-color-secondary" v-if="p.description">
|
<div class="p-4 flex flex-col gap-4 flex-1">
|
||||||
{{ p.description }}
|
<!-- 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>
|
||||||
|
|
||||||
<div class="mt-4">
|
<!-- Ações -->
|
||||||
<div class="text-4xl font-semibold leading-none">
|
<div class="flex flex-col gap-2 mt-auto">
|
||||||
{{ 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">
|
|
||||||
<Button
|
<Button
|
||||||
:label="billingInterval === 'month' ? 'Escolher mensal' : 'Escolher anual'"
|
:label="billingInterval === 'month' ? 'Escolher mensal' : 'Escolher anual'"
|
||||||
icon="pi pi-check"
|
icon="pi pi-check"
|
||||||
|
class="rounded-full w-full"
|
||||||
:loading="saving"
|
:loading="saving"
|
||||||
:disabled="loading || saving"
|
:disabled="loading || saving"
|
||||||
@click="choosePlan(p, billingInterval)"
|
@click="choosePlan(p, billingInterval)"
|
||||||
/>
|
/>
|
||||||
|
<div class="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
label="Mensal"
|
label="Mensal"
|
||||||
severity="secondary"
|
severity="secondary"
|
||||||
outlined
|
outlined
|
||||||
:disabled="loading || saving"
|
class="rounded-full flex-1"
|
||||||
@click="choosePlan(p, 'month')"
|
:disabled="loading || saving"
|
||||||
/>
|
@click="choosePlan(p, 'month')"
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
label="Anual"
|
label="Anual"
|
||||||
severity="secondary"
|
severity="secondary"
|
||||||
outlined
|
outlined
|
||||||
:disabled="loading || saving"
|
class="rounded-full flex-1"
|
||||||
@click="choosePlan(p, 'year')"
|
:disabled="loading || saving"
|
||||||
/>
|
@click="choosePlan(p, 'year')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-3 text-xs text-color-secondary">
|
<!-- Status do preço -->
|
||||||
<span v-if="priceFor(p.id, billingInterval)">
|
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-60">
|
||||||
Preço ativo encontrado para {{ intervalLabel(billingInterval) }}.
|
<span v-if="priceFor(p.id, billingInterval)">Preço ativo encontrado para {{ intervalLabel(billingInterval) }}.</span>
|
||||||
</span>
|
<span v-else>Sem preço ativo para {{ intervalLabel(billingInterval) }}.</span>
|
||||||
<span v-else>
|
|
||||||
Sem preço ativo para {{ intervalLabel(billingInterval) }}.
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="!filteredPlans.length && !loading" class="mt-4 text-sm text-color-secondary">
|
|
||||||
Nenhum plano encontrado.
|
|
||||||
</div>
|
|
||||||
</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>
|
<script setup>
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
@@ -311,174 +311,292 @@ watch(() => tenantStore.user?.id, () => { fetchAll() })
|
|||||||
<template>
|
<template>
|
||||||
<Toast />
|
<Toast />
|
||||||
|
|
||||||
<div class="p-4 md:p-6 lg:p-8">
|
<!-- Sentinel -->
|
||||||
<!-- HERO -->
|
<div class="h-px" />
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="relative flex flex-col gap-4">
|
<!-- ══════════════════════════════════════════════════════
|
||||||
<div class="flex items-start justify-between gap-3 flex-wrap">
|
HERO sticky
|
||||||
<div class="min-w-0">
|
═══════════════════════════════════════════════════════ -->
|
||||||
<div class="text-2xl md:text-3xl font-semibold leading-tight">Atualize seu plano</div>
|
<section
|
||||||
<div class="mt-1 text-sm md:text-base text-[var(--text-color-secondary)]">
|
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"
|
||||||
Contexto: <b>{{ isTherapist ? 'Terapeuta' : 'Clínica' }}</b>
|
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
|
||||||
<span class="mx-2 opacity-50">•</span>
|
>
|
||||||
Plano atual: <b>{{ currentPlanKey || '—' }}</b>
|
<!-- Blobs -->
|
||||||
</div>
|
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
|
||||||
</div>
|
<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">
|
<div class="relative z-[1] flex flex-col gap-2.5">
|
||||||
<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" />
|
<!-- Linha 1: brand + busca + ações -->
|
||||||
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined :loading="loading" :disabled="upgrading" @click="fetchAll" />
|
<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>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- recurso bloqueado -->
|
<!-- Busca desktop -->
|
||||||
<div
|
<div class="hidden md:flex flex-1 min-w-[180px] max-w-xs">
|
||||||
v-if="requestedFeatureLabel"
|
<IconField class="w-full">
|
||||||
class="relative overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4"
|
<InputIcon class="pi pi-search" />
|
||||||
>
|
<InputText v-model="q" placeholder="Buscar plano..." class="w-full" autocomplete="off" :disabled="loading || upgrading" />
|
||||||
<div class="relative flex flex-col md:flex-row md:items-center md:justify-between gap-3">
|
</IconField>
|
||||||
<div class="min-w-0">
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Tag severity="warning" value="Recurso bloqueado" />
|
<!-- Ações -->
|
||||||
<div class="font-semibold truncate">{{ requestedFeatureLabel }}</div>
|
<div class="flex items-center gap-1 flex-shrink-0 ml-auto">
|
||||||
</div>
|
<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" />
|
||||||
<div class="mt-1 text-sm text-[var(--text-color-secondary)]">
|
<Button label="Assinatura" icon="pi pi-credit-card" severity="secondary" outlined class="rounded-full hidden sm:inline-flex" :disabled="upgrading" @click="goBilling" />
|
||||||
Esse recurso depende da feature <b>{{ requestedFeature }}</b>.
|
<Button label="Voltar" icon="pi pi-arrow-left" severity="secondary" outlined class="rounded-full" :disabled="upgrading" @click="goBack" />
|
||||||
</div>
|
</div>
|
||||||
</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
|
<Button
|
||||||
label="Ver planos"
|
v-if="currentPlanId !== p.id"
|
||||||
icon="pi pi-arrow-down"
|
: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"
|
severity="secondary"
|
||||||
outlined
|
outlined
|
||||||
@click="() => document.getElementById('plans-grid')?.scrollIntoView({ behavior: 'smooth', block: 'start' })"
|
class="w-full rounded-full"
|
||||||
|
disabled
|
||||||
/>
|
/>
|
||||||
</div>
|
<Button
|
||||||
</div>
|
v-if="String(p.key).toLowerCase() !== 'free'"
|
||||||
|
label="Falar com suporte"
|
||||||
<!-- busca + intervalo -->
|
icon="pi pi-comments"
|
||||||
<div class="flex flex-col md:flex-row md:items-end md:justify-between gap-3">
|
severity="secondary"
|
||||||
<div class="w-full md:w-[420px]">
|
outlined
|
||||||
<FloatLabel variant="on" class="w-full">
|
class="w-full rounded-full"
|
||||||
<IconField class="w-full">
|
:disabled="upgrading"
|
||||||
<InputIcon class="pi pi-search" />
|
@click="contactSupport"
|
||||||
<InputText v-model="q" id="upgrade_search" class="w-full pr-10" variant="filled" :disabled="loading || upgrading" />
|
/>
|
||||||
</IconField>
|
<div class="text-center text-[1rem] text-[var(--text-color-secondary)] opacity-60">
|
||||||
<label for="upgrade_search">Buscar plano...</label>
|
Cancele quando quiser. Sem burocracia.
|
||||||
</FloatLabel>
|
</div>
|
||||||
</div>
|
<div v-if="!subscription?.id" class="text-center text-[1rem] text-amber-500">
|
||||||
<div class="flex flex-col items-start md:items-end gap-2">
|
⚠ Sem assinatura ativa — clique em <b>Assinatura</b> para ativar/criar.
|
||||||
<small class="text-[var(--text-color-secondary)]">Exibição de preço</small>
|
</div>
|
||||||
<SelectButton v-model="billingInterval" :options="intervalOptions" optionLabel="label" optionValue="value" :disabled="loading || upgrading" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- PLANOS -->
|
<div class="mt-4 text-[1rem] text-[var(--text-color-secondary)] opacity-60">
|
||||||
<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)]">
|
|
||||||
Alguns recursos PRO podem depender de configuração inicial (ex.: SMS exige provedor).
|
Alguns recursos PRO podem depender de configuração inicial (ex.: SMS exige provedor).
|
||||||
</div>
|
</div>
|
||||||
</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 RecentSalesWidget from '@/components/dashboard/RecentSalesWidget.vue';
|
||||||
import RevenueStreamWidget from '@/components/dashboard/RevenueStreamWidget.vue';
|
import RevenueStreamWidget from '@/components/dashboard/RevenueStreamWidget.vue';
|
||||||
import StatsWidget from '@/components/dashboard/StatsWidget.vue';
|
import StatsWidget from '@/components/dashboard/StatsWidget.vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -13,8 +16,9 @@ import StatsWidget from '@/components/dashboard/StatsWidget.vue';
|
|||||||
<span class="text-primary font-medium">Área</span>
|
<span class="text-primary font-medium">Área</span>
|
||||||
<span class="text-muted-color"> da Clínica</span>
|
<span class="text-muted-color"> da Clínica</span>
|
||||||
</div>
|
</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">
|
<div class="flex items-center gap-2">
|
||||||
<i class="pi pi-user text-blue-500 text-xl!"></i>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -324,268 +324,256 @@ watch(
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-4 md:p-6">
|
<Toast />
|
||||||
<Toast />
|
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Sentinel -->
|
||||||
<div class="mb-4 overflow-hidden rounded-[2rem] border border-[var(--surface-border)] bg-[var(--surface-card)]">
|
<div class=”h-px” />
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="relative flex flex-col gap-2">
|
<!-- Hero sticky -->
|
||||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
<div
|
||||||
<div class="min-w-0">
|
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”
|
||||||
<h1 class="text-xl md:text-2xl font-semibold leading-tight">Tipos de Clínica</h1>
|
:style=”{ top: 'var(--layout-sticky-top, 56px)' }”
|
||||||
<p class="mt-1 text-sm opacity-80">
|
>
|
||||||
Ative/desative recursos por clínica. Isso controla menu, rotas (guard) e acesso no banco (RLS).
|
<div class=”absolute inset-0 pointer-events-none overflow-hidden” aria-hidden=”true”>
|
||||||
</p>
|
<div class=”absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10” />
|
||||||
</div>
|
<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 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>
|
|
||||||
</div>
|
</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
|
<div
|
||||||
v-if="!isOwner && tenantReady"
|
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"
|
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" />
|
<i class=”pi pi-lock text-amber-400 shrink-0” />
|
||||||
<span class="opacity-90">
|
<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>.
|
Você está visualizando as configurações da clínica em <b>modo somente leitura</b>.
|
||||||
Apenas o administrador pode ativar ou desativar módulos.
|
Apenas o administrador pode ativar ou desativar módulos.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Presets -->
|
<!-- Presets -->
|
||||||
<div class="mb-4 grid grid-cols-1 md:grid-cols-3 gap-3">
|
<div class=”grid grid-cols-1 md:grid-cols-3 gap-3”>
|
||||||
<Card class="rounded-[2rem]">
|
<div class=”rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5”>
|
||||||
<template #content>
|
<div class=”flex items-start justify-between gap-3”>
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class=”min-w-0”>
|
||||||
<div class="min-w-0">
|
<div class=”text-[1rem] font-semibold text-[var(--text-color)]”>Preset: Coworking</div>
|
||||||
<div class="text-sm font-semibold">Preset: Coworking</div>
|
<div class=”mt-1 text-[1rem] text-[var(--text-color-secondary)]”>
|
||||||
<div class="mt-1 text-xs opacity-80">
|
Para aluguel de salas: sem pacientes, com salas.
|
||||||
Para aluguel de salas: sem pacientes, com salas.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
label="Aplicar"
|
|
||||||
severity="secondary"
|
|
||||||
outlined
|
|
||||||
:loading="applyingPreset"
|
|
||||||
:disabled="!isOwner || !tenantReady || loading || !!savingKey"
|
|
||||||
@click="applyPreset('coworking')"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
<Button
|
||||||
</Card>
|
size=”small”
|
||||||
|
label=”Aplicar”
|
||||||
|
severity=”secondary”
|
||||||
|
outlined
|
||||||
|
:loading=”applyingPreset”
|
||||||
|
:disabled=”!isOwner || !tenantReady || loading || !!savingKey”
|
||||||
|
@click=”applyPreset('coworking')”
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card class="rounded-[2rem]">
|
<div class=”rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5”>
|
||||||
<template #content>
|
<div class=”flex items-start justify-between gap-3”>
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class=”min-w-0”>
|
||||||
<div class="min-w-0">
|
<div class=”text-[1rem] font-semibold text-[var(--text-color)]”>Preset: Clínica com recepção</div>
|
||||||
<div class="text-sm font-semibold">Preset: Clínica com recepção</div>
|
<div class=”mt-1 text-[1rem] text-[var(--text-color-secondary)]”>
|
||||||
<div class="mt-1 text-xs opacity-80">
|
Para secretária gerenciar agenda (pacientes opcional).
|
||||||
Para secretária gerenciar agenda (pacientes opcional).
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
label="Aplicar"
|
|
||||||
severity="secondary"
|
|
||||||
outlined
|
|
||||||
:loading="applyingPreset"
|
|
||||||
:disabled="!isOwner || !tenantReady || loading || !!savingKey"
|
|
||||||
@click="applyPreset('reception')"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
<Button
|
||||||
</Card>
|
size=”small”
|
||||||
|
label=”Aplicar”
|
||||||
|
severity=”secondary”
|
||||||
|
outlined
|
||||||
|
:loading=”applyingPreset”
|
||||||
|
:disabled=”!isOwner || !tenantReady || loading || !!savingKey”
|
||||||
|
@click=”applyPreset('reception')”
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card class="rounded-[2rem]">
|
<div class=”rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5”>
|
||||||
<template #content>
|
<div class=”flex items-start justify-between gap-3”>
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class=”min-w-0”>
|
||||||
<div class="min-w-0">
|
<div class=”text-[1rem] font-semibold text-[var(--text-color)]”>Preset: Clínica completa</div>
|
||||||
<div class="text-sm font-semibold">Preset: Clínica completa</div>
|
<div class=”mt-1 text-[1rem] text-[var(--text-color-secondary)]”>
|
||||||
<div class="mt-1 text-xs opacity-80">
|
Pacientes + recepção + salas (se quiser).
|
||||||
Pacientes + recepção + salas (se quiser).
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
label="Aplicar"
|
|
||||||
severity="secondary"
|
|
||||||
outlined
|
|
||||||
:loading="applyingPreset"
|
|
||||||
:disabled="!isOwner || !tenantReady || loading || !!savingKey"
|
|
||||||
@click="applyPreset('full')"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
<Button
|
||||||
</Card>
|
size=”small”
|
||||||
|
label=”Aplicar”
|
||||||
|
severity=”secondary”
|
||||||
|
outlined
|
||||||
|
:loading=”applyingPreset”
|
||||||
|
:disabled=”!isOwner || !tenantReady || loading || !!savingKey”
|
||||||
|
@click=”applyPreset('full')”
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Modules -->
|
<!-- Modules -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
<div class=”grid grid-cols-1 lg:grid-cols-2 gap-3”>
|
||||||
<Card class="rounded-[2rem]">
|
<div class=”rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5”>
|
||||||
<template #content>
|
<ModuleRow
|
||||||
<ModuleRow
|
title=”Pacientes”
|
||||||
title="Pacientes"
|
desc=”Habilita gestão de pacientes por clínica. Todo paciente tem um responsável (therapist).”
|
||||||
desc="Habilita gestão de pacientes por clínica. Todo paciente tem um responsável (therapist)."
|
icon=”pi pi-users”
|
||||||
icon="pi pi-users"
|
:enabled=”isOn('patients')”
|
||||||
:enabled="isOn('patients')"
|
:loading=”savingKey === 'patients'”
|
||||||
:loading="savingKey === 'patients'"
|
:disabled=”isLocked('patients')”
|
||||||
:disabled="isLocked('patients')"
|
@toggle=”toggle('patients')”
|
||||||
@toggle="toggle('patients')"
|
/>
|
||||||
/>
|
<div
|
||||||
<div
|
v-if=”planDenied.has('patients')”
|
||||||
v-if="planDenied.has('patients')"
|
class=”mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90”
|
||||||
class="mt-3 text-xs rounded-2xl border border-[var(--surface-border)] p-3 opacity-90"
|
>
|
||||||
>
|
<i class=”pi pi-lock mr-2” />
|
||||||
<i class="pi pi-lock mr-2" />
|
Este módulo foi bloqueado pelo plano atual do tenant.
|
||||||
Este módulo foi bloqueado pelo plano atual do tenant.
|
</div>
|
||||||
</div>
|
<Divider class=”my-4” />
|
||||||
<Divider class="my-4" />
|
<div class=”text-[1rem] text-[var(--text-color-secondary)] leading-relaxed”>
|
||||||
<div class="text-xs opacity-80 leading-relaxed">
|
Quando desligado:
|
||||||
Quando desligado:
|
<ul class=”mt-2 list-disc pl-5 space-y-1”>
|
||||||
<ul class="mt-2 list-disc pl-5 space-y-1">
|
<li>Menu “Pacientes” some.</li>
|
||||||
<li>Menu “Pacientes” some.</li>
|
<li>Rotas com <span class=”font-mono”>meta.tenantFeature = 'patients'</span> redirecionam pra cá.</li>
|
||||||
<li>Rotas com <span class="font-mono">meta.tenantFeature = 'patients'</span> redirecionam pra cá.</li>
|
<li>RLS bloqueia acesso direto no banco.</li>
|
||||||
<li>RLS bloqueia acesso direto no banco.</li>
|
</ul>
|
||||||
</ul>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card class="rounded-[2rem]">
|
<div class=”rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5”>
|
||||||
<template #content>
|
<ModuleRow
|
||||||
<ModuleRow
|
title=”Recepção / Secretária”
|
||||||
title="Recepção / Secretária"
|
desc=”Permite um papel de secretária gerenciar a agenda dos profissionais (sem precisar ver tudo do paciente).”
|
||||||
desc="Permite um papel de secretária gerenciar a agenda dos profissionais (sem precisar ver tudo do paciente)."
|
icon=”pi pi-briefcase”
|
||||||
icon="pi pi-briefcase"
|
:enabled=”isOn('shared_reception')”
|
||||||
:enabled="isOn('shared_reception')"
|
:loading=”savingKey === 'shared_reception'”
|
||||||
:loading="savingKey === 'shared_reception'"
|
:disabled=”isLocked('shared_reception')”
|
||||||
:disabled="isLocked('shared_reception')"
|
@toggle=”toggle('shared_reception')”
|
||||||
@toggle="toggle('shared_reception')"
|
/>
|
||||||
/>
|
<div
|
||||||
<div
|
v-if=”planDenied.has('shared_reception')”
|
||||||
v-if="planDenied.has('shared_reception')"
|
class=”mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90”
|
||||||
class="mt-3 text-xs rounded-2xl border border-[var(--surface-border)] p-3 opacity-90"
|
>
|
||||||
>
|
<i class=”pi pi-lock mr-2” />
|
||||||
<i class="pi pi-lock mr-2" />
|
Este módulo foi bloqueado pelo plano atual do tenant.
|
||||||
Este módulo foi bloqueado pelo plano atual do tenant.
|
</div>
|
||||||
</div>
|
<Divider class=”my-4” />
|
||||||
<Divider class="my-4" />
|
<div class=”text-[1rem] text-[var(--text-color-secondary)] leading-relaxed”>
|
||||||
<div class="text-xs opacity-80 leading-relaxed">
|
Observação: este módulo é “produto” (UX + permissões). A base aqui é só o toggle.
|
||||||
Observação: este módulo é “produto” (UX + permissões). A base aqui é só o toggle.
|
Depois a gente cria:
|
||||||
Depois a gente cria:
|
<ul class=”mt-2 list-disc pl-5 space-y-1”>
|
||||||
<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>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>policies e telas para a secretária</li>
|
<li>nível de visibilidade do paciente na agenda</li>
|
||||||
<li>nível de visibilidade do paciente na agenda</li>
|
</ul>
|
||||||
</ul>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card class="rounded-[2rem]">
|
<div class=”rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5”>
|
||||||
<template #content>
|
<ModuleRow
|
||||||
<ModuleRow
|
title=”Salas / Coworking”
|
||||||
title="Salas / Coworking"
|
desc=”Habilita cadastro e reserva de salas/recursos no agendamento.”
|
||||||
desc="Habilita cadastro e reserva de salas/recursos no agendamento."
|
icon=”pi pi-building”
|
||||||
icon="pi pi-building"
|
:enabled=”isOn('rooms')”
|
||||||
:enabled="isOn('rooms')"
|
:loading=”savingKey === 'rooms'”
|
||||||
:loading="savingKey === 'rooms'"
|
:disabled=”isLocked('rooms')”
|
||||||
:disabled="isLocked('rooms')"
|
@toggle=”toggle('rooms')”
|
||||||
@toggle="toggle('rooms')"
|
/>
|
||||||
/>
|
<div
|
||||||
<div
|
v-if=”planDenied.has('rooms')”
|
||||||
v-if="planDenied.has('rooms')"
|
class=”mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90”
|
||||||
class="mt-3 text-xs rounded-2xl border border-[var(--surface-border)] p-3 opacity-90"
|
>
|
||||||
>
|
<i class=”pi pi-lock mr-2” />
|
||||||
<i class="pi pi-lock mr-2" />
|
Este módulo foi bloqueado pelo plano atual do tenant.
|
||||||
Este módulo foi bloqueado pelo plano atual do tenant.
|
</div>
|
||||||
</div>
|
<Divider class=”my-4” />
|
||||||
<Divider class="my-4" />
|
<div class=”text-[1rem] text-[var(--text-color-secondary)] leading-relaxed”>
|
||||||
<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.
|
||||||
Isso prepara o terreno para a clínica operar como locação de sala, com agenda vinculando sala + profissional.
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card class="rounded-[2rem]">
|
<div class=”rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5”>
|
||||||
<template #content>
|
<ModuleRow
|
||||||
<ModuleRow
|
title=”Link externo de cadastro”
|
||||||
title="Link externo de cadastro"
|
desc=”Libera fluxo público de intake/cadastro externo para a clínica.”
|
||||||
desc="Libera fluxo público de intake/cadastro externo para a clínica."
|
icon=”pi pi-link”
|
||||||
icon="pi pi-link"
|
:enabled=”isOn('intake_public')”
|
||||||
:enabled="isOn('intake_public')"
|
:loading=”savingKey === 'intake_public'”
|
||||||
:loading="savingKey === 'intake_public'"
|
:disabled=”isLocked('intake_public')”
|
||||||
:disabled="isLocked('intake_public')"
|
@toggle=”toggle('intake_public')”
|
||||||
@toggle="toggle('intake_public')"
|
/>
|
||||||
/>
|
<div
|
||||||
<div
|
v-if=”planDenied.has('intake_public')”
|
||||||
v-if="planDenied.has('intake_public')"
|
class=”mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90”
|
||||||
class="mt-3 text-xs rounded-2xl border border-[var(--surface-border)] p-3 opacity-90"
|
>
|
||||||
>
|
<i class=”pi pi-lock mr-2” />
|
||||||
<i class="pi pi-lock mr-2" />
|
Este módulo foi bloqueado pelo plano atual do tenant.
|
||||||
Este módulo foi bloqueado pelo plano atual do tenant.
|
</div>
|
||||||
</div>
|
<Divider class=”my-4” />
|
||||||
<Divider class="my-4" />
|
<div class=”text-[1rem] text-[var(--text-color-secondary)] leading-relaxed”>
|
||||||
<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.
|
||||||
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>
|
||||||
</template>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* (sem estilos adicionais por enquanto) */
|
|
||||||
</style>
|
</style>
|
||||||
@@ -51,6 +51,12 @@ const loadingHistory = ref(false)
|
|||||||
const loadHistoryError = ref('')
|
const loadHistoryError = ref('')
|
||||||
const historySearch = 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 filteredHistory = computed(() => {
|
||||||
const q = (historySearch.value || '').trim().toLowerCase()
|
const q = (historySearch.value || '').trim().toLowerCase()
|
||||||
const base = history.value || []
|
const base = history.value || []
|
||||||
@@ -96,8 +102,12 @@ const activeTenantKind = ref(null)
|
|||||||
const canManage = computed(() => {
|
const canManage = computed(() => {
|
||||||
const r = (effectiveRole.value || '').toString()
|
const r = (effectiveRole.value || '').toString()
|
||||||
const isAdmin = r === 'clinic_admin' || r === 'tenant_admin'
|
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)
|
if (!isAdmin) return false
|
||||||
return isAdmin && activeTenantKind.value === 'clinic'
|
// 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 = [
|
const DEV_TEST_EMAILS = [
|
||||||
@@ -733,91 +743,76 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="p-4 md:p-6">
|
<Toast />
|
||||||
<Toast />
|
<ConfirmDialog />
|
||||||
<ConfirmDialog />
|
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Sentinel -->
|
||||||
<div class="mb-4 overflow-hidden rounded-[2rem] border border-[var(--surface-border)] bg-[var(--surface-card)]">
|
<div class="h-px" />
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="relative flex flex-col gap-2">
|
<!-- Hero sticky -->
|
||||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
<div
|
||||||
<div class="min-w-0">
|
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"
|
||||||
<div class="text-xl md:text-2xl font-semibold leading-tight">
|
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
|
||||||
Profissionais da clínica
|
>
|
||||||
</div>
|
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
|
||||||
<div class="opacity-70 text-sm">
|
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
|
||||||
Gerencie terapeutas e secretarias vinculados ao seu tenant.
|
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
|
||||||
</div>
|
<div class="absolute rounded-full blur-[70px] w-72 h-72 -bottom-20 right-24 bg-fuchsia-400/10" />
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 🔎 Aviso sobre logins de teste -->
|
<div class="relative z-10 flex flex-col gap-2">
|
||||||
<!-- 🔎 Aviso sobre logins de teste + atalhos de convite -->
|
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||||
<div
|
<div class="min-w-0">
|
||||||
class="mb-4 overflow-hidden rounded-[2rem] border border-[var(--surface-border)] bg-[color-mix(in_srgb,var(--surface-card),transparent_18%)]"
|
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Profissionais da clínica</div>
|
||||||
>
|
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">
|
||||||
|
Gerencie terapeutas e secretarias vinculados ao seu tenant.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 flex-wrap 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 + 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="p-5 md:p-6">
|
||||||
<div class="flex items-start gap-3">
|
<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" />
|
<i class="pi pi-info-circle opacity-70" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -826,18 +821,18 @@ onMounted(async () => {
|
|||||||
Logins de teste (ambiente de desenvolvimento)
|
Logins de teste (ambiente de desenvolvimento)
|
||||||
</div>
|
</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)
|
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>).
|
estão disponíveis na tela inicial do sistema (<span class="font-mono">HomeCards</span>).
|
||||||
</div>
|
</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.
|
Para utilizá-las, basta realizar <b>logout da sessão atual</b> e selecionar o perfil desejado na tela inicial.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ✅ Atalhos: abrir dialog e já preencher o email -->
|
<!-- ✅ Atalhos: abrir dialog e já preencher o email -->
|
||||||
<div class="mt-4 flex flex-wrap items-center gap-2">
|
<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
|
<Button
|
||||||
label="Convidar therapist2"
|
label="Convidar therapist2"
|
||||||
@@ -872,13 +867,13 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<!-- Links de convite pendentes para testes -->
|
<!-- Links de convite pendentes para testes -->
|
||||||
<div class="mt-3 flex flex-col gap-1">
|
<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
|
<div
|
||||||
v-for="item in devInviteLinks"
|
v-for="item in devInviteLinks"
|
||||||
:key="item.email"
|
:key="item.email"
|
||||||
class="flex items-center gap-2 flex-wrap"
|
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
|
<Button
|
||||||
label="Copiar link de convite"
|
label="Copiar link de convite"
|
||||||
icon="pi pi-copy"
|
icon="pi pi-copy"
|
||||||
@@ -888,7 +883,7 @@ onMounted(async () => {
|
|||||||
:disabled="!item.token"
|
:disabled="!item.token"
|
||||||
@click="copyInviteLink(item)"
|
@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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -897,16 +892,16 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ✅ DOCUMENTAÇÃO INTERNA (visível na tela, para QA) -->
|
<!-- ✅ 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="p-5 md:p-6">
|
||||||
<div class="flex items-start gap-3">
|
<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" />
|
<i class="pi pi-book opacity-70" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="min-w-0">
|
<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="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.
|
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:
|
A tela pública de aceite está em:
|
||||||
<span class="font-mono">/accept-invite?token=<uuid></span>.
|
<span class="font-mono">/accept-invite?token=<uuid></span>.
|
||||||
@@ -914,9 +909,9 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 grid gap-3">
|
<div class="mt-4 grid gap-3">
|
||||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
|
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
|
||||||
<div class="font-semibold text-sm mb-2">Rotas e comportamento esperado</div>
|
<div class="font-semibold text-[1rem] mb-2">Rotas e comportamento esperado</div>
|
||||||
<ul class="text-sm opacity-80 leading-relaxed list-disc pl-5 space-y-1">
|
<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>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><b>Login:</b> <span class="font-mono">/auth/login</span></li>
|
||||||
<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>
|
<li><b>Erros esperados:</b> token inválido/expirado, convite já usado, e-mail diferente (mismatch).</li>
|
||||||
</ul>
|
</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
|
<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
|
(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>.
|
<span class="font-mono">auth.users</span> usando <span class="font-mono">SECURITY DEFINER</span>.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
|
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
|
||||||
<div class="font-semibold text-sm mb-2">Como testar (prático)</div>
|
<div class="font-semibold text-[1rem] mb-2">Como testar (prático)</div>
|
||||||
<ol class="text-sm opacity-80 leading-relaxed list-decimal pl-5 space-y-1">
|
<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>Convidar alguém nesta tela (botões acima).</li>
|
||||||
<li>Abrir a aba <b>Convites</b> e copiar o link.</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>
|
<li>Abrir o link em aba anônima → logar com o mesmo email → aceitar.</li>
|
||||||
@@ -965,7 +960,7 @@ onMounted(async () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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.
|
Dica: use aba anônima para testar o fluxo completo sem interferência de sessão.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -974,18 +969,18 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Abas -->
|
<!-- Abas -->
|
||||||
<TabView class="rounded-[2rem] overflow-hidden">
|
<TabView class="rounded-md overflow-hidden">
|
||||||
<!-- =========================
|
<!-- =========================
|
||||||
ABA 1: EQUIPE
|
ABA 1: EQUIPE
|
||||||
========================= -->
|
========================= -->
|
||||||
<TabPanel header="Equipe">
|
<TabPanel header="Equipe">
|
||||||
<div class="grid grid-cols-1 gap-4">
|
<div class="grid grid-cols-1 gap-4">
|
||||||
<Card class="rounded-[2rem]">
|
<Card class="rounded-md">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="font-semibold">Equipe</div>
|
<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>).
|
Membros ativos/inativos do tenant (somente <b>tenant_members</b>).
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1015,7 +1010,7 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #content>
|
<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 }}
|
{{ loadError }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1024,14 +1019,15 @@ onMounted(async () => {
|
|||||||
:loading="loading"
|
:loading="loading"
|
||||||
dataKey="user_id"
|
dataKey="user_id"
|
||||||
responsiveLayout="scroll"
|
responsiveLayout="scroll"
|
||||||
class="p-datatable-sm"
|
class="p-datatable-sm prof-datatable"
|
||||||
sortField="role_sort"
|
sortField="role_sort"
|
||||||
:sortOrder="1"
|
:sortOrder="1"
|
||||||
|
:rowClass="(r) => isRecent(r) ? 'row-new-highlight' : ''"
|
||||||
>
|
>
|
||||||
<Column header="Pessoa" style="min-width: 18rem">
|
<Column header="Pessoa" style="min-width: 18rem">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
<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" />
|
<i class="pi pi-user opacity-70" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1040,15 +1036,15 @@ onMounted(async () => {
|
|||||||
{{ data.full_name || 'Sem nome' }}
|
{{ data.full_name || 'Sem nome' }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-sm opacity-70 truncate">
|
<div class="text-[1rem] opacity-70 truncate">
|
||||||
{{ data.email || 'Sem email' }}
|
{{ data.email || 'Sem email' }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-xs opacity-60 font-mono truncate">
|
<div class="text-[1rem] opacity-60 font-mono truncate">
|
||||||
tenant: {{ data.tenant_id }}
|
tenant: {{ data.tenant_id }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-xs opacity-60 font-mono truncate">
|
<div class="text-[1rem] opacity-60 font-mono truncate">
|
||||||
uid: {{ data.user_id }}
|
uid: {{ data.user_id }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1074,16 +1070,16 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<Column header="Vínculo" style="min-width: 12rem">
|
<Column header="Vínculo" style="min-width: 12rem">
|
||||||
<template #body="{ data }">
|
<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>
|
<template v-else>
|
||||||
<div v-if="myLinks.length > 0" class="flex flex-col gap-1">
|
<div v-if="myLinks.length > 0" class="flex flex-col gap-1">
|
||||||
<span
|
<span
|
||||||
v-for="link in myLinks"
|
v-for="link in myLinks"
|
||||||
:key="link.tenant_id"
|
:key="link.tenant_id"
|
||||||
class="text-sm"
|
class="text-[1rem]"
|
||||||
>{{ link.clinic_name }}</span>
|
>{{ link.clinic_name }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span v-else class="text-sm opacity-50">—</span>
|
<span v-else class="text-[1rem] opacity-50">—</span>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
@@ -1113,7 +1109,7 @@ onMounted(async () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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ê
|
Você
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -1126,26 +1122,26 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
</DataTable>
|
</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>:
|
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>.
|
<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).
|
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.
|
<b>Status:</b> <span class="font-mono">active</span> = acesso liberado.
|
||||||
<span class="font-mono">inactive</span> = vínculo desativado (histórico).
|
<span class="font-mono">inactive</span> = vínculo desativado (histórico).
|
||||||
</small>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- Meus Vínculos (visível apenas para terapeutas e secretárias) -->
|
<!-- 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>
|
<template #title>
|
||||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="font-semibold">Meus vínculos</div>
|
<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.
|
Clínicas às quais sua conta está associada.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1161,7 +1157,7 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #content>
|
<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 }}
|
{{ loadMyLinksError }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1169,15 +1165,15 @@ onMounted(async () => {
|
|||||||
<div
|
<div
|
||||||
v-for="link in myLinks"
|
v-for="link in myLinks"
|
||||||
:key="link.tenant_id"
|
: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="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" />
|
<i class="pi pi-building opacity-70" />
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="font-semibold truncate">{{ link.clinic_name }}</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1192,9 +1188,9 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</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.
|
Um profissional pode estar vinculado a múltiplas clínicas simultaneamente.
|
||||||
</small>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -1202,12 +1198,12 @@ onMounted(async () => {
|
|||||||
========================= -->
|
========================= -->
|
||||||
<TabPanel header="Convites">
|
<TabPanel header="Convites">
|
||||||
<div class="grid grid-cols-1 gap-4">
|
<div class="grid grid-cols-1 gap-4">
|
||||||
<Card class="rounded-[2rem]">
|
<Card class="rounded-md">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="font-semibold">Convites pendentes</div>
|
<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>).
|
Convites do tenant que ainda não foram aceitos (tabela <b>tenant_invites</b>).
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1237,7 +1233,7 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #content>
|
<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 }}
|
{{ loadInvitesError }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1246,18 +1242,19 @@ onMounted(async () => {
|
|||||||
:loading="loadingInvites"
|
:loading="loadingInvites"
|
||||||
dataKey="token"
|
dataKey="token"
|
||||||
responsiveLayout="scroll"
|
responsiveLayout="scroll"
|
||||||
class="p-datatable-sm"
|
class="p-datatable-sm invites-datatable"
|
||||||
sortField="created_at"
|
sortField="created_at"
|
||||||
:sortOrder="-1"
|
:sortOrder="-1"
|
||||||
|
:rowClass="(r) => isRecent(r) ? 'row-new-highlight' : ''"
|
||||||
>
|
>
|
||||||
<Column header="Email" style="min-width: 18rem">
|
<Column header="Email" style="min-width: 18rem">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="font-semibold truncate">{{ data.email }}</div>
|
<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 }}
|
token: {{ data.token }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs opacity-60 font-mono truncate">
|
<div class="text-[1rem] opacity-60 font-mono truncate">
|
||||||
tenant: {{ data.tenant_id }}
|
tenant: {{ data.tenant_id }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1272,13 +1269,13 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<Column header="Expira" style="width: 14rem">
|
<Column header="Expira" style="width: 14rem">
|
||||||
<template #body="{ data }">
|
<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>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<Column header="Criado em" style="width: 14rem">
|
<Column header="Criado em" style="width: 14rem">
|
||||||
<template #body="{ data }">
|
<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>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
@@ -1325,15 +1322,15 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
</DataTable>
|
</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
|
<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>.
|
<span class="font-mono">/accept-invite?token=...</span>.
|
||||||
</small>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- QA TOOL: Auth Users (TEMPORÁRIO) -->
|
<!-- QA TOOL: Auth Users (TEMPORÁRIO) -->
|
||||||
<Card class="rounded-[2rem] mt-2">
|
<Card class="rounded-md mt-2">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="font-semibold">Usuários cadastrados (Auth)</div>
|
<div class="font-semibold">Usuários cadastrados (Auth)</div>
|
||||||
@@ -1349,10 +1346,10 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #content>
|
<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">
|
<div class="flex gap-3">
|
||||||
<i class="pi pi-exclamation-triangle mt-0.5 text-orange-600" />
|
<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">
|
<div class="font-semibold mb-1">
|
||||||
Aviso técnico — View temporária para testes (QA)
|
Aviso técnico — View temporária para testes (QA)
|
||||||
</div>
|
</div>
|
||||||
@@ -1372,14 +1369,14 @@ onMounted(async () => {
|
|||||||
⚠️ Remover após validação:
|
⚠️ Remover após validação:
|
||||||
</div>
|
</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;
|
drop view if exists public.v_auth_users_public;
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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 }}
|
{{ loadUsersError }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1393,7 +1390,7 @@ onMounted(async () => {
|
|||||||
<Column field="email" header="Email" style="min-width: 18rem" />
|
<Column field="email" header="Email" style="min-width: 18rem" />
|
||||||
<Column header="User ID" style="min-width: 22rem">
|
<Column header="User ID" style="min-width: 22rem">
|
||||||
<template #body="{ data }">
|
<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>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
<Column field="created_at" header="Criado em" />
|
<Column field="created_at" header="Criado em" />
|
||||||
@@ -1409,12 +1406,12 @@ onMounted(async () => {
|
|||||||
========================= -->
|
========================= -->
|
||||||
<TabPanel header="Histórico">
|
<TabPanel header="Histórico">
|
||||||
<div class="grid grid-cols-1 gap-4">
|
<div class="grid grid-cols-1 gap-4">
|
||||||
<Card class="rounded-[2rem]">
|
<Card class="rounded-md">
|
||||||
<template #title>
|
<template #title>
|
||||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="font-semibold">Histórico de desvinculados</div>
|
<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.
|
Membros inativos e convites revogados ou expirados deste tenant.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1444,7 +1441,7 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #content>
|
<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 }}
|
{{ loadHistoryError }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1460,13 +1457,13 @@ onMounted(async () => {
|
|||||||
<Column header="Pessoa / Email" style="min-width: 18rem">
|
<Column header="Pessoa / Email" style="min-width: 18rem">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<div class="flex items-center gap-3 min-w-0">
|
<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'" />
|
<i :class="data.kind === 'member' ? 'pi pi-user opacity-70' : 'pi pi-envelope opacity-70'" />
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<div class="font-semibold truncate">{{ data.full_name || data.email || 'Sem identificação' }}</div>
|
<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 v-if="data.full_name" class="text-[1rem] opacity-70 truncate">{{ data.email }}</div>
|
||||||
<div class="text-xs opacity-50 font-mono truncate">
|
<div class="text-[1rem] opacity-50 font-mono truncate">
|
||||||
{{ data.kind === 'member' ? 'uid: ' + data.user_id : 'token: ' + data.token }}
|
{{ data.kind === 'member' ? 'uid: ' + data.user_id : 'token: ' + data.token }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1502,10 +1499,10 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<Column header="Data" style="width: 14rem">
|
<Column header="Data" style="width: 14rem">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<div class="text-sm opacity-80">
|
<div class="text-[1rem] opacity-80">
|
||||||
<div>Criado: {{ formatDate(data.created_at) }}</div>
|
<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-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-xs opacity-60 mt-0.5">Expirou: {{ formatDate(data.expires_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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
@@ -1532,9 +1529,9 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
</DataTable>
|
</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.
|
Membros inativos podem ser reativados a qualquer momento. Convites revogados/expirados são apenas registro histórico.
|
||||||
</small>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
@@ -1548,10 +1545,9 @@ onMounted(async () => {
|
|||||||
dismissableMask
|
dismissableMask
|
||||||
:style="{ width: 'min(520px, 94vw)' }"
|
:style="{ width: 'min(520px, 94vw)' }"
|
||||||
:header="inviteHeader"
|
:header="inviteHeader"
|
||||||
class="rounded-[2rem] overflow-hidden"
|
|
||||||
>
|
>
|
||||||
<div class="space-y-4">
|
<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
|
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>.
|
<span class="font-mono">/accept-invite</span>.
|
||||||
</div>
|
</div>
|
||||||
@@ -1577,7 +1573,7 @@ onMounted(async () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 }}
|
{{ invite.error }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1586,4 +1582,8 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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>
|
</style>
|
||||||
@@ -407,424 +407,341 @@ onBeforeUnmount(() => {
|
|||||||
<Toast />
|
<Toast />
|
||||||
<ConfirmDialog />
|
<ConfirmDialog />
|
||||||
|
|
||||||
<!-- Info decorativa (scrolls away naturalmente) -->
|
<!-- Sentinel -->
|
||||||
<div class="flex items-center gap-3 px-4 pb-3">
|
<div ref="sentinelRef" class="h-px" />
|
||||||
<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 -->
|
<!-- Hero sticky -->
|
||||||
<div ref="sentinelRef" style="height: 1px; pointer-events: none;" />
|
|
||||||
|
|
||||||
<!-- hero -->
|
|
||||||
<div
|
<div
|
||||||
ref="heroRef"
|
ref="heroRef"
|
||||||
class="dash-hero"
|
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"
|
||||||
:class="{ 'dash-hero--stuck': heroStuck }"
|
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
|
||||||
>
|
>
|
||||||
<div class="dash-hero__blob dash-hero__blob--1" />
|
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
|
||||||
<div class="dash-hero__blob dash-hero__blob--2" />
|
<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">
|
<!-- desktop actions -->
|
||||||
<div class="min-w-0 flex-1">
|
<div class="hidden xl:flex items-center gap-2 shrink-0">
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<SelectButton
|
||||||
<span class="text-xl font-bold leading-none">Central de Controle do SaaS</span>
|
v-model="intervalView"
|
||||||
|
:options="intervalOptions"
|
||||||
<!-- desktop actions -->
|
optionLabel="label"
|
||||||
<div class="hidden xl:flex items-center gap-2">
|
optionValue="value"
|
||||||
<SelectButton
|
:disabled="loading"
|
||||||
v-model="intervalView"
|
/>
|
||||||
:options="intervalOptions"
|
<Button
|
||||||
optionLabel="label"
|
label="Recarregar"
|
||||||
optionValue="value"
|
icon="pi pi-refresh"
|
||||||
:disabled="loading"
|
severity="secondary"
|
||||||
/>
|
outlined
|
||||||
<Button
|
:loading="loading"
|
||||||
label="Recarregar"
|
@click="loadStats"
|
||||||
icon="pi pi-refresh"
|
/>
|
||||||
severity="secondary"
|
<Button
|
||||||
outlined
|
label="Assinaturas"
|
||||||
:loading="loading"
|
icon="pi pi-credit-card"
|
||||||
@click="loadStats"
|
severity="secondary"
|
||||||
/>
|
outlined
|
||||||
<Button
|
:disabled="loading"
|
||||||
label="Assinaturas"
|
@click="router.push('/saas/subscriptions')"
|
||||||
icon="pi pi-credit-card"
|
/>
|
||||||
severity="secondary"
|
<Button
|
||||||
outlined
|
label="Eventos"
|
||||||
:disabled="loading"
|
icon="pi pi-history"
|
||||||
@click="router.push('/saas/subscriptions')"
|
severity="secondary"
|
||||||
/>
|
outlined
|
||||||
<Button
|
:disabled="loading"
|
||||||
label="Eventos"
|
@click="router.push('/saas/subscription-events')"
|
||||||
icon="pi pi-history"
|
/>
|
||||||
severity="secondary"
|
</div>
|
||||||
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>
|
|
||||||
|
|
||||||
|
<!-- 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- content -->
|
<!-- content -->
|
||||||
<div class="px-4 pb-4">
|
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
|
||||||
<!-- KPIs -->
|
<!-- KPIs -->
|
||||||
<div class="grid grid-cols-12 gap-4">
|
<div class="grid grid-cols-12 gap-4">
|
||||||
<div class="col-span-12 md:col-span-3">
|
<div class="col-span-12 md:col-span-3">
|
||||||
<Card class="h-full">
|
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 h-full">
|
||||||
<template #title>
|
<div class="flex items-center justify-between mb-3">
|
||||||
<div class="flex items-center justify-between">
|
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Ativas</div>
|
||||||
<span>Ativas</span>
|
<Tag value="active" severity="success" rounded />
|
||||||
<Tag value="active" severity="success" rounded />
|
</div>
|
||||||
</div>
|
<div class="text-4xl font-semibold">{{ totalActive }}</div>
|
||||||
</template>
|
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">assinaturas em status <b>active</b></div>
|
||||||
<template #content>
|
</div>
|
||||||
<div class="text-4xl font-semibold">{{ totalActive }}</div>
|
|
||||||
<small class="text-color-secondary">assinaturas em status <b>active</b></small>
|
|
||||||
</template>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-12 md:col-span-3">
|
<div class="col-span-12 md:col-span-3">
|
||||||
<Card class="h-full">
|
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 h-full">
|
||||||
<template #title>
|
<div class="flex items-center justify-between mb-3">
|
||||||
<div class="flex items-center justify-between">
|
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Canceladas</div>
|
||||||
<span>Canceladas</span>
|
<Tag value="canceled" severity="danger" rounded />
|
||||||
<Tag value="canceled" severity="danger" rounded />
|
</div>
|
||||||
</div>
|
<div class="text-4xl font-semibold">{{ totalCanceled }}</div>
|
||||||
</template>
|
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">assinaturas em status <b>canceled</b></div>
|
||||||
<template #content>
|
</div>
|
||||||
<div class="text-4xl font-semibold">{{ totalCanceled }}</div>
|
|
||||||
<small class="text-color-secondary">assinaturas em status <b>canceled</b></small>
|
|
||||||
</template>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-12 md:col-span-3">
|
<div class="col-span-12 md:col-span-3">
|
||||||
<Card class="h-full">
|
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 h-full">
|
||||||
<template #title>
|
<div class="flex items-center justify-between mb-3">
|
||||||
<div class="flex items-center justify-between">
|
<div class="text-[1rem] font-semibold text-[var(--text-color)]">{{ intervalView === 'year' ? 'ARR' : 'MRR' }}</div>
|
||||||
<span>{{ intervalView === 'year' ? 'ARR' : 'MRR' }}</span>
|
<Tag :value="intervalView === 'year' ? 'anual' : 'mensal'" severity="secondary" rounded />
|
||||||
<Tag :value="intervalView === 'year' ? 'anual' : 'mensal'" severity="secondary" rounded />
|
</div>
|
||||||
</div>
|
<div class="text-3xl font-semibold">{{ moneyBRLFromCents(revenueCents) }}</div>
|
||||||
</template>
|
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">normalizado (mensal ↔ anual)</div>
|
||||||
<template #content>
|
</div>
|
||||||
<div class="text-3xl font-semibold">{{ moneyBRLFromCents(revenueCents) }}</div>
|
|
||||||
<small class="text-color-secondary">normalizado (mensal ↔ anual)</small>
|
|
||||||
</template>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-12 md:col-span-3">
|
<div class="col-span-12 md:col-span-3">
|
||||||
<Card class="h-full">
|
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 h-full">
|
||||||
<template #title>
|
<div class="flex items-center justify-between mb-3">
|
||||||
<div class="flex items-center justify-between">
|
<div class="text-[1rem] font-semibold text-[var(--text-color)]">ARPA</div>
|
||||||
<span>ARPA</span>
|
<Tag value="média" severity="secondary" rounded />
|
||||||
<Tag value="média" severity="secondary" rounded />
|
</div>
|
||||||
</div>
|
<div class="text-3xl font-semibold">{{ moneyBRLFromCents(arpaCents) }}</div>
|
||||||
</template>
|
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">média por assinatura ativa</div>
|
||||||
<template #content>
|
</div>
|
||||||
<div class="text-3xl font-semibold">{{ moneyBRLFromCents(arpaCents) }}</div>
|
|
||||||
<small class="text-color-secondary">média por assinatura ativa</small>
|
|
||||||
</template>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Intenções + Health + Chart -->
|
<!-- Intenções + Health + Chart -->
|
||||||
<div class="grid grid-cols-12 gap-4 mt-4">
|
<div class="grid grid-cols-12 gap-4">
|
||||||
<!-- Intenções -->
|
<!-- Intenções -->
|
||||||
<div class="col-span-12 md:col-span-4">
|
<div class="col-span-12 md:col-span-4">
|
||||||
<Card class="h-full">
|
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 h-full">
|
||||||
<template #title>
|
<div class="flex items-center justify-between mb-3">
|
||||||
<div class="flex items-center justify-between">
|
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Intenções de assinatura</div>
|
||||||
<span>Intenções de assinatura</span>
|
<Tag :value="intentsLoading ? 'carregando' : 'últimas'" severity="secondary" rounded />
|
||||||
<Tag :value="intentsLoading ? 'carregando' : 'últimas'" severity="secondary" rounded />
|
</div>
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #content>
|
<div class="grid grid-cols-12 gap-3">
|
||||||
<div class="grid grid-cols-12 gap-3">
|
<div class="col-span-4 rounded-md border border-[var(--surface-border)] p-3">
|
||||||
<div class="col-span-4 rounded-xl border border-[var(--surface-border)] p-3">
|
<div class="text-[1rem] text-[var(--text-color-secondary)]">Total</div>
|
||||||
<div class="text-xs text-color-secondary">Total</div>
|
<div class="text-2xl font-semibold">{{ totalIntents }}</div>
|
||||||
<div class="text-2xl font-semibold">{{ totalIntents }}</div>
|
</div>
|
||||||
</div>
|
<div class="col-span-4 rounded-md border border-[var(--surface-border)] p-3">
|
||||||
<div class="col-span-4 rounded-xl border border-[var(--surface-border)] p-3">
|
<div class="text-[1rem] text-[var(--text-color-secondary)]">New</div>
|
||||||
<div class="text-xs text-color-secondary">New</div>
|
<div class="text-2xl font-semibold">{{ totalIntentsNew }}</div>
|
||||||
<div class="text-2xl font-semibold">{{ totalIntentsNew }}</div>
|
</div>
|
||||||
</div>
|
<div class="col-span-4 rounded-md border border-[var(--surface-border)] p-3">
|
||||||
<div class="col-span-4 rounded-xl border border-[var(--surface-border)] p-3">
|
<div class="text-[1rem] text-[var(--text-color-secondary)]">Paid</div>
|
||||||
<div class="text-xs text-color-secondary">Paid</div>
|
<div class="text-2xl font-semibold">{{ totalIntentsPaid }}</div>
|
||||||
<div class="text-2xl font-semibold">{{ totalIntentsPaid }}</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Divider class="my-3" />
|
<div v-else class="space-y-2">
|
||||||
|
<div
|
||||||
<div v-if="intentsLoading" class="text-color-secondary text-sm">
|
v-for="(it, idx) in intents"
|
||||||
Carregando intenções…
|
:key="idx"
|
||||||
</div>
|
class="flex items-start justify-between gap-3 rounded-md border border-[var(--surface-border)] p-3"
|
||||||
|
>
|
||||||
<div v-else>
|
<div class="min-w-0">
|
||||||
<div v-if="!intents.length" class="text-color-secondary text-sm">
|
<div class="font-medium truncate">
|
||||||
Nenhuma intenção encontrada.
|
{{ maskEmail(it.email) }}
|
||||||
</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>
|
</div>
|
||||||
|
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">
|
||||||
<div class="shrink-0">
|
{{ it.plan_key || '—' }} • {{ intervalLabel(it.interval) }} •
|
||||||
<Tag :value="it.status || '—'" :severity="statusSeverity(it.status)" rounded />
|
<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>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-2 flex-wrap mt-3">
|
<div class="shrink-0">
|
||||||
<Button
|
<Tag :value="it.status || '—'" :severity="statusSeverity(it.status)" rounded />
|
||||||
label="Atualizar"
|
</div>
|
||||||
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>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Health -->
|
<!-- Health -->
|
||||||
<div class="col-span-12 md:col-span-4">
|
<div class="col-span-12 md:col-span-4">
|
||||||
<Card class="h-full">
|
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 h-full">
|
||||||
<template #title>
|
<div class="flex items-center justify-between mb-3">
|
||||||
<div class="flex items-center justify-between">
|
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Saúde do sistema</div>
|
||||||
<span>Saúde do sistema</span>
|
<Tag :severity="healthSeverity" :value="healthLabel" rounded />
|
||||||
<Tag :severity="healthSeverity" :value="healthLabel" rounded />
|
</div>
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #content>
|
<div class="flex items-center justify-between gap-2">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="text-4xl font-semibold">{{ totalMismatches }}</div>
|
||||||
<div class="text-4xl font-semibold">{{ totalMismatches }}</div>
|
<div class="text-[1rem] text-[var(--text-color-secondary)] text-right">
|
||||||
<small class="text-color-secondary text-right">
|
divergências entre plano (esperado) e entitlements (atual)
|
||||||
divergências entre plano (esperado) e entitlements (atual)
|
|
||||||
</small>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="text-color-secondary text-sm mt-2">
|
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-2">
|
||||||
{{ healthHint }}
|
{{ healthHint }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Divider class="my-3" />
|
<Divider class="my-3" />
|
||||||
|
|
||||||
<div class="flex gap-2 flex-wrap">
|
<div class="flex gap-2 flex-wrap">
|
||||||
<Button
|
<Button
|
||||||
v-if="totalMismatches > 0"
|
v-if="totalMismatches > 0"
|
||||||
label="Corrigir tudo"
|
label="Corrigir tudo"
|
||||||
icon="pi pi-refresh"
|
icon="pi pi-refresh"
|
||||||
severity="danger"
|
severity="danger"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
@click="askFixAll"
|
@click="askFixAll"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
label="Ver divergências"
|
label="Ver divergências"
|
||||||
icon="pi pi-search"
|
icon="pi pi-search"
|
||||||
severity="secondary"
|
severity="secondary"
|
||||||
outlined
|
outlined
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
@click="router.push('/saas/subscription-health')"
|
@click="router.push('/saas/subscription-health')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-color-secondary text-xs mt-3" v-if="lastUpdatedAt">
|
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-3" v-if="lastUpdatedAt">
|
||||||
Atualizado em {{ fmtDate(lastUpdatedAt) }}
|
Atualizado em {{ fmtDate(lastUpdatedAt) }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Chart -->
|
<!-- Chart -->
|
||||||
<div class="col-span-12 md:col-span-4">
|
<div class="col-span-12 md:col-span-4">
|
||||||
<Card class="h-full">
|
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 h-full">
|
||||||
<template #title>{{ intervalView === 'year' ? 'ARR por plano' : 'MRR por plano' }}</template>
|
<div class="text-[1rem] font-semibold text-[var(--text-color)] mb-3">{{ intervalView === 'year' ? 'ARR por plano' : 'MRR por plano' }}</div>
|
||||||
<template #content>
|
<div style="height: 260px;">
|
||||||
<div style="height: 260px;">
|
<Chart type="bar" :data="chartData" :options="chartOptions" />
|
||||||
<Chart type="bar" :data="chartData" :options="chartOptions" />
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Breakdown table (com ações) -->
|
<!-- Breakdown table (com ações) -->
|
||||||
<div class="mt-4">
|
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
||||||
<Card>
|
<div class="text-[1rem] font-semibold text-[var(--text-color)] mb-3">Distribuição por plano</div>
|
||||||
<template #title>Distribuição por plano</template>
|
<DataTable :value="breakdown" stripedRows responsiveLayout="scroll" emptyMessage="Sem dados para exibir.">
|
||||||
<template #content>
|
<Column field="plan_key" header="Plano" style="min-width: 14rem">
|
||||||
<DataTable :value="breakdown" stripedRows responsiveLayout="scroll" emptyMessage="Sem dados para exibir.">
|
<template #body="{ data }">
|
||||||
<Column field="plan_key" header="Plano" style="min-width: 14rem">
|
<div class="flex flex-col">
|
||||||
<template #body="{ data }">
|
<span class="font-medium">{{ data.plan_key }}</span>
|
||||||
<div class="flex flex-col">
|
<div class="text-[1rem] text-[var(--text-color-secondary)]">
|
||||||
<span class="font-medium">{{ data.plan_key }}</span>
|
{{ data.plan_active ? 'ativo no catálogo' : 'inativo no catálogo' }}
|
||||||
<small class="text-color-secondary">
|
</div>
|
||||||
{{ data.plan_active ? 'ativo no catálogo' : 'inativo no catálogo' }}
|
</div>
|
||||||
</small>
|
</template>
|
||||||
</div>
|
</Column>
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
|
|
||||||
<Column header="Público" style="width: 12rem">
|
<Column header="Público" style="width: 12rem">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<Tag
|
<Tag
|
||||||
:value="planTargetLabel(data.plan_target)"
|
:value="planTargetLabel(data.plan_target)"
|
||||||
:severity="planTargetSeverity(data.plan_target)"
|
:severity="planTargetSeverity(data.plan_target)"
|
||||||
rounded
|
rounded
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<Column header="Ativas" style="width: 8rem">
|
<Column header="Ativas" style="width: 8rem">
|
||||||
<template #body="{ data }">{{ data.active_count }}</template>
|
<template #body="{ data }">{{ data.active_count }}</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<Column header="Canceladas" style="width: 10rem">
|
<Column header="Canceladas" style="width: 10rem">
|
||||||
<template #body="{ data }">{{ data.canceled_count }}</template>
|
<template #body="{ data }">{{ data.canceled_count }}</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<Column header="Preço (ref.)" style="min-width: 12rem">
|
<Column header="Preço (ref.)" style="min-width: 12rem">
|
||||||
<template #body="{ data }">{{ moneyBRLFromCents(data.price_cents) }}</template>
|
<template #body="{ data }">{{ moneyBRLFromCents(data.price_cents) }}</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<Column :header="intervalView === 'year' ? 'ARR' : 'MRR'" style="min-width: 12rem">
|
<Column :header="intervalView === 'year' ? 'ARR' : 'MRR'" style="min-width: 12rem">
|
||||||
<template #body="{ data }">{{ moneyBRLFromCents(data.revenue_cents) }}</template>
|
<template #body="{ data }">{{ moneyBRLFromCents(data.revenue_cents) }}</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<Column header="Ações" style="width: 16rem">
|
<Column header="Ações" style="width: 16rem">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<div class="flex gap-2 justify-end flex-wrap">
|
<div class="flex gap-2 justify-end flex-wrap">
|
||||||
<Button
|
<Button
|
||||||
label="Abrir vitrine"
|
label="Abrir vitrine"
|
||||||
icon="pi pi-external-link"
|
icon="pi pi-external-link"
|
||||||
severity="secondary"
|
severity="secondary"
|
||||||
outlined
|
outlined
|
||||||
size="small"
|
size="small"
|
||||||
:disabled="!data.plan_key || data.plan_key === '(sem plano)'"
|
:disabled="!data.plan_key || data.plan_key === '(sem plano)'"
|
||||||
@click="openPlanPublic(data.plan_key)"
|
@click="openPlanPublic(data.plan_key)"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
icon="pi pi-pencil"
|
icon="pi pi-pencil"
|
||||||
severity="secondary"
|
severity="secondary"
|
||||||
outlined
|
outlined
|
||||||
size="small"
|
size="small"
|
||||||
:disabled="!data.plan_key || data.plan_key === '(sem plano)'"
|
:disabled="!data.plan_key || data.plan_key === '(sem plano)'"
|
||||||
v-tooltip.top="'Abrir catálogo interno do plano'"
|
v-tooltip.top="'Abrir catálogo interno do plano'"
|
||||||
@click="openPlanCatalog(data.plan_key)"
|
@click="openPlanCatalog(data.plan_key)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
|
|
||||||
<div class="text-color-secondary text-sm mt-3">
|
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-3">
|
||||||
Nota: "Preço (ref.)" e "MRR/ARR" são normalizados usando o preço ativo.
|
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.
|
Se só existir anual, MRR = anual/12; se só existir mensal, ARR = mensal*12.
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="faq-page">
|
<!-- Sentinel -->
|
||||||
|
<div class="h-px" />
|
||||||
|
|
||||||
<!-- ── Cabeçalho ─────────────────────────────────────────── -->
|
<!-- Hero sticky -->
|
||||||
<div class="faq-header">
|
<div
|
||||||
<div class="faq-header-inner">
|
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"
|
||||||
<div class="flex items-center gap-3 mb-3">
|
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
|
||||||
<div class="faq-icon-wrap">
|
>
|
||||||
<i class="pi pi-comments text-xl" />
|
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
|
||||||
</div>
|
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
|
||||||
<div>
|
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
|
||||||
<h1 class="faq-title">Central de Ajuda</h1>
|
</div>
|
||||||
<p class="faq-subtitle">Encontre respostas para as dúvidas mais comuns</p>
|
<div class="relative z-10 flex flex-col gap-3">
|
||||||
</div>
|
<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>
|
||||||
|
<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 -->
|
<!-- Busca -->
|
||||||
<div class="faq-search-wrap">
|
<div>
|
||||||
<IconField class="w-full">
|
<IconField class="w-full">
|
||||||
<InputIcon class="pi pi-search" />
|
<InputIcon class="pi pi-search" />
|
||||||
<InputText
|
<InputText
|
||||||
v-model="busca"
|
v-model="busca"
|
||||||
placeholder="Buscar pergunta…"
|
placeholder="Buscar pergunta…"
|
||||||
class="faq-search-input"
|
class="w-full"
|
||||||
/>
|
/>
|
||||||
<InputIcon v-if="busca" class="pi pi-times cursor-pointer" @click="busca = ''" />
|
<InputIcon v-if="busca" class="pi pi-times cursor-pointer" @click="busca = ''" />
|
||||||
</IconField>
|
</IconField>
|
||||||
<div v-if="totalResultados !== null" class="faq-search-result">
|
<div v-if="totalResultados !== null" class="text-[1rem] text-[var(--text-color-secondary)] opacity-70 mt-1 ml-1">
|
||||||
{{ totalResultados }} resultado{{ totalResultados !== 1 ? 's' : '' }} encontrado{{ totalResultados !== 1 ? 's' : '' }}
|
{{ totalResultados }} resultado{{ totalResultados !== 1 ? 's' : '' }} encontrado{{ totalResultados !== 1 ? 's' : '' }}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ── Corpo ─────────────────────────────────────────────── -->
|
<!-- content -->
|
||||||
<div class="faq-body">
|
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
|
||||||
|
|
||||||
<!-- Loading -->
|
<!-- Loading -->
|
||||||
<div v-if="loading" class="flex justify-center py-16">
|
<div v-if="loading" class="flex justify-center py-16">
|
||||||
<i class="pi pi-spinner pi-spin text-2xl opacity-30" />
|
<i class="pi pi-spinner pi-spin text-2xl opacity-30" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
<div class="flex gap-4 items-start flex-col sm:flex-row">
|
||||||
|
|
||||||
<!-- Sidebar de categorias -->
|
<!-- Sidebar de categorias -->
|
||||||
<aside v-if="categorias.length" class="faq-sidebar">
|
<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="faq-sidebar-title">Categorias</div>
|
<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
|
<button
|
||||||
class="faq-cat-btn"
|
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="{ 'faq-cat-btn--active': !catAtiva }"
|
:class="{ 'bg-[color-mix(in_srgb,var(--primary-color)_10%,transparent)] text-[var(--primary-color)] font-semibold': !catAtiva }"
|
||||||
@click="selecionarCat(null)"
|
@click="selecionarCat(null)"
|
||||||
>
|
>
|
||||||
<i class="pi pi-th-large text-xs mr-2" />
|
<i class="pi pi-th-large mr-2 opacity-60" />
|
||||||
Todas
|
Todas
|
||||||
<span class="faq-cat-count">{{ faqItens.length }}</span>
|
<span class="ml-auto opacity-50 text-[1rem]">{{ faqItens.length }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-for="cat in categorias"
|
v-for="cat in categorias"
|
||||||
:key="cat"
|
:key="cat"
|
||||||
class="faq-cat-btn"
|
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="{ 'faq-cat-btn--active': catAtiva === cat }"
|
:class="{ 'bg-[color-mix(in_srgb,var(--primary-color)_10%,transparent)] text-[var(--primary-color)] font-semibold': catAtiva === cat }"
|
||||||
@click="selecionarCat(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 }}
|
{{ 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 }}
|
{{ faqItens.filter(f => docs.find(d => d.id === f.doc_id && d.categoria === cat)).length }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Conteúdo principal -->
|
<!-- Conteúdo principal -->
|
||||||
<main class="faq-main">
|
<div class="flex-1 min-w-0 flex flex-col gap-4">
|
||||||
|
|
||||||
<!-- Sem resultados -->
|
<!-- 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" />
|
<i class="pi pi-search text-3xl opacity-20 mb-3" />
|
||||||
<p class="text-[var(--text-color-secondary)]">Nenhuma pergunta encontrada.</p>
|
<div class="text-[var(--text-color-secondary)] text-[1rem]">Nenhuma pergunta encontrada.</div>
|
||||||
<button v-if="busca || catAtiva" class="text-[var(--primary-color)] text-sm mt-2 underline" @click="busca = ''; catAtiva = null; abertos = {}">
|
<button v-if="busca || catAtiva" class="text-[var(--primary-color)] text-[1rem] mt-2 underline" @click="busca = ''; catAtiva = null; abertos = {}">
|
||||||
Limpar filtros
|
Limpar filtros
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -211,45 +220,48 @@ function selecionarCat (cat) {
|
|||||||
<div
|
<div
|
||||||
v-for="doc in docsComResultado"
|
v-for="doc in docsComResultado"
|
||||||
:key="doc.id"
|
: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) -->
|
<!-- Cabeçalho do grupo (doc) -->
|
||||||
<div class="faq-group-header">
|
<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="faq-group-icon">
|
<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 text-sm" />
|
<i class="pi pi-file-edit" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<h2 class="faq-group-title">{{ doc.titulo }}</h2>
|
<div class="text-[1rem] font-semibold text-[var(--text-color)] leading-tight">{{ doc.titulo }}</div>
|
||||||
<span v-if="doc.categoria" class="faq-group-cat">{{ doc.categoria }}</span>
|
<div v-if="doc.categoria" class="text-[1rem] text-[var(--text-color-secondary)] opacity-60 mt-px">{{ doc.categoria }}</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<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'"
|
v-tooltip.top="'Editar documento'"
|
||||||
@click="editarDoc(doc.id)"
|
@click="editarDoc(doc.id)"
|
||||||
>
|
>
|
||||||
<i class="pi pi-pencil text-xs" />
|
<i class="pi pi-pencil" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Itens FAQ do grupo -->
|
<!-- Itens FAQ do grupo -->
|
||||||
<div class="faq-items">
|
<div class="flex flex-col">
|
||||||
<div
|
<div
|
||||||
v-for="item in itensDo(doc.id)"
|
v-for="item in itensDo(doc.id)"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="faq-item"
|
class="border-b border-[var(--surface-border)] last:border-b-0 transition-colors"
|
||||||
:class="{ 'faq-item--open': abertos[item.id] }"
|
:class="abertos[item.id] ? 'bg-[color-mix(in_srgb,var(--primary-color)_3%,transparent)]' : ''"
|
||||||
>
|
>
|
||||||
<button class="faq-pergunta" @click="toggle(item.id)">
|
<button
|
||||||
<span class="faq-pergunta-text">{{ item.pergunta }}</span>
|
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
|
<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'"
|
:class="abertos[item.id] ? 'pi-chevron-up' : 'pi-chevron-down'"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<Transition name="faq-expand">
|
<Transition name="faq-expand">
|
||||||
<div
|
<div
|
||||||
v-if="abertos[item.id] && item.resposta"
|
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"
|
v-html="item.resposta"
|
||||||
/>
|
/>
|
||||||
</Transition>
|
</Transition>
|
||||||
@@ -257,270 +269,23 @@ function selecionarCat (cat) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</main>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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 */
|
/* Quill content */
|
||||||
.faq-resposta.ql-content :deep(p) { margin: 0 0 0.5rem; }
|
.ql-content :deep(p) { margin: 0 0 0.5rem; }
|
||||||
.faq-resposta.ql-content :deep(p:last-child) { margin-bottom: 0; }
|
.ql-content :deep(p:last-child) { margin-bottom: 0; }
|
||||||
.faq-resposta.ql-content :deep(strong) { font-weight: 600; color: var(--text-color); }
|
.ql-content :deep(strong) { font-weight: 600; color: var(--text-color); }
|
||||||
.faq-resposta.ql-content :deep(em) { font-style: italic; }
|
.ql-content :deep(em) { font-style: italic; }
|
||||||
.faq-resposta.ql-content :deep(ul),
|
.ql-content :deep(ul),
|
||||||
.faq-resposta.ql-content :deep(ol) { padding-left: 1.25rem; margin: 0.4rem 0; }
|
.ql-content :deep(ol) { padding-left: 1.25rem; margin: 0.4rem 0; }
|
||||||
.faq-resposta.ql-content :deep(li) { margin-bottom: 0.2rem; }
|
.ql-content :deep(li) { margin-bottom: 0.2rem; }
|
||||||
.faq-resposta.ql-content :deep(a) { color: var(--primary-color); text-decoration: underline; }
|
.ql-content :deep(a) { color: var(--primary-color); text-decoration: underline; }
|
||||||
.faq-resposta.ql-content :deep(blockquote) {
|
.ql-content :deep(blockquote) {
|
||||||
border-left: 3px solid var(--surface-border);
|
border-left: 3px solid var(--surface-border);
|
||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
padding: 0.25rem 0.75rem;
|
padding: 0.25rem 0.75rem;
|
||||||
@@ -539,12 +304,4 @@ function selecionarCat (cat) {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
max-height: 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>
|
</style>
|
||||||
@@ -233,56 +233,53 @@ onBeforeUnmount(() => {
|
|||||||
<Toast />
|
<Toast />
|
||||||
<ConfirmDialog />
|
<ConfirmDialog />
|
||||||
|
|
||||||
<div class="features-root">
|
<!-- Sentinel -->
|
||||||
|
<div ref="heroSentinelRef" class="h-px" />
|
||||||
|
|
||||||
<!-- Info decorativa (scrolls away naturalmente) -->
|
<!-- Hero sticky -->
|
||||||
<div class="flex items-start gap-4 px-4 pb-3">
|
<div
|
||||||
<div class="features-hero__icon-wrap">
|
ref="heroEl"
|
||||||
<i class="pi pi-bolt features-hero__icon" />
|
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"
|
||||||
</div>
|
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
|
||||||
<div class="features-hero__sub">
|
>
|
||||||
Cadastre os recursos (features) que os planos podem habilitar.
|
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
|
||||||
A <b>key</b> é o identificador técnico; o <b>nome</b> é exibido para o usuário.
|
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-fuchsia-400/10" />
|
||||||
</div>
|
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-indigo-400/10" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── HERO ─────────────────────────────────────────────── -->
|
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
|
||||||
<div ref="heroSentinelRef" class="features-hero-sentinel" />
|
<div class="min-w-0">
|
||||||
<div ref="heroEl" class="features-hero mb-4" :class="{ 'features-hero--stuck': heroStuck }">
|
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Recursos do Sistema</div>
|
||||||
<div class="features-hero__blobs" aria-hidden="true">
|
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Cadastre os recursos (features) que os planos podem habilitar.</div>
|
||||||
<div class="features-hero__blob features-hero__blob--1" />
|
|
||||||
<div class="features-hero__blob features-hero__blob--2" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="features-hero__inner">
|
<!-- Ações desktop (≥ 1200px) -->
|
||||||
<div class="features-hero__info min-w-0">
|
<div class="hidden xl:flex items-center gap-2 flex-wrap">
|
||||||
<div class="features-hero__title">Recursos do Sistema</div>
|
<Button label="Atualizar" icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loading" :disabled="saving" @click="fetchAll" />
|
||||||
</div>
|
<Button label="Adicionar recurso" icon="pi pi-plus" size="small" :disabled="saving" @click="openCreate" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Ações desktop (≥ 1200px) -->
|
<!-- Ações mobile (< 1200px) -->
|
||||||
<div class="features-hero__actions features-hero__actions--desktop">
|
<div class="flex xl:hidden">
|
||||||
<Button label="Atualizar" icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loading" :disabled="saving" @click="fetchAll" />
|
<Button
|
||||||
<Button label="Adicionar recurso" icon="pi pi-plus" size="small" :disabled="saving" @click="openCreate" />
|
label="Ações"
|
||||||
</div>
|
icon="pi pi-ellipsis-v"
|
||||||
|
severity="warn"
|
||||||
<!-- Ações mobile (< 1200px) -->
|
size="small"
|
||||||
<div class="features-hero__actions--mobile">
|
aria-haspopup="true"
|
||||||
<Button
|
aria-controls="features_hero_menu"
|
||||||
label="Ações"
|
@click="(e) => heroMenuRef.toggle(e)"
|
||||||
icon="pi pi-ellipsis-v"
|
/>
|
||||||
severity="warn"
|
<Menu ref="heroMenuRef" id="features_hero_menu" :model="heroMenuItems" :popup="true" />
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Search — sempre visível, fora do hero sticky -->
|
<!-- content -->
|
||||||
<div class="px-4 mb-4">
|
<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]">
|
<FloatLabel variant="on" class="w-full md:w-[380px]">
|
||||||
<IconField class="w-full">
|
<IconField class="w-full">
|
||||||
<InputIcon class="pi pi-search" />
|
<InputIcon class="pi pi-search" />
|
||||||
@@ -292,7 +289,6 @@ onBeforeUnmount(() => {
|
|||||||
</FloatLabel>
|
</FloatLabel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-4 pb-4">
|
|
||||||
<DataTable :value="filteredRows" dataKey="id" :loading="loading" stripedRows responsiveLayout="scroll">
|
<DataTable :value="filteredRows" dataKey="id" :loading="loading" stripedRows responsiveLayout="scroll">
|
||||||
<Column header="Domínio" style="width: 9rem">
|
<Column header="Domínio" style="width: 9rem">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
@@ -303,8 +299,8 @@ onBeforeUnmount(() => {
|
|||||||
<Column field="key" header="Key" sortable style="min-width: 18rem">
|
<Column field="key" header="Key" sortable style="min-width: 18rem">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<span class="font-medium font-mono text-sm">{{ data.key }}</span>
|
<span class="font-medium font-mono text-[1rem]">{{ data.key }}</span>
|
||||||
<small class="text-color-secondary">ID: {{ data.id }}</small>
|
<div class="text-[1rem] text-[var(--text-color-secondary)]">ID: {{ data.id }}</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
@@ -317,7 +313,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<Column field="descricao" header="Descrição" sortable style="min-width: 22rem">
|
<Column field="descricao" header="Descrição" sortable style="min-width: 22rem">
|
||||||
<template #body="{ data }">
|
<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 || '—' }}
|
{{ data.descricao || '—' }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -334,151 +330,87 @@ onBeforeUnmount(() => {
|
|||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
v-model:visible="showDlg"
|
v-model:visible="showDlg"
|
||||||
modal
|
modal
|
||||||
:header="isEdit ? 'Editar recurso' : 'Novo recurso'"
|
:header="isEdit ? 'Editar recurso' : 'Novo recurso'"
|
||||||
:style="{ width: '640px' }"
|
:style="{ width: '640px' }"
|
||||||
:closable="!saving"
|
:closable="!saving"
|
||||||
:dismissableMask="!saving"
|
:dismissableMask="!saving"
|
||||||
:draggable="false"
|
:draggable="false"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<!-- Key -->
|
<!-- Key -->
|
||||||
<div>
|
<div>
|
||||||
<FloatLabel variant="on">
|
<FloatLabel variant="on">
|
||||||
<IconField>
|
<IconField>
|
||||||
<InputIcon class="pi pi-tag" />
|
<InputIcon class="pi pi-tag" />
|
||||||
<InputText
|
<InputText
|
||||||
id="cr-key"
|
id="cr-key"
|
||||||
v-model.trim="form.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"
|
|
||||||
class="w-full"
|
class="w-full"
|
||||||
rows="3"
|
variant="filled"
|
||||||
autoResize
|
|
||||||
:disabled="saving"
|
:disabled="saving"
|
||||||
|
autocomplete="off"
|
||||||
|
autofocus
|
||||||
|
@blur="form.key = slugifyKey(form.key)"
|
||||||
|
@keydown.enter.prevent="save"
|
||||||
/>
|
/>
|
||||||
<label for="cr-desc-pt">Descrição</label>
|
</IconField>
|
||||||
</FloatLabel>
|
<label for="cr-key">Key *</label>
|
||||||
<small class="text-color-secondary block mt-1">
|
</FloatLabel>
|
||||||
Explique o que o recurso habilita e para quem se aplica.
|
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">
|
||||||
</small>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<!-- Nome -->
|
||||||
<Button label="Cancelar" severity="secondary" outlined @click="showDlg = false" :disabled="saving" />
|
<div>
|
||||||
<Button :label="isEdit ? 'Salvar' : 'Criar'" icon="pi pi-check" :loading="saving" @click="save" />
|
<FloatLabel variant="on">
|
||||||
</template>
|
<IconField>
|
||||||
</Dialog>
|
<InputIcon class="pi pi-bookmark" />
|
||||||
</div>
|
<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>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<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>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.features-root { padding: 1rem; }
|
|
||||||
@media (min-width: 768px) { .features-root { padding: 1.5rem; } }
|
|
||||||
|
|
||||||
.features-hero-sentinel { height: 1px; }
|
|
||||||
|
|
||||||
.features-hero {
|
|
||||||
position: sticky;
|
|
||||||
top: var(--layout-sticky-top, 56px);
|
|
||||||
z-index: 20;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 1.75rem;
|
|
||||||
border: 1px solid var(--surface-border);
|
|
||||||
background: var(--surface-card);
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
.features-hero--stuck {
|
|
||||||
border-top-left-radius: 0;
|
|
||||||
border-top-right-radius: 0;
|
|
||||||
}
|
|
||||||
.features-hero__blobs {
|
|
||||||
position: absolute; inset: 0; pointer-events: none; overflow: hidden;
|
|
||||||
}
|
|
||||||
.features-hero__blob {
|
|
||||||
position: absolute; border-radius: 50%; filter: blur(70px);
|
|
||||||
}
|
|
||||||
.features-hero__blob--1 { width: 20rem; height: 20rem; top: -5rem; right: -4rem; background: rgba(217,70,239,0.10); }
|
|
||||||
.features-hero__blob--2 { width: 18rem; height: 18rem; top: 1rem; left: -5rem; background: rgba(99,102,241,0.10); }
|
|
||||||
|
|
||||||
.features-hero__inner {
|
|
||||||
position: relative; z-index: 1;
|
|
||||||
display: flex; align-items: center; gap: 1.25rem; flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.features-hero__icon-wrap {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 4rem; height: 4rem; border-radius: 1.125rem;
|
|
||||||
border: 2px solid var(--surface-border);
|
|
||||||
background: var(--surface-ground);
|
|
||||||
display: grid; place-items: center;
|
|
||||||
}
|
|
||||||
.features-hero__icon { font-size: 1.5rem; color: var(--text-color); }
|
|
||||||
|
|
||||||
.features-hero__info { flex: 1; min-width: 0; }
|
|
||||||
.features-hero__title {
|
|
||||||
font-size: 1.25rem; font-weight: 700; letter-spacing: -0.025em;
|
|
||||||
color: var(--text-color); line-height: 1.2;
|
|
||||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
||||||
}
|
|
||||||
.features-hero__sub {
|
|
||||||
font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 4px; line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.features-hero__actions--desktop {
|
|
||||||
display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.features-hero__actions--mobile { display: none; }
|
|
||||||
|
|
||||||
@media (max-width: 1199px) {
|
|
||||||
.features-hero__actions--desktop { display: none; }
|
|
||||||
.features-hero__actions--mobile { display: flex; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -177,46 +177,59 @@ async function excluir (id) {
|
|||||||
<template>
|
<template>
|
||||||
<Toast />
|
<Toast />
|
||||||
|
|
||||||
<div class="flex flex-col gap-4 p-4">
|
<!-- Sentinel -->
|
||||||
|
<div class="h-px" />
|
||||||
|
|
||||||
<!-- ── Header ─────────────────────────────────────────── -->
|
<!-- Hero sticky -->
|
||||||
<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
|
||||||
|
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>
|
||||||
<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" />
|
<i class="pi pi-star text-amber-500" />
|
||||||
Feriados Municipais
|
Feriados Municipais
|
||||||
</div>
|
</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.
|
Feriados cadastrados pelos tenants — alimentam o banco central de feriados do SAAS.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Button icon="pi pi-chevron-left" text rounded severity="secondary" @click="anoAnterior" />
|
<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-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-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>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- content -->
|
||||||
|
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
|
||||||
|
|
||||||
<!-- ── Stats ──────────────────────────────────────────── -->
|
<!-- ── Stats ──────────────────────────────────────────── -->
|
||||||
<div class="grid grid-cols-3 gap-3">
|
<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-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>
|
||||||
<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-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>
|
||||||
<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-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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Filtros ─────────────────────────────────────────── -->
|
<!-- ── 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]">
|
<div class="flex-1 min-w-[160px]">
|
||||||
<IconField>
|
<IconField>
|
||||||
<InputIcon class="pi pi-search" />
|
<InputIcon class="pi pi-search" />
|
||||||
@@ -247,34 +260,42 @@ async function excluir (id) {
|
|||||||
|
|
||||||
<template v-else>
|
<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 }}.
|
Nenhum feriado municipal cadastrado para {{ ano }}.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Lista agrupada por data ───────────────────────── -->
|
<!-- ── Lista agrupada por data ───────────────────────── -->
|
||||||
<div v-for="[data, lista] in agrupados" :key="data" class="blk-group">
|
<div
|
||||||
<div class="blk-group__head">
|
v-for="[data, lista] in agrupados"
|
||||||
<span class="font-mono text-sm">{{ fmtDate(data) }}</span>
|
:key="data"
|
||||||
<span class="blk-group__count">{{ lista.length }}</span>
|
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>
|
||||||
|
|
||||||
<div class="blk-list">
|
<div class="flex flex-col">
|
||||||
<div v-for="f in lista" :key="f.id" class="blk-item">
|
<div
|
||||||
<div class="blk-item__name">{{ f.nome }}</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">
|
<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.cidade" :value="f.cidade" severity="secondary" />
|
||||||
<Tag v-if="f.estado" :value="f.estado" severity="info" class="text-xs" />
|
<Tag v-if="f.estado" :value="f.estado" severity="info" />
|
||||||
<Tag v-if="f.bloqueia_sessoes" value="Bloqueia" severity="danger" class="text-xs" />
|
<Tag v-if="f.bloqueia_sessoes" value="Bloqueia" severity="danger" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="f.tenants?.name" class="blk-item__tenant">
|
<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 text-xs" /> {{ f.tenants.name }}
|
<i class="pi pi-building" /> {{ f.tenants.name }}
|
||||||
</div>
|
</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)" />
|
<Button icon="pi pi-trash" text rounded size="small" severity="danger" @click="excluir(f.id)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -295,12 +316,12 @@ async function excluir (id) {
|
|||||||
<div class="flex flex-col gap-4 pt-1">
|
<div class="flex flex-col gap-4 pt-1">
|
||||||
|
|
||||||
<div>
|
<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…" />
|
<InputText v-model="form.nome" class="w-full mt-1" placeholder="Ex.: Padroeiro Municipal, Aniversário da cidade…" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="dlg-label">Data *</label>
|
<label class="text-[1rem] text-[var(--text-color-secondary)] font-medium">Data *</label>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
v-model="form.data"
|
v-model="form.data"
|
||||||
showIcon fluid iconDisplay="input"
|
showIcon fluid iconDisplay="input"
|
||||||
@@ -314,17 +335,17 @@ async function excluir (id) {
|
|||||||
|
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<div class="flex-1">
|
<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" />
|
<InputText v-model="form.cidade" class="w-full mt-1" placeholder="Ex.: São Paulo" />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-24">
|
<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" />
|
<InputText v-model="form.estado" class="w-full mt-1" placeholder="SP" maxlength="2" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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
|
<Select
|
||||||
v-model="form.tenant_id"
|
v-model="form.tenant_id"
|
||||||
:options="tenantOptions"
|
:options="tenantOptions"
|
||||||
@@ -336,13 +357,13 @@ async function excluir (id) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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…" />
|
<Textarea v-model="form.observacao" class="w-full mt-1" rows="2" autoResize placeholder="Nota interna…" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Checkbox v-model="form.bloqueia_sessoes" :binary="true" inputId="bloqueia" />
|
<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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -359,67 +380,3 @@ async function excluir (id) {
|
|||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</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>
|
<template>
|
||||||
<div class="p-4">
|
<!-- Sentinel -->
|
||||||
<div class="text-xl font-semibold">Em construção</div>
|
<div class="h-px" />
|
||||||
<div class="text-color-secondary mt-2">
|
<!-- Hero sticky -->
|
||||||
Esta área do Admin SaaS ainda será implementada.
|
<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>
|
</div>
|
||||||
|
|
||||||
|
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
|
||||||
|
<!-- conteúdo futuro -->
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -398,57 +398,55 @@ onBeforeUnmount(() => {
|
|||||||
<Toast />
|
<Toast />
|
||||||
<ConfirmDialog />
|
<ConfirmDialog />
|
||||||
|
|
||||||
<div class="matrix-root">
|
<!-- Sentinel -->
|
||||||
|
<div ref="heroSentinelRef" class="h-px" />
|
||||||
|
|
||||||
<!-- Info decorativa (scrolls away naturalmente) -->
|
<!-- Hero sticky -->
|
||||||
<div class="flex items-start gap-4 px-4 pb-3">
|
<div
|
||||||
<div class="matrix-hero__icon-wrap">
|
ref="heroEl"
|
||||||
<i class="pi pi-th-large matrix-hero__icon" />
|
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"
|
||||||
</div>
|
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
|
||||||
<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 class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
|
||||||
</div>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- ── HERO ─────────────────────────────────────────────── -->
|
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
|
||||||
<div ref="heroSentinelRef" class="matrix-hero-sentinel" />
|
<div class="min-w-0">
|
||||||
<div ref="heroEl" class="matrix-hero mb-4" :class="{ 'matrix-hero--stuck': heroStuck }">
|
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Controle de Recursos</div>
|
||||||
<div class="matrix-hero__blobs" aria-hidden="true">
|
<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 class="matrix-hero__blob matrix-hero__blob--1" />
|
|
||||||
<div class="matrix-hero__blob matrix-hero__blob--2" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="matrix-hero__inner">
|
<!-- Ações desktop (≥ 1200px) -->
|
||||||
<div class="matrix-hero__info min-w-0">
|
<div class="hidden xl:flex items-center gap-2 flex-wrap">
|
||||||
<div class="matrix-hero__title">Controle de Recursos</div>
|
<SelectButton v-model="targetFilter" :options="targetOptions" optionLabel="label" optionValue="value" size="small" :disabled="loading || saving" />
|
||||||
</div>
|
<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) -->
|
<!-- Ações mobile (< 1200px) -->
|
||||||
<div class="matrix-hero__actions matrix-hero__actions--desktop">
|
<div class="flex xl:hidden">
|
||||||
<SelectButton v-model="targetFilter" :options="targetOptions" optionLabel="label" optionValue="value" size="small" :disabled="loading || saving" />
|
<Button
|
||||||
<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" />
|
label="Ações"
|
||||||
<Button label="Descartar" icon="pi pi-undo" severity="secondary" outlined size="small" :disabled="loading || saving || !hasPending" @click="confirmReset" />
|
icon="pi pi-ellipsis-v"
|
||||||
<Button label="Salvar alterações" icon="pi pi-save" size="small" :loading="saving" :disabled="loading || !hasPending" @click="saveChanges" />
|
severity="warn"
|
||||||
</div>
|
size="small"
|
||||||
|
aria-haspopup="true"
|
||||||
<!-- Ações mobile (< 1200px) -->
|
aria-controls="matrix_hero_menu"
|
||||||
<div class="matrix-hero__actions--mobile">
|
@click="(e) => heroMenuRef.toggle(e)"
|
||||||
<Button
|
/>
|
||||||
label="Ações"
|
<Menu ref="heroMenuRef" id="matrix_hero_menu" :model="heroMenuItems" :popup="true" />
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Search — sempre visível, fora do hero sticky -->
|
<!-- content -->
|
||||||
<div class="px-4 mb-4">
|
<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]">
|
<FloatLabel variant="on" class="w-full md:w-[340px]">
|
||||||
<IconField class="w-full">
|
<IconField class="w-full">
|
||||||
<InputIcon class="pi pi-search" />
|
<InputIcon class="pi pi-search" />
|
||||||
@@ -458,8 +456,7 @@ onBeforeUnmount(() => {
|
|||||||
</FloatLabel>
|
</FloatLabel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-4 pb-4">
|
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-3">
|
||||||
<div class="mb-3 surface-100 border-round p-3">
|
|
||||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
<div class="flex gap-2 items-center flex-wrap">
|
<div class="flex gap-2 items-center flex-wrap">
|
||||||
<Tag :value="`Planos: ${filteredPlans.length}`" severity="info" icon="pi pi-list" rounded />
|
<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 />
|
<Tag v-if="hasPending" value="Alterações pendentes" severity="warn" icon="pi pi-clock" rounded />
|
||||||
</div>
|
</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.
|
Dica: use a busca para reduzir a lista e aplique ações em massa com confirmação.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Divider class="my-4" />
|
<Divider class="my-0" />
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
:value="filteredFeatures"
|
:value="filteredFeatures"
|
||||||
@@ -488,9 +485,9 @@ onBeforeUnmount(() => {
|
|||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<span class="font-medium">{{ data.key }}</span>
|
<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 || '—' }}
|
{{ data.descricao || data.description || '—' }}
|
||||||
</small>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
@@ -506,7 +503,7 @@ onBeforeUnmount(() => {
|
|||||||
{{ planTitle(p) }}
|
{{ planTitle(p) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-center gap-1 flex-wrap">
|
<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 />
|
<Tag :value="targetLabel(p.target)" :severity="targetSeverity(p.target)" rounded />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 justify-center">
|
<div class="flex gap-2 justify-center">
|
||||||
@@ -545,69 +542,5 @@ onBeforeUnmount(() => {
|
|||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</div><!-- /px-4 pb-4 -->
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
|
||||||
@@ -329,56 +329,53 @@ onBeforeUnmount(() => {
|
|||||||
<Toast />
|
<Toast />
|
||||||
<ConfirmDialog />
|
<ConfirmDialog />
|
||||||
|
|
||||||
<div class="limits-root">
|
<!-- Sentinel -->
|
||||||
|
<div ref="heroSentinelRef" class="h-px" />
|
||||||
|
|
||||||
<!-- Info decorativa (scrolls away naturalmente) -->
|
<!-- Hero sticky -->
|
||||||
<div class="flex items-start gap-4 px-4 pb-3">
|
<div
|
||||||
<div class="limits-hero__icon-wrap">
|
ref="heroEl"
|
||||||
<i class="pi pi-sliders-h limits-hero__icon" />
|
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"
|
||||||
</div>
|
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
|
||||||
<div class="limits-hero__sub">
|
>
|
||||||
Configure os limites reais de cada feature por plano (ex: max_patients, max_sessions_per_month).
|
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
|
||||||
Esses valores são lidos pelo sistema para bloquear ações quando o limite é atingido.
|
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-orange-400/10" />
|
||||||
</div>
|
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-indigo-400/10" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── HERO ─────────────────────────────────────────────── -->
|
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
|
||||||
<div ref="heroSentinelRef" class="limits-hero-sentinel" />
|
<div class="min-w-0">
|
||||||
<div ref="heroEl" class="limits-hero mb-4" :class="{ 'limits-hero--stuck': heroStuck }">
|
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Limites por Plano</div>
|
||||||
<div class="limits-hero__blobs" aria-hidden="true">
|
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Configure os limites reais de cada feature por plano.</div>
|
||||||
<div class="limits-hero__blob limits-hero__blob--1" />
|
|
||||||
<div class="limits-hero__blob limits-hero__blob--2" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="limits-hero__inner">
|
<!-- Ações desktop (≥ 1200px) -->
|
||||||
<div class="limits-hero__info min-w-0">
|
<div class="hidden xl:flex items-center gap-2 flex-wrap">
|
||||||
<div class="limits-hero__title">Limites por Plano</div>
|
<SelectButton v-model="targetFilter" :options="targetOptions" optionLabel="label" optionValue="value" size="small" :disabled="loading || saving" />
|
||||||
</div>
|
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loading" :disabled="saving" @click="fetchAll" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Ações desktop (≥ 1200px) -->
|
<!-- Ações mobile (< 1200px) -->
|
||||||
<div class="limits-hero__actions limits-hero__actions--desktop">
|
<div class="flex xl:hidden">
|
||||||
<SelectButton v-model="targetFilter" :options="targetOptions" optionLabel="label" optionValue="value" size="small" :disabled="loading || saving" />
|
<Button
|
||||||
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loading" :disabled="saving" @click="fetchAll" />
|
label="Ações"
|
||||||
</div>
|
icon="pi pi-ellipsis-v"
|
||||||
|
severity="warn"
|
||||||
<!-- Ações mobile (< 1200px) -->
|
size="small"
|
||||||
<div class="limits-hero__actions--mobile">
|
aria-haspopup="true"
|
||||||
<Button
|
aria-controls="limits_hero_menu"
|
||||||
label="Ações"
|
@click="(e) => heroMenuRef.toggle(e)"
|
||||||
icon="pi pi-ellipsis-v"
|
/>
|
||||||
severity="warn"
|
<Menu ref="heroMenuRef" id="limits_hero_menu" :model="heroMenuItems" :popup="true" />
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Search — sempre visível, fora do hero sticky -->
|
<!-- content -->
|
||||||
<div class="px-4 mb-4">
|
<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">
|
<FloatLabel variant="on" class="w-full md:w-80">
|
||||||
<IconField class="w-full">
|
<IconField class="w-full">
|
||||||
<InputIcon class="pi pi-search" />
|
<InputIcon class="pi pi-search" />
|
||||||
@@ -388,19 +385,18 @@ onBeforeUnmount(() => {
|
|||||||
</FloatLabel>
|
</FloatLabel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-4 pb-4">
|
|
||||||
<!-- Legenda rápida -->
|
<!-- 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 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" />
|
<i class="pi pi-info-circle text-blue-400" />
|
||||||
<span><strong>Sem limites</strong> = acesso habilitado sem restrição de quantidade</span>
|
<span><strong>Sem limites</strong> = acesso habilitado sem restrição de quantidade</span>
|
||||||
</div>
|
</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" />
|
<i class="pi pi-info-circle text-orange-400" />
|
||||||
<span><strong>-1</strong> = ilimitado (explícito no JSON, útil para planos PRO)</span>
|
<span><strong>-1</strong> = ilimitado (explícito no JSON, útil para planos PRO)</span>
|
||||||
</div>
|
</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" />
|
<i class="pi pi-info-circle text-red-400" />
|
||||||
<span><strong>0 ou N</strong> = limite máximo que o sistema vai verificar</span>
|
<span><strong>0 ou N</strong> = limite máximo que o sistema vai verificar</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -431,11 +427,11 @@ onBeforeUnmount(() => {
|
|||||||
:severity="domainSeverity(featureDomain(data.feature.key))"
|
:severity="domainSeverity(featureDomain(data.feature.key))"
|
||||||
rounded
|
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>
|
</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 || '—' }}
|
{{ data.feature.descricao || '—' }}
|
||||||
</small>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
@@ -452,7 +448,7 @@ onBeforeUnmount(() => {
|
|||||||
<span class="font-semibold truncate" :title="plan.name">{{ plan.name || plan.key }}</span>
|
<span class="font-semibold truncate" :title="plan.name">{{ plan.name || plan.key }}</span>
|
||||||
<Tag :value="targetLabel(plan.target)" :severity="targetSeverity(plan.target)" rounded />
|
<Tag :value="targetLabel(plan.target)" :severity="targetSeverity(plan.target)" rounded />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -472,7 +468,7 @@ onBeforeUnmount(() => {
|
|||||||
<!-- Limites atuais -->
|
<!-- Limites atuais -->
|
||||||
<div
|
<div
|
||||||
v-if="data.planCols[plan.id].limits"
|
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
|
<div
|
||||||
v-for="(val, key) in data.planCols[plan.id].limits"
|
v-for="(val, key) in data.planCols[plan.id].limits"
|
||||||
@@ -483,7 +479,7 @@ onBeforeUnmount(() => {
|
|||||||
<span>{{ limitValueDisplay(val) }}</span>
|
<span>{{ limitValueDisplay(val) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</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
|
Sem limites definidos
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -508,7 +504,7 @@ onBeforeUnmount(() => {
|
|||||||
@click="askClearLimits(plan, data.feature)"
|
@click="askClearLimits(plan, data.feature)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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/>
|
Feature não vinculada a este plano.<br/>
|
||||||
Configure em <strong>Recursos por Plano</strong>.
|
Configure em <strong>Recursos por Plano</strong>.
|
||||||
</div>
|
</div>
|
||||||
@@ -516,260 +512,195 @@ onBeforeUnmount(() => {
|
|||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div><!-- /px-4 pb-4 -->
|
<!-- Dialog: editar limites de plan_features -->
|
||||||
|
<Dialog
|
||||||
<!-- Dialog: editar limites de plan_features -->
|
v-model:visible="showDlg"
|
||||||
<Dialog
|
modal
|
||||||
v-model:visible="showDlg"
|
:draggable="false"
|
||||||
modal
|
:closable="!saving"
|
||||||
:draggable="false"
|
:dismissableMask="!saving"
|
||||||
:closable="!saving"
|
:style="{ width: '680px' }"
|
||||||
:dismissableMask="!saving"
|
>
|
||||||
:style="{ width: '680px' }"
|
<template #header>
|
||||||
>
|
<div class="flex flex-col gap-1">
|
||||||
<template #header>
|
<div class="text-[1rem] font-semibold">Limites — {{ dlgFeature?.key }}</div>
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
<div class="text-lg font-semibold">Limites — {{ dlgFeature?.key }}</div>
|
<Tag :value="dlgPlan?.name || dlgPlan?.key" severity="secondary" />
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<Tag :value="targetLabel(dlgPlan?.target)" :severity="targetSeverity(dlgPlan?.target)" rounded />
|
||||||
<Tag :value="dlgPlan?.name || dlgPlan?.key" severity="secondary" />
|
|
||||||
<Tag :value="targetLabel(dlgPlan?.target)" :severity="targetSeverity(dlgPlan?.target)" rounded />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
|
|
||||||
<!-- Campos existentes -->
|
<!-- Campos existentes -->
|
||||||
<div v-if="limitFields.length">
|
<div v-if="limitFields.length">
|
||||||
<div class="font-semibold mb-2">Limites configurados</div>
|
<div class="font-semibold mb-2">Limites configurados</div>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<div
|
<div
|
||||||
v-for="(field, idx) in limitFields"
|
v-for="(field, idx) in limitFields"
|
||||||
:key="idx"
|
:key="idx"
|
||||||
class="flex items-center gap-3 surface-100 border-round p-3"
|
class="flex items-center gap-3 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-3"
|
||||||
>
|
>
|
||||||
<!-- Key (não editável) -->
|
<!-- Key (não editável) -->
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<div class="font-mono font-medium text-sm">{{ field.key }}</div>
|
<div class="font-mono font-medium text-[1rem]">{{ field.key }}</div>
|
||||||
<small class="text-color-secondary">{{ field.type }}</small>
|
<div class="text-[1rem] text-[var(--text-color-secondary)]">{{ field.type }}</div>
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tipo + Valor + Botão -->
|
<!-- Valor -->
|
||||||
<div class="flex items-end gap-2 flex-wrap">
|
<div class="w-40 shrink-0">
|
||||||
<div>
|
<template v-if="field.type === 'number'">
|
||||||
<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>
|
|
||||||
<InputNumber
|
<InputNumber
|
||||||
v-if="newLimitType === 'number'"
|
v-model="field.value"
|
||||||
v-model="newLimitValue"
|
|
||||||
class="w-full"
|
class="w-full"
|
||||||
inputClass="w-full"
|
inputClass="w-full"
|
||||||
variant="filled"
|
|
||||||
:disabled="saving"
|
:disabled="saving"
|
||||||
:min="-1"
|
:min="-1"
|
||||||
placeholder="-1 = ilimitado"
|
placeholder="-1 = ilimitado"
|
||||||
/>
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="field.type === 'boolean'">
|
||||||
<SelectButton
|
<SelectButton
|
||||||
v-else-if="newLimitType === 'boolean'"
|
v-model="field.value"
|
||||||
v-model="newLimitValue"
|
|
||||||
:options="[{ label: 'Sim', value: true }, { label: 'Não', value: false }]"
|
:options="[{ label: 'Sim', value: true }, { label: 'Não', value: false }]"
|
||||||
optionLabel="label"
|
optionLabel="label"
|
||||||
optionValue="value"
|
optionValue="value"
|
||||||
:disabled="saving"
|
:disabled="saving"
|
||||||
/>
|
/>
|
||||||
<InputText
|
</template>
|
||||||
v-else
|
<template v-else>
|
||||||
v-model="newLimitValue"
|
<InputText v-model="field.value" class="w-full" :disabled="saving" />
|
||||||
class="w-full"
|
</template>
|
||||||
variant="filled"
|
</div>
|
||||||
:disabled="saving"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<!-- Ações rápidas -->
|
||||||
|
<div class="flex gap-1 shrink-0">
|
||||||
<Button
|
<Button
|
||||||
icon="pi pi-plus"
|
icon="pi pi-infinity"
|
||||||
label="Adicionar"
|
size="small"
|
||||||
:disabled="saving || !newLimitKey?.trim()"
|
severity="secondary"
|
||||||
@click="addLimitField"
|
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>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Dica de boas práticas -->
|
<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">
|
||||||
<div class="surface-100 border-round p-3 text-xs text-color-secondary leading-relaxed">
|
Nenhum limite configurado. Adicione abaixo.
|
||||||
<div class="font-semibold mb-1">Convenções recomendadas</div>
|
</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>
|
<Divider />
|
||||||
<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>
|
<!-- Adicionar novo campo -->
|
||||||
<div><span class="font-mono">max_therapists</span> — terapeutas vinculados</div>
|
<div>
|
||||||
<div><span class="font-mono">-1</span> — sem limite (planos PRO)</div>
|
<div class="font-semibold mb-3">Adicionar campo de limite</div>
|
||||||
<div><span class="font-mono">0</span> — bloqueado completamente</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<!-- Dica de boas práticas -->
|
||||||
<Button label="Cancelar" severity="secondary" outlined :disabled="saving" @click="showDlg = false" />
|
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-3 text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">
|
||||||
<Button label="Salvar limites" icon="pi pi-check" :loading="saving" @click="saveLimits" />
|
<div class="font-semibold mb-1">Convenções recomendadas</div>
|
||||||
</template>
|
<div class="grid grid-cols-2 gap-x-4 gap-y-1">
|
||||||
</Dialog>
|
<div><span class="font-mono">max_patients</span> — número máximo de pacientes</div>
|
||||||
</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>
|
||||||
|
|
||||||
|
<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>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.limits-root { padding: 1rem; }
|
|
||||||
@media (min-width: 768px) { .limits-root { padding: 1.5rem; } }
|
|
||||||
|
|
||||||
.limits-hero-sentinel { height: 1px; }
|
|
||||||
|
|
||||||
.limits-hero {
|
|
||||||
position: sticky;
|
|
||||||
top: var(--layout-sticky-top, 56px);
|
|
||||||
z-index: 20;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 1.75rem;
|
|
||||||
border: 1px solid var(--surface-border);
|
|
||||||
background: var(--surface-card);
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
.limits-hero--stuck {
|
|
||||||
border-top-left-radius: 0;
|
|
||||||
border-top-right-radius: 0;
|
|
||||||
}
|
|
||||||
.limits-hero__blobs {
|
|
||||||
position: absolute; inset: 0; pointer-events: none; overflow: hidden;
|
|
||||||
}
|
|
||||||
.limits-hero__blob {
|
|
||||||
position: absolute; border-radius: 50%; filter: blur(70px);
|
|
||||||
}
|
|
||||||
.limits-hero__blob--1 { width: 20rem; height: 20rem; top: -5rem; right: -4rem; background: rgba(251,146,60,0.12); }
|
|
||||||
.limits-hero__blob--2 { width: 18rem; height: 18rem; top: 1rem; left: -5rem; background: rgba(99,102,241,0.09); }
|
|
||||||
|
|
||||||
.limits-hero__inner {
|
|
||||||
position: relative; z-index: 1;
|
|
||||||
display: flex; align-items: center; gap: 1.25rem; flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.limits-hero__icon-wrap {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 4rem; height: 4rem; border-radius: 1.125rem;
|
|
||||||
border: 2px solid var(--surface-border);
|
|
||||||
background: var(--surface-ground);
|
|
||||||
display: grid; place-items: center;
|
|
||||||
}
|
|
||||||
.limits-hero__icon { font-size: 1.5rem; color: var(--text-color); }
|
|
||||||
|
|
||||||
.limits-hero__info { flex: 1; min-width: 0; }
|
|
||||||
.limits-hero__title {
|
|
||||||
font-size: 1.25rem; font-weight: 700; letter-spacing: -0.025em;
|
|
||||||
color: var(--text-color); line-height: 1.2;
|
|
||||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
||||||
}
|
|
||||||
.limits-hero__sub {
|
|
||||||
font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 4px; line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.limits-hero__actions--desktop {
|
|
||||||
display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.limits-hero__actions--mobile { display: none; }
|
|
||||||
|
|
||||||
@media (max-width: 1199px) {
|
|
||||||
.limits-hero__actions--desktop { display: none; }
|
|
||||||
.limits-hero__actions--mobile { display: flex; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -432,321 +432,231 @@ onBeforeUnmount(() => {
|
|||||||
<Toast />
|
<Toast />
|
||||||
<ConfirmDialog />
|
<ConfirmDialog />
|
||||||
|
|
||||||
<div class="plans-root">
|
<!-- Sentinel -->
|
||||||
|
<div ref=”heroSentinelRef” class=”h-px” />
|
||||||
|
|
||||||
<!-- Info decorativa (scrolls away naturalmente) -->
|
<!-- Hero sticky -->
|
||||||
<div class="flex items-start gap-4 px-4 pb-3">
|
<div
|
||||||
<div class="plans-hero__icon-wrap">
|
ref=”heroEl”
|
||||||
<i class="pi pi-list plans-hero__icon" />
|
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”
|
||||||
</div>
|
:style=”{ top: 'var(--layout-sticky-top, 56px)' }”
|
||||||
<div class="plans-hero__sub">
|
>
|
||||||
Catálogo de planos do SaaS. A <b>key</b> é a referência técnica estável.
|
<div class=”absolute inset-0 pointer-events-none overflow-hidden” aria-hidden=”true”>
|
||||||
O <b>público</b> indica se o plano é para <b>Clínica</b> ou <b>Terapeuta</b>.
|
<div class=”absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10” />
|
||||||
</div>
|
<div class=”absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10” />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── HERO ─────────────────────────────────────────────── -->
|
<div class=”relative z-10 flex items-center justify-between gap-3 flex-wrap”>
|
||||||
<div ref="heroSentinelRef" class="plans-hero-sentinel" />
|
<div class=”min-w-0”>
|
||||||
<div ref="heroEl" class="plans-hero mb-5" :class="{ 'plans-hero--stuck': heroStuck }">
|
<div class=”text-[1rem] font-bold tracking-tight text-[var(--text-color)]”>Planos e preços</div>
|
||||||
<div class="plans-hero__blobs" aria-hidden="true">
|
<div class=”text-[1rem] text-[var(--text-color-secondary)] mt-0.5”>Catálogo de planos do SaaS.</div>
|
||||||
<div class="plans-hero__blob plans-hero__blob--1" />
|
|
||||||
<div class="plans-hero__blob plans-hero__blob--2" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="plans-hero__inner">
|
<!-- Ações desktop (≥ 1200px) -->
|
||||||
<!-- Título -->
|
<div class=”hidden xl:flex items-center gap-2 flex-wrap”>
|
||||||
<div class="plans-hero__info min-w-0">
|
<SelectButton
|
||||||
<div class="plans-hero__title">Planos e preços</div>
|
v-model=”targetFilter”
|
||||||
</div>
|
: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) -->
|
<!-- Ações mobile (< 1200px) -->
|
||||||
<div class="plans-hero__actions plans-hero__actions--desktop">
|
<div class=”flex xl:hidden”>
|
||||||
<SelectButton
|
<Button
|
||||||
v-model="targetFilter"
|
label=”Ações”
|
||||||
:options="targetFilterOptions"
|
icon=”pi pi-ellipsis-v”
|
||||||
optionLabel="label"
|
severity=”warn”
|
||||||
optionValue="value"
|
size=”small”
|
||||||
size="small"
|
aria-haspopup=”true”
|
||||||
/>
|
aria-controls=”plans_hero_menu”
|
||||||
<Button label="Atualizar" icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loading" :disabled="saving" @click="fetchAll" />
|
@click=”(e) => heroMenuRef.toggle(e)”
|
||||||
<Button label="Adicionar plano" icon="pi pi-plus" size="small" :disabled="saving" @click="openCreate" />
|
/>
|
||||||
</div>
|
<Menu ref=”heroMenuRef” id=”plans_hero_menu” :model=”heroMenuItems” :popup=”true” />
|
||||||
|
|
||||||
<!-- 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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="px-4 pb-4">
|
<!-- content -->
|
||||||
<DataTable :value="filteredRows" dataKey="id" :loading="loading" stripedRows responsiveLayout="scroll">
|
<div class=”px-3 md:px-4 pb-8 flex flex-col gap-4”>
|
||||||
<Column field="name" header="Nome" sortable style="min-width: 14rem" />
|
<DataTable :value=”filteredRows” dataKey=”id” :loading=”loading” stripedRows responsiveLayout=”scroll”>
|
||||||
<Column field="key" header="Key" sortable />
|
<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">
|
<Column field=”target” header=”Público” sortable style=”width: 10rem”>
|
||||||
<template #body="{ data }">
|
<template #body=”{ data }”>
|
||||||
<span class="font-medium">{{ formatTargetLabel(data.target) }}</span>
|
<span class=”font-medium”>{{ formatTargetLabel(data.target) }}</span>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<Column header="Mensal" sortable style="width: 12rem">
|
<Column header=”Mensal” sortable style=”width: 12rem”>
|
||||||
<template #body="{ data }">
|
<template #body=”{ data }”>
|
||||||
<span class="font-medium">{{ formatBRLFromCents(data.monthly_cents) }}</span>
|
<span class=”font-medium”>{{ formatBRLFromCents(data.monthly_cents) }}</span>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<Column header="Anual" sortable style="width: 12rem">
|
<Column header=”Anual” sortable style=”width: 12rem”>
|
||||||
<template #body="{ data }">
|
<template #body=”{ data }”>
|
||||||
<span class="font-medium">{{ formatBRLFromCents(data.yearly_cents) }}</span>
|
<span class=”font-medium”>{{ formatBRLFromCents(data.yearly_cents) }}</span>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</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">
|
<Column header=”Ações” style=”width: 12rem”>
|
||||||
<template #body="{ data }">
|
<template #body=”{ data }”>
|
||||||
<div class="flex gap-2">
|
<div class=”flex gap-2”>
|
||||||
<Button icon="pi pi-pencil" severity="secondary" outlined @click="openEdit(data)" />
|
<Button icon=”pi pi-pencil” severity=”secondary” outlined @click=”openEdit(data)” />
|
||||||
<Button
|
<Button
|
||||||
icon="pi pi-trash"
|
icon=”pi pi-trash”
|
||||||
severity="danger"
|
severity=”danger”
|
||||||
outlined
|
outlined
|
||||||
:disabled="isDeleteLockedRow(data)"
|
:disabled=”isDeleteLockedRow(data)”
|
||||||
:title="isDeleteLockedRow(data) ? 'Plano padrão do sistema não pode ser removido.' : 'Excluir plano'"
|
:title=”isDeleteLockedRow(data) ? 'Plano padrão do sistema não pode ser removido.' : 'Excluir plano'”
|
||||||
@click="askDelete(data)"
|
@click=”askDelete(data)”
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
v-model:visible="showDlg"
|
v-model:visible=”showDlg”
|
||||||
modal
|
modal
|
||||||
:draggable="false"
|
:draggable=”false”
|
||||||
:header="isEdit ? 'Editar plano' : 'Novo plano'"
|
:header=”isEdit ? 'Editar plano' : 'Novo plano'”
|
||||||
:style="{ width: '620px' }"
|
:style=”{ width: '620px' }”
|
||||||
class="plans-dialog"
|
>
|
||||||
>
|
<div class=”flex flex-col gap-4”>
|
||||||
<div class="flex flex-col gap-4">
|
<div>
|
||||||
<div>
|
<label class=”block mb-2”>Público do plano</label>
|
||||||
<label class="block mb-2">Público do plano</label>
|
<SelectButton
|
||||||
<SelectButton
|
v-model=”form.target”
|
||||||
v-model="form.target"
|
:options=”targetOptions”
|
||||||
:options="targetOptions"
|
optionLabel=”label”
|
||||||
optionLabel="label"
|
optionValue=”value”
|
||||||
optionValue="value"
|
class=”w-full”
|
||||||
class="w-full"
|
:disabled=”isTargetLocked || saving”
|
||||||
:disabled="isTargetLocked || saving"
|
/>
|
||||||
/>
|
<div class=”text-[1rem] text-[var(--text-color-secondary)] mt-1”>
|
||||||
<small class="text-color-secondary">
|
Planos já existentes não mudam de público. Isso evita inconsistência no catálogo.
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<FloatLabel variant=”on” class=”w-full”>
|
||||||
<Button label="Cancelar" severity="secondary" outlined @click="showDlg = false" :disabled="saving" />
|
<IconField class=”w-full”>
|
||||||
<Button :label="isEdit ? 'Salvar' : 'Criar'" icon="pi pi-check" :loading="saving" @click="save" />
|
<InputIcon class=”pi pi-tag” />
|
||||||
</template>
|
<InputText
|
||||||
</Dialog>
|
v-model=”form.key”
|
||||||
</div>
|
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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<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>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* ─── Root ──────────────────────────────────────────────── */
|
|
||||||
.plans-root { padding: 1rem; }
|
|
||||||
@media (min-width: 768px) { .plans-root { padding: 1.5rem; } }
|
|
||||||
|
|
||||||
/* ─── Hero ──────────────────────────────────────────────── */
|
|
||||||
.plans-hero-sentinel { height: 1px; }
|
|
||||||
|
|
||||||
.plans-hero {
|
|
||||||
position: sticky;
|
|
||||||
top: var(--layout-sticky-top, 56px);
|
|
||||||
z-index: 20;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 1.75rem;
|
|
||||||
border: 1px solid var(--surface-border);
|
|
||||||
background: var(--surface-card);
|
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
|
||||||
.plans-hero--stuck {
|
|
||||||
border-top-left-radius: 0;
|
|
||||||
border-top-right-radius: 0;
|
|
||||||
}
|
|
||||||
.plans-hero__blobs {
|
|
||||||
position: absolute; inset: 0; pointer-events: none; overflow: hidden;
|
|
||||||
}
|
|
||||||
.plans-hero__blob {
|
|
||||||
position: absolute; border-radius: 50%; filter: blur(70px);
|
|
||||||
}
|
|
||||||
.plans-hero__blob--1 { width: 20rem; height: 20rem; top: -5rem; right: -4rem; background: rgba(99,102,241,0.12); }
|
|
||||||
.plans-hero__blob--2 { width: 18rem; height: 18rem; top: 1rem; left: -5rem; background: rgba(52,211,153,0.09); }
|
|
||||||
|
|
||||||
.plans-hero__inner {
|
|
||||||
position: relative; z-index: 1;
|
|
||||||
display: flex; align-items: center; gap: 1.25rem; flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ícone */
|
|
||||||
.plans-hero__icon-wrap {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 4rem; height: 4rem; border-radius: 1.125rem;
|
|
||||||
border: 2px solid var(--surface-border);
|
|
||||||
background: var(--surface-ground);
|
|
||||||
display: grid; place-items: center;
|
|
||||||
}
|
|
||||||
.plans-hero__icon { font-size: 1.5rem; color: var(--text-color); }
|
|
||||||
|
|
||||||
/* Info */
|
|
||||||
.plans-hero__info { flex: 1; min-width: 0; }
|
|
||||||
.plans-hero__title {
|
|
||||||
font-size: 1.25rem; font-weight: 700; letter-spacing: -0.025em;
|
|
||||||
color: var(--text-color); line-height: 1.2;
|
|
||||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
||||||
}
|
|
||||||
.plans-hero__sub {
|
|
||||||
font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 4px; line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Ações */
|
|
||||||
.plans-hero__actions--desktop {
|
|
||||||
display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.plans-hero__actions--mobile { display: none; }
|
|
||||||
|
|
||||||
@media (max-width: 1199px) {
|
|
||||||
.plans-hero__actions--desktop { display: none; }
|
|
||||||
.plans-hero__actions--mobile { display: flex; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Dialog: linhas divisórias no header e footer */
|
|
||||||
:deep(.plans-dialog .p-dialog-header) {
|
|
||||||
border-bottom: 1px solid var(--surface-border);
|
|
||||||
}
|
|
||||||
:deep(.plans-dialog .p-dialog-footer) {
|
|
||||||
border-top: 1px solid var(--surface-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pequena melhoria de leitura */
|
|
||||||
small.text-color-secondary {
|
|
||||||
line-height: 1.35rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -449,152 +449,149 @@ onBeforeUnmount(() => {
|
|||||||
<Toast />
|
<Toast />
|
||||||
<ConfirmDialog />
|
<ConfirmDialog />
|
||||||
|
|
||||||
<div class="showcase-root">
|
<!-- Sentinel -->
|
||||||
|
<div ref=”heroSentinelRef” class=”h-px” />
|
||||||
|
|
||||||
<!-- Info decorativa (scrolls away naturalmente) -->
|
<!-- Hero sticky -->
|
||||||
<div class="flex items-start gap-4 px-4 pb-3">
|
<div
|
||||||
<div class="showcase-hero__icon-wrap">
|
ref=”heroEl”
|
||||||
<i class="pi pi-megaphone showcase-hero__icon" />
|
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”
|
||||||
</div>
|
:style=”{ top: 'var(--layout-sticky-top, 56px)' }”
|
||||||
<div class="showcase-hero__sub">
|
>
|
||||||
Configure como os planos aparecem na página pública — nome, descrição, badge, ordem e benefícios.
|
<div class=”absolute inset-0 pointer-events-none overflow-hidden” aria-hidden=”true”>
|
||||||
</div>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- ── HERO ─────────────────────────────────────────────── -->
|
<div class=”relative z-10 flex items-center justify-between gap-3 flex-wrap”>
|
||||||
<div ref="heroSentinelRef" class="showcase-hero-sentinel" />
|
<div class=”min-w-0”>
|
||||||
<div ref="heroEl" class="showcase-hero mb-4" :class="{ 'showcase-hero--stuck': heroStuck }">
|
<div class=”text-[1rem] font-bold tracking-tight text-[var(--text-color)]”>Vitrine de Planos</div>
|
||||||
<div class="showcase-hero__blobs" aria-hidden="true">
|
<div class=”text-[1rem] text-[var(--text-color-secondary)] mt-0.5”>Configure como os planos aparecem na página pública.</div>
|
||||||
<div class="showcase-hero__blob showcase-hero__blob--1" />
|
|
||||||
<div class="showcase-hero__blob showcase-hero__blob--2" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="showcase-hero__inner">
|
<!-- Ações desktop (≥ 1200px) -->
|
||||||
<div class="showcase-hero__info min-w-0">
|
<div class=”hidden xl:flex items-center gap-2 flex-wrap”>
|
||||||
<div class="showcase-hero__title">Vitrine de Planos</div>
|
<SelectButton v-model=”targetFilter” :options=”targetOptions” optionLabel=”label” optionValue=”value” size=”small” :disabled=”loading || saving || bulletSaving” />
|
||||||
</div>
|
<Button label=”Recarregar” icon=”pi pi-refresh” severity=”secondary” outlined size=”small” :loading=”loading” :disabled=”saving || bulletSaving” @click=”fetchAll” />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Ações desktop (≥ 1200px) -->
|
<!-- Ações mobile (< 1200px) -->
|
||||||
<div class="showcase-hero__actions showcase-hero__actions--desktop">
|
<div class=”flex xl:hidden”>
|
||||||
<SelectButton v-model="targetFilter" :options="targetOptions" optionLabel="label" optionValue="value" size="small" :disabled="loading || saving || bulletSaving" />
|
<Button
|
||||||
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loading" :disabled="saving || bulletSaving" @click="fetchAll" />
|
label=”Ações”
|
||||||
</div>
|
icon=”pi pi-ellipsis-v”
|
||||||
|
severity=”warn”
|
||||||
<!-- Ações mobile (< 1200px) -->
|
size=”small”
|
||||||
<div class="showcase-hero__actions--mobile">
|
aria-haspopup=”true”
|
||||||
<Button
|
aria-controls=”showcase_hero_menu”
|
||||||
label="Ações"
|
@click=”(e) => heroMenuRef.toggle(e)”
|
||||||
icon="pi pi-ellipsis-v"
|
/>
|
||||||
severity="warn"
|
<Menu ref=”heroMenuRef” id=”showcase_hero_menu” :model=”heroMenuItems” :popup=”true” />
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Search — sempre visível, fora do hero sticky -->
|
<!-- content -->
|
||||||
<div class="px-4 mb-4">
|
<div class=”px-3 md:px-4 pb-8 flex flex-col gap-4”>
|
||||||
<FloatLabel variant="on" class="w-full md:w-80">
|
|
||||||
<IconField class="w-full">
|
<!-- Search -->
|
||||||
<InputIcon class="pi pi-search" />
|
<div>
|
||||||
<InputText v-model="q" id="plans_public_search" class="w-full pr-10" variant="filled" :disabled="loading || saving || bulletSaving" />
|
<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>
|
</IconField>
|
||||||
<label for="plans_public_search">Buscar plano</label>
|
<label for=”plans_public_search”>Buscar plano</label>
|
||||||
</FloatLabel>
|
</FloatLabel>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Popover global (reutilizado) -->
|
<!-- Popover global (reutilizado) -->
|
||||||
<Popover ref="bulletsPop">
|
<Popover ref=”bulletsPop”>
|
||||||
<div class="w-[340px] max-w-[80vw]">
|
<div class=”w-[340px] max-w-[80vw]”>
|
||||||
<div class="text-sm font-semibold mb-2">{{ popPlanTitle }}</div>
|
<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.
|
Nenhum benefício configurado.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul v-else class="m-0 pl-4 space-y-2">
|
<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">
|
<li v-for=”b in popBullets” :key=”b.id” class=”text-[1rem] leading-snug”>
|
||||||
<span :class="b.highlight ? 'font-semibold' : ''">
|
<span :class=”b.highlight ? 'font-semibold' : ''”>
|
||||||
{{ b.text }}
|
{{ b.text }}
|
||||||
</span>
|
</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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|
||||||
<div class="px-4 pb-4">
|
<DataTable :value=”tableRows” dataKey=”plan_id” :loading=”loading” stripedRows responsiveLayout=”scroll”>
|
||||||
<DataTable :value="tableRows" dataKey="plan_id" :loading="loading" stripedRows responsiveLayout="scroll">
|
<Column header=”Plano” style=”min-width: 18rem”>
|
||||||
<Column header="Plano" style="min-width: 18rem">
|
<template #body=”{ data }”>
|
||||||
<template #body="{ data }">
|
<div class=”flex flex-col”>
|
||||||
<div class="flex flex-col">
|
<span class=”font-semibold”>{{ data.public_name || data.plan_name || data.plan_key }}</span>
|
||||||
<span class="font-semibold">{{ data.public_name || data.plan_name || data.plan_key }}</span>
|
<div class=”text-[1rem] text-[var(--text-color-secondary)]”>
|
||||||
<small class="text-color-secondary">
|
|
||||||
{{ data.plan_key }} • {{ data.plan_name || '—' }}
|
{{ data.plan_key }} • {{ data.plan_name || '—' }}
|
||||||
</small>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<Column header="Público" style="width: 10rem">
|
<Column header=”Público” style=”width: 10rem”>
|
||||||
<template #body="{ data }">
|
<template #body=”{ data }”>
|
||||||
<Tag :value="targetLabel(normalizeTarget(data))" :severity="targetSeverity(normalizeTarget(data))" rounded />
|
<Tag :value=”targetLabel(normalizeTarget(data))” :severity=”targetSeverity(normalizeTarget(data))” rounded />
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<Column header="Mensal" style="width: 12rem">
|
<Column header=”Mensal” style=”width: 12rem”>
|
||||||
<template #body="{ data }">
|
<template #body=”{ data }”>
|
||||||
<span class="font-medium">{{ formatBRLFromCents(data.monthly_cents) }}</span>
|
<span class=”font-medium”>{{ formatBRLFromCents(data.monthly_cents) }}</span>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<Column header="Anual" style="width: 12rem">
|
<Column header=”Anual” style=”width: 12rem”>
|
||||||
<template #body="{ data }">
|
<template #body=”{ data }”>
|
||||||
<span class="font-medium">{{ formatBRLFromCents(data.yearly_cents) }}</span>
|
<span class=”font-medium”>{{ formatBRLFromCents(data.yearly_cents) }}</span>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</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">
|
<Column header=”Visível” style=”width: 8rem”>
|
||||||
<template #body="{ data }">
|
<template #body=”{ data }”>
|
||||||
<span>{{ data.is_visible ? 'Sim' : 'Não' }}</span>
|
<span>{{ data.is_visible ? 'Sim' : 'Não' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<Column header="Destaque" style="width: 9rem">
|
<Column header=”Destaque” style=”width: 9rem”>
|
||||||
<template #body="{ data }">
|
<template #body=”{ data }”>
|
||||||
<span>{{ data.is_featured ? 'Sim' : 'Não' }}</span>
|
<span>{{ data.is_featured ? 'Sim' : 'Não' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</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">
|
<Column header=”Ações” style=”width: 14rem”>
|
||||||
<template #body="{ data }">
|
<template #body=”{ data }”>
|
||||||
<div class="flex gap-2 justify-end">
|
<div class=”flex gap-2 justify-end”>
|
||||||
<Button
|
<Button
|
||||||
severity="secondary"
|
severity=”secondary”
|
||||||
outlined
|
outlined
|
||||||
size="small"
|
size=”small”
|
||||||
:disabled="loading || saving || bulletSaving"
|
:disabled=”loading || saving || bulletSaving”
|
||||||
@click="(e) => openBulletsPopover(e, data)"
|
@click=”(e) => openBulletsPopover(e, data)”
|
||||||
>
|
>
|
||||||
<i class="pi pi-list mr-2" />
|
<i class=”pi pi-list mr-2” />
|
||||||
<span class="font-medium">{{ data.bullets?.length || 0 }}</span>
|
<span class=”font-medium”>{{ data.bullets?.length || 0 }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
icon="pi pi-pencil"
|
icon=”pi pi-pencil”
|
||||||
severity="secondary"
|
severity=”secondary”
|
||||||
outlined
|
outlined
|
||||||
size="small"
|
size=”small”
|
||||||
:disabled="loading || saving || bulletSaving"
|
:disabled=”loading || saving || bulletSaving”
|
||||||
@click="openEdit(data)"
|
@click=”openEdit(data)”
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -602,407 +599,338 @@ onBeforeUnmount(() => {
|
|||||||
</DataTable>
|
</DataTable>
|
||||||
|
|
||||||
<!-- PREVIEW PÚBLICO (conceitual) -->
|
<!-- PREVIEW PÚBLICO (conceitual) -->
|
||||||
<div class="mt-10">
|
<div class=”rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden”>
|
||||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
|
<!-- Hero -->
|
||||||
<!-- Hero -->
|
<div class=”relative p-6 md:p-10”>
|
||||||
<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="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="relative">
|
<div class=”flex flex-col md:flex-row md:items-end md:justify-between gap-6”>
|
||||||
<div class="flex flex-col md:flex-row md:items-end md:justify-between gap-6">
|
<div class=”max-w-2xl”>
|
||||||
<div class="max-w-2xl">
|
<div class=”flex items-center gap-2 mb-3 flex-wrap”>
|
||||||
<div class="flex items-center gap-2 mb-3 flex-wrap">
|
<Tag
|
||||||
<Tag
|
:value=”targetFilter === 'all' ? 'Vitrine (Todos)' : `Vitrine (${targetLabel(targetFilter)})`”
|
||||||
:value="targetFilter === 'all' ? 'Vitrine (Todos)' : `Vitrine (${targetLabel(targetFilter)})`"
|
:severity=”targetFilter === 'therapist' ? 'success' : (targetFilter === 'clinic' ? 'info' : 'secondary')”
|
||||||
:severity="targetFilter === 'therapist' ? 'success' : (targetFilter === 'clinic' ? 'info' : 'secondary')"
|
rounded
|
||||||
rounded
|
/>
|
||||||
/>
|
<div class=”text-[1rem] text-[var(--text-color-secondary)]”>
|
||||||
<span class="text-sm text-color-secondary">
|
Ajuste nomes, descrições, badges e benefícios — e veja o resultado aqui.
|
||||||
Ajuste nomes, descrições, badges e benefícios — e veja o resultado aqui.
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 class="text-3xl md:text-5xl font-semibold leading-tight">
|
|
||||||
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>
|
||||||
|
|
||||||
<div class="flex flex-col items-start md:items-end gap-4">
|
<div class=”text-3xl md:text-5xl font-semibold leading-tight”>
|
||||||
<div class="flex flex-col gap-2">
|
Um plano não é preço.<br />
|
||||||
<div class="text-sm text-color-secondary">Cobrança</div>
|
É promessa organizada.
|
||||||
<SelectButton
|
</div>
|
||||||
v-model="billingInterval"
|
|
||||||
:options="intervalOptions"
|
<div class=”text-[var(--text-color-secondary)] mt-3”>
|
||||||
optionLabel="label"
|
A vitrine é o lugar onde o produto deixa de ser tabela e vira escolha.
|
||||||
optionValue="value"
|
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>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
<div class=”text-[1rem] text-[var(--text-color-secondary)]”>{{ p.plan_key }}</div>
|
||||||
<div class="text-sm text-color-secondary">Planos sem preço</div>
|
</div>
|
||||||
<SelectButton
|
|
||||||
v-model="previewPricePolicy"
|
<div class=”mt-4”>
|
||||||
:options="previewPolicyOptions"
|
<template v-if=”priceDisplayForPreview(p).kind === 'paid'”>
|
||||||
optionLabel="label"
|
<div class=”text-4xl font-semibold leading-none”>
|
||||||
optionValue="value"
|
{{ priceDisplayForPreview(p).main }}
|
||||||
/>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Cards -->
|
<div v-if=”previewPricePolicy === 'hide'” class=”mt-6 text-[1rem] text-[var(--text-color-secondary)]”>
|
||||||
<div class="p-6 md:p-10 pt-0">
|
Observação: planos sem preço não aparecem no preview (política atual).
|
||||||
<div v-if="!previewPlans.length" class="text-sm text-color-secondary">
|
Para exibir como “Sob consulta”, mude acima.
|
||||||
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div><!-- /px-4 pb-4 -->
|
</div><!-- /content -->
|
||||||
|
|
||||||
<!-- Dialog principal (✅ sem drag: removemos draggable) -->
|
<!-- Dialog principal (✅ sem drag: removemos draggable) -->
|
||||||
<Dialog
|
<Dialog
|
||||||
v-model:visible="showDlg"
|
v-model:visible=”showDlg”
|
||||||
modal
|
modal
|
||||||
header="Editar vitrine"
|
header=”Editar vitrine”
|
||||||
:style="{ width: '820px' }"
|
:style=”{ width: '820px' }”
|
||||||
:closable="!saving"
|
:closable=”!saving”
|
||||||
:dismissableMask="!saving"
|
:dismissableMask=”!saving”
|
||||||
:draggable="false"
|
:draggable=”false”
|
||||||
>
|
>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div class=”grid grid-cols-1 md:grid-cols-2 gap-6”>
|
||||||
<div class="flex flex-col gap-4">
|
<div class=”flex flex-col gap-4”>
|
||||||
<!-- ✅ Nome público (FloatLabel + Icon) -->
|
<!-- ✅ Nome público (FloatLabel + Icon) -->
|
||||||
<FloatLabel variant="on">
|
<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>
|
<IconField>
|
||||||
<InputIcon class="pi pi-tag" />
|
<InputIcon class=”pi pi-sort-amount-up-alt” />
|
||||||
<InputText
|
<InputNumber
|
||||||
id="pp-public-name"
|
id=”pp-sort”
|
||||||
v-model.trim="form.public_name"
|
v-model=”form.sort_order”
|
||||||
class="w-full"
|
class=”w-full”
|
||||||
variant="filled"
|
inputClass=”w-full”
|
||||||
:disabled="saving"
|
:disabled=”saving”
|
||||||
autocomplete="off"
|
|
||||||
autofocus
|
|
||||||
@keydown.enter.prevent="save"
|
|
||||||
/>
|
/>
|
||||||
</IconField>
|
</IconField>
|
||||||
<label for="pp-public-name">Nome público *</label>
|
<label for=”pp-sort”>Ordem</label>
|
||||||
</FloatLabel>
|
</FloatLabel>
|
||||||
|
|
||||||
<!-- ✅ Descrição pública -->
|
<div class=”flex flex-col gap-3 pt-2”>
|
||||||
<FloatLabel variant="on">
|
<div class=”flex items-center gap-2”>
|
||||||
<IconField>
|
<Checkbox v-model=”form.is_visible” :binary=”true” :disabled=”saving” />
|
||||||
<InputIcon class="pi pi-align-left" />
|
<label>Visível no público</label>
|
||||||
<Textarea
|
</div>
|
||||||
id="pp-public-desc"
|
<div class=”flex items-center gap-2”>
|
||||||
v-model.trim="form.public_description"
|
<Checkbox v-model=”form.is_featured” :binary=”true” :disabled=”saving” />
|
||||||
class="w-full"
|
<label>Destaque</label>
|
||||||
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>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<!-- bullets -->
|
||||||
<Button label="Cancelar" severity="secondary" outlined :disabled="saving" @click="showDlg = false" />
|
<div>
|
||||||
<Button label="Salvar" icon="pi pi-check" :loading="saving" @click="save" />
|
<div class=”flex items-center justify-between mb-3”>
|
||||||
</template>
|
<div class=”font-semibold”>Benefícios (bullets)</div>
|
||||||
</Dialog>
|
<Button label=”Adicionar” icon=”pi pi-plus” size=”small” :disabled=”saving || bulletSaving” @click=”openBulletCreate” />
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Dialog bullet (✅ sem drag + inputs padronizados) -->
|
<DataTable :value=”bullets” dataKey=”id” stripedRows responsiveLayout=”scroll”>
|
||||||
<Dialog
|
<Column field=”text” header=”Texto” />
|
||||||
v-model:visible="showBulletDlg"
|
<Column field=”sort_order” header=”Ordem” style=”width: 7rem” />
|
||||||
modal
|
<Column header=”Destaque” style=”width: 8rem”>
|
||||||
:header="bulletIsEdit ? 'Editar benefício' : 'Novo benefício'"
|
<template #body=”{ data }”>
|
||||||
:style="{ width: '560px' }"
|
<span>{{ data.highlight ? 'Sim' : 'Não' }}</span>
|
||||||
:closable="!bulletSaving"
|
</template>
|
||||||
:dismissableMask="!bulletSaving"
|
</Column>
|
||||||
:draggable="false"
|
<Column header=”Ações” style=”width: 9rem”>
|
||||||
>
|
<template #body=”{ data }”>
|
||||||
<div class="flex flex-col gap-4">
|
<div class=”flex gap-2”>
|
||||||
<FloatLabel variant="on">
|
<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>
|
<IconField>
|
||||||
<InputIcon class="pi pi-list" />
|
<InputIcon class=”pi pi-sort-numeric-up” />
|
||||||
<Textarea
|
<InputNumber
|
||||||
id="pp-bullet-text"
|
id=”pp-bullet-order”
|
||||||
v-model.trim="bulletForm.text"
|
v-model=”bulletForm.sort_order”
|
||||||
class="w-full"
|
class=”w-full”
|
||||||
rows="3"
|
inputClass=”w-full”
|
||||||
autoResize
|
:disabled=”bulletSaving”
|
||||||
:disabled="bulletSaving"
|
|
||||||
/>
|
/>
|
||||||
</IconField>
|
</IconField>
|
||||||
<label for="pp-bullet-text">Texto *</label>
|
<label for=”pp-bullet-order”>Ordem</label>
|
||||||
</FloatLabel>
|
</FloatLabel>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class=”flex items-center gap-2 pt-7”>
|
||||||
<FloatLabel variant="on">
|
<Checkbox v-model=”bulletForm.highlight” :binary=”true” :disabled=”bulletSaving” />
|
||||||
<IconField>
|
<label>Destaque</label>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<Button label="Cancelar" severity="secondary" outlined :disabled="bulletSaving" @click="showBulletDlg = false" />
|
<Button label=”Cancelar” severity=”secondary” outlined :disabled=”bulletSaving” @click=”showBulletDlg = false” />
|
||||||
<Button :label="bulletIsEdit ? 'Salvar' : 'Criar'" icon="pi pi-check" :loading="bulletSaving" @click="saveBullet" />
|
<Button :label=”bulletIsEdit ? 'Salvar' : 'Criar'” icon=”pi pi-check” :loading=”bulletSaving” @click=”saveBullet” />
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
|
||||||
</template>
|
</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>
|
|
||||||
@@ -316,41 +316,28 @@ onBeforeUnmount(() => {
|
|||||||
<template>
|
<template>
|
||||||
<Toast />
|
<Toast />
|
||||||
|
|
||||||
<!-- Info decorativa (scrolls away naturalmente) -->
|
<!-- Sentinel -->
|
||||||
<div class="flex items-start gap-4 px-4 pb-3">
|
<div ref="sentinelRef" class="h-px" />
|
||||||
<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 -->
|
<!-- Hero sticky -->
|
||||||
<div ref="sentinelRef" style="height: 1px; pointer-events: none;" />
|
|
||||||
|
|
||||||
<!-- hero -->
|
|
||||||
<div
|
<div
|
||||||
ref="heroRef"
|
ref="heroRef"
|
||||||
class="events-hero"
|
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"
|
||||||
:class="{ 'events-hero--stuck': heroStuck }"
|
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
|
||||||
>
|
>
|
||||||
<div class="events-hero__blobs" aria-hidden="true">
|
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
|
||||||
<div class="events-hero__blob events-hero__blob--1" />
|
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-amber-400/10" />
|
||||||
<div class="events-hero__blob events-hero__blob--2" />
|
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-orange-400/10" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="events-hero__inner">
|
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
|
||||||
<!-- Título -->
|
<div class="min-w-0">
|
||||||
<div class="events-hero__info min-w-0">
|
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Histórico de assinaturas</div>
|
||||||
<div class="events-hero__title">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>
|
</div>
|
||||||
|
|
||||||
<!-- Ações desktop (≥ 1200px) -->
|
<!-- 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
|
<Button
|
||||||
label="Voltar para assinaturas"
|
label="Voltar para assinaturas"
|
||||||
icon="pi pi-arrow-left"
|
icon="pi pi-arrow-left"
|
||||||
@@ -380,7 +367,7 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Ações mobile (< 1200px) -->
|
<!-- Ações mobile (< 1200px) -->
|
||||||
<div class="events-hero__actions--mobile">
|
<div class="flex xl:hidden">
|
||||||
<Button
|
<Button
|
||||||
label="Ações"
|
label="Ações"
|
||||||
icon="pi pi-ellipsis-v"
|
icon="pi pi-ellipsis-v"
|
||||||
@@ -394,20 +381,20 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- content -->
|
<!-- content -->
|
||||||
<div class="px-4 pb-4">
|
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
|
||||||
<!-- Card foco -->
|
<!-- Card foco -->
|
||||||
<div
|
<div
|
||||||
v-if="isFocused"
|
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="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">
|
<div class="mt-1 flex flex-wrap items-center gap-2">
|
||||||
<Tag value="Filtro ativo" severity="warning" rounded />
|
<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 }}
|
{{ route.query.q }}
|
||||||
</small>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -424,7 +411,7 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- busca -->
|
<!-- busca -->
|
||||||
<div class="mb-4">
|
<div>
|
||||||
<FloatLabel variant="on" class="w-full">
|
<FloatLabel variant="on" class="w-full">
|
||||||
<IconField class="w-full">
|
<IconField class="w-full">
|
||||||
<InputIcon class="pi pi-search" />
|
<InputIcon class="pi pi-search" />
|
||||||
@@ -446,7 +433,6 @@ onBeforeUnmount(() => {
|
|||||||
:loading="loading"
|
:loading="loading"
|
||||||
stripedRows
|
stripedRows
|
||||||
responsiveLayout="scroll"
|
responsiveLayout="scroll"
|
||||||
class="events-table"
|
|
||||||
:rowHover="true"
|
:rowHover="true"
|
||||||
paginator
|
paginator
|
||||||
:rows="15"
|
:rows="15"
|
||||||
@@ -475,9 +461,9 @@ onBeforeUnmount(() => {
|
|||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<span class="font-medium">{{ ownerKeyFromEvent(data) }}</span>
|
<span class="font-medium">{{ ownerKeyFromEvent(data) }}</span>
|
||||||
<small class="text-color-secondary">
|
<div class="text-[1rem] text-[var(--text-color-secondary)]">
|
||||||
{{ displayOwner(data) }}
|
{{ displayOwner(data) }}
|
||||||
</small>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
@@ -501,7 +487,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<Column field="subscription_id" header="Subscription" style="min-width: 22rem">
|
<Column field="subscription_id" header="Subscription" style="min-width: 22rem">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<span class="font-mono text-sm">{{ data.subscription_id }}</span>
|
<span class="font-mono text-[1rem]">{{ data.subscription_id }}</span>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
@@ -527,87 +513,14 @@ onBeforeUnmount(() => {
|
|||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<template #empty>
|
<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.
|
Nenhum evento encontrado com os filtros atuais.
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</DataTable>
|
</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.
|
Mostrando até 500 eventos mais recentes.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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 />
|
<Toast />
|
||||||
<ConfirmDialog />
|
<ConfirmDialog />
|
||||||
|
|
||||||
<!-- Info decorativa (scrolls away naturalmente) -->
|
<!-- Sentinel -->
|
||||||
<div class="flex items-start gap-4 px-4 pb-3">
|
<div ref="sentinelRef" class="h-px" />
|
||||||
<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 -->
|
<!-- Hero sticky -->
|
||||||
<div ref="sentinelRef" style="height: 1px; pointer-events: none;" />
|
|
||||||
|
|
||||||
<!-- hero -->
|
|
||||||
<div
|
<div
|
||||||
ref="heroRef"
|
ref="heroRef"
|
||||||
class="health-hero"
|
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"
|
||||||
:class="{ 'health-hero--stuck': heroStuck }"
|
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
|
||||||
>
|
>
|
||||||
<div class="health-hero__blobs" aria-hidden="true">
|
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
|
||||||
<div class="health-hero__blob health-hero__blob--1" />
|
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
|
||||||
<div class="health-hero__blob health-hero__blob--2" />
|
<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>
|
||||||
|
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
|
||||||
<div class="health-hero__inner">
|
<div class="min-w-0">
|
||||||
<!-- Título -->
|
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Saúde das Assinaturas</div>
|
||||||
<div class="health-hero__info min-w-0">
|
<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 class="health-hero__title">Saúde das Assinaturas</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Ações desktop (≥ 1200px) -->
|
<!-- 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
|
<Button
|
||||||
label="Recarregar"
|
label="Recarregar"
|
||||||
icon="pi pi-refresh"
|
icon="pi pi-refresh"
|
||||||
@@ -467,7 +456,7 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Ações mobile (< 1200px) -->
|
<!-- Ações mobile (< 1200px) -->
|
||||||
<div class="health-hero__actions--mobile">
|
<div class="flex xl:hidden shrink-0">
|
||||||
<Button
|
<Button
|
||||||
label="Ações"
|
label="Ações"
|
||||||
icon="pi pi-ellipsis-v"
|
icon="pi pi-ellipsis-v"
|
||||||
@@ -481,9 +470,9 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- content -->
|
<!-- content -->
|
||||||
<div class="px-4 pb-4">
|
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
|
||||||
<!-- busca -->
|
<!-- busca -->
|
||||||
<div class="mb-4">
|
<div>
|
||||||
<FloatLabel variant="on" class="w-full">
|
<FloatLabel variant="on" class="w-full">
|
||||||
<IconField class="w-full">
|
<IconField class="w-full">
|
||||||
<InputIcon class="pi pi-search" />
|
<InputIcon class="pi pi-search" />
|
||||||
@@ -505,7 +494,7 @@ onBeforeUnmount(() => {
|
|||||||
<!-- Terapeutas (Personal) -->
|
<!-- Terapeutas (Personal) -->
|
||||||
<!-- ===================================================== -->
|
<!-- ===================================================== -->
|
||||||
<TabPanel header="Terapeutas (Pessoal)">
|
<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 flex-wrap gap-2 items-center justify-content-between">
|
||||||
<div class="flex gap-2 items-center flex-wrap">
|
<div class="flex gap-2 items-center flex-wrap">
|
||||||
<Tag :value="`Divergências: ${totalPersonal}`" severity="secondary" />
|
<Tag :value="`Divergências: ${totalPersonal}`" severity="secondary" />
|
||||||
@@ -514,7 +503,7 @@ onBeforeUnmount(() => {
|
|||||||
<Tag v-if="totalPersonalWithoutOwner > 0" :value="`Sem owner: ${totalPersonalWithoutOwner}`" severity="warn" />
|
<Tag v-if="totalPersonalWithoutOwner > 0" :value="`Sem owner: ${totalPersonalWithoutOwner}`" severity="warn" />
|
||||||
</div>
|
</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">Faltando</span>: o plano exige, mas não está ativo ·
|
||||||
<span class="font-medium">Inesperado</span>: está ativo sem constar no plano
|
<span class="font-medium">Inesperado</span>: está ativo sem constar no plano
|
||||||
</div>
|
</div>
|
||||||
@@ -545,9 +534,9 @@ onBeforeUnmount(() => {
|
|||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<span class="font-medium">{{ data.feature_key }}</span>
|
<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) || '—' }}
|
{{ helpForMismatch(data.mismatch_type) || '—' }}
|
||||||
</small>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
@@ -588,12 +577,12 @@ onBeforeUnmount(() => {
|
|||||||
<Divider class="my-5" />
|
<Divider class="my-5" />
|
||||||
|
|
||||||
<Message severity="info" class="mt-4">
|
<Message severity="info" class="mt-4">
|
||||||
<div class="text-sm line-height-3">
|
<div class="text-[1rem] line-height-3">
|
||||||
<p class="mb-0">
|
<div class="mb-0">
|
||||||
<span class="font-semibold">Dica:</span>
|
<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.
|
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.
|
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>
|
</div>
|
||||||
</Message>
|
</Message>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
@@ -602,13 +591,13 @@ onBeforeUnmount(() => {
|
|||||||
<!-- Clínicas (Tenant) -->
|
<!-- Clínicas (Tenant) -->
|
||||||
<!-- ===================================================== -->
|
<!-- ===================================================== -->
|
||||||
<TabPanel header="Clínicas (Exceções)">
|
<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 flex-wrap gap-2 items-center justify-content-between">
|
||||||
<div class="flex gap-2 items-center flex-wrap">
|
<div class="flex gap-2 items-center flex-wrap">
|
||||||
<Tag :value="`Exceções ativas: ${totalClinic}`" severity="info" />
|
<Tag :value="`Exceções ativas: ${totalClinic}`" severity="info" />
|
||||||
</div>
|
</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.
|
Exceções comerciais: features liberadas manualmente fora do plano. Útil para testes, suporte e acordos.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -627,7 +616,7 @@ onBeforeUnmount(() => {
|
|||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<span class="font-medium">{{ data.tenant_name || data.tenant_id }}</span>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
@@ -642,9 +631,9 @@ onBeforeUnmount(() => {
|
|||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<span class="font-medium">{{ data.feature_key }}</span>
|
<span class="font-medium">{{ data.feature_key }}</span>
|
||||||
<small class="text-color-secondary">
|
<div class="text-[1rem] text-[var(--text-color-secondary)]">
|
||||||
{{ helpForException() }}
|
{{ helpForException() }}
|
||||||
</small>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
@@ -684,83 +673,22 @@ onBeforeUnmount(() => {
|
|||||||
<Divider class="my-5" />
|
<Divider class="my-5" />
|
||||||
|
|
||||||
<Message severity="info" class="mt-4">
|
<Message severity="info" class="mt-4">
|
||||||
<div class="text-sm line-height-3">
|
<div class="text-[1rem] line-height-3">
|
||||||
<p class="mb-2">
|
<div class="mb-2">
|
||||||
<span class="font-semibold">Observação:</span>
|
<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.
|
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.
|
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>
|
<span class="font-semibold">Dica:</span>
|
||||||
Exceções comerciais liberam recursos fora do plano.
|
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.
|
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.
|
A ação <span class="font-medium">Remover exceção</span> restaura o comportamento estritamente definido pelo plano.
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Message>
|
</Message>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
</TabView>
|
</TabView>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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 />
|
<Toast />
|
||||||
<ConfirmDialog />
|
<ConfirmDialog />
|
||||||
|
|
||||||
<!-- Info decorativa (scrolls away naturalmente) -->
|
<!-- Sentinel -->
|
||||||
<div class="flex items-start gap-4 px-4 pb-3">
|
<div ref="sentinelRef" class="h-px" />
|
||||||
<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 -->
|
<!-- Hero sticky -->
|
||||||
<div ref="sentinelRef" style="height: 1px; pointer-events: none;" />
|
|
||||||
|
|
||||||
<!-- hero -->
|
|
||||||
<div
|
<div
|
||||||
ref="heroRef"
|
ref="heroRef"
|
||||||
class="subs-hero"
|
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"
|
||||||
:class="{ 'subs-hero--stuck': heroStuck }"
|
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
|
||||||
>
|
>
|
||||||
<div class="subs-hero__blob subs-hero__blob--1" />
|
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
|
||||||
<div class="subs-hero__blob subs-hero__blob--2" />
|
<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="subs-hero__inner">
|
<div class="absolute rounded-full blur-[70px] w-72 h-72 -bottom-20 right-24 bg-fuchsia-400/10" />
|
||||||
<!-- Título -->
|
</div>
|
||||||
<div class="subs-hero__info min-w-0">
|
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
|
||||||
<div class="subs-hero__title">Assinaturas</div>
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Ações desktop (≥ 1200px) -->
|
<!-- 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
|
<SelectButton
|
||||||
v-model="typeFilter"
|
v-model="typeFilter"
|
||||||
:options="typeOptions"
|
:options="typeOptions"
|
||||||
@@ -471,7 +460,7 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Ações mobile (< 1200px) -->
|
<!-- Ações mobile (< 1200px) -->
|
||||||
<div class="subs-hero__actions--mobile">
|
<div class="flex xl:hidden shrink-0">
|
||||||
<Button
|
<Button
|
||||||
label="Ações"
|
label="Ações"
|
||||||
icon="pi pi-ellipsis-v"
|
icon="pi pi-ellipsis-v"
|
||||||
@@ -485,13 +474,13 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- content -->
|
<!-- content -->
|
||||||
<div class="px-4 pb-4">
|
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
|
||||||
<!-- Header foco -->
|
<!-- 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 class="flex align-items-center justify-content-between gap-3 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-lg font-semibold">Assinatura em foco</div>
|
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Assinatura em foco</div>
|
||||||
<small class="text-color-secondary">Filtro: {{ route.query.q }}</small>
|
<div class="text-[1rem] text-[var(--text-color-secondary)]">Filtro: {{ route.query.q }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -506,7 +495,7 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- busca -->
|
<!-- busca -->
|
||||||
<div class="mb-4">
|
<div>
|
||||||
<FloatLabel variant="on" class="w-full">
|
<FloatLabel variant="on" class="w-full">
|
||||||
<IconField class="w-full">
|
<IconField class="w-full">
|
||||||
<InputIcon class="pi pi-search" />
|
<InputIcon class="pi pi-search" />
|
||||||
@@ -528,7 +517,6 @@ onBeforeUnmount(() => {
|
|||||||
:loading="loading"
|
:loading="loading"
|
||||||
stripedRows
|
stripedRows
|
||||||
responsiveLayout="scroll"
|
responsiveLayout="scroll"
|
||||||
class="subs-table"
|
|
||||||
:rowHover="true"
|
:rowHover="true"
|
||||||
paginator
|
paginator
|
||||||
:rows="15"
|
:rows="15"
|
||||||
@@ -546,9 +534,9 @@ onBeforeUnmount(() => {
|
|||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<span class="font-medium">{{ ownerKey(data) }}</span>
|
<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 || '—'}` }}
|
{{ data.tenant_id ? `tenant_id: ${data.tenant_id}` : `user_id: ${data.user_id || '—'}` }}
|
||||||
</small>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
@@ -585,9 +573,9 @@ onBeforeUnmount(() => {
|
|||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<div>
|
<div>
|
||||||
<div>{{ fmtDate(data.current_period_start) }}</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) }}
|
até {{ fmtDate(data.current_period_end) }}
|
||||||
</small>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
@@ -642,74 +630,10 @@ onBeforeUnmount(() => {
|
|||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<template #empty>
|
<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.
|
Nenhuma assinatura encontrada com os filtros atuais.
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="saas-support p-4 md:p-6">
|
<Toast />
|
||||||
<Toast />
|
|
||||||
|
|
||||||
<!-- Cabeçalho -->
|
<!-- Sentinel -->
|
||||||
<div class="flex items-center gap-3 mb-5">
|
<div class="h-px" />
|
||||||
<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" />
|
<!-- Hero sticky -->
|
||||||
</div>
|
<div
|
||||||
<div class="flex-1">
|
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"
|
||||||
<h1 class="text-xl font-bold m-0">Suporte Técnico</h1>
|
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
|
||||||
<p class="text-sm text-surface-500 m-0">Gere e gerencie links seguros de acesso em modo debug</p>
|
>
|
||||||
|
<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>
|
</div>
|
||||||
<Tag
|
<Tag
|
||||||
v-if="activeSessionCount > 0"
|
v-if="activeSessionCount > 0"
|
||||||
@@ -258,6 +270,10 @@ function sessionStatusLabel (session) {
|
|||||||
severity="warning"
|
severity="warning"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- content -->
|
||||||
|
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
|
||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<TabView @tab-change="onTabChange">
|
<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">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5 pt-2">
|
||||||
|
|
||||||
<!-- Formulário -->
|
<!-- Formulário -->
|
||||||
<div class="card">
|
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
||||||
<h2 class="text-base font-semibold flex items-center gap-2 m-0 mb-4">
|
<div class="text-[1rem] font-semibold flex items-center gap-2 mb-4">
|
||||||
<i class="pi pi-plus-circle text-primary" />
|
<i class="pi pi-plus-circle text-[var(--primary-color)]" />
|
||||||
Configurar acesso de suporte
|
Configurar acesso de suporte
|
||||||
</h2>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<div class="flex flex-col gap-1">
|
<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
|
<Select
|
||||||
v-model="selectedTenantId"
|
v-model="selectedTenantId"
|
||||||
:options="tenants"
|
:options="tenants"
|
||||||
@@ -290,7 +306,7 @@ function sessionStatusLabel (session) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1">
|
<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
|
<Select
|
||||||
v-model="ttlMinutes"
|
v-model="ttlMinutes"
|
||||||
:options="ttlOptions"
|
:options="ttlOptions"
|
||||||
@@ -301,9 +317,9 @@ function sessionStatusLabel (session) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<label class="text-sm font-medium">
|
<label class="text-[1rem] font-medium">
|
||||||
Nota / Motivo
|
Nota / Motivo
|
||||||
<span class="text-surface-400 font-normal">(opcional)</span>
|
<span class="text-[var(--text-color-secondary)] font-normal">(opcional)</span>
|
||||||
</label>
|
</label>
|
||||||
<InputText
|
<InputText
|
||||||
v-model="sessionNote"
|
v-model="sessionNote"
|
||||||
@@ -325,45 +341,45 @@ function sessionStatusLabel (session) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- URL Gerada -->
|
<!-- URL Gerada -->
|
||||||
<div class="card">
|
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
||||||
<h2 class="text-base font-semibold flex items-center gap-2 m-0 mb-4">
|
<div class="text-[1rem] font-semibold flex items-center gap-2 mb-4">
|
||||||
<i class="pi pi-link text-primary" />
|
<i class="pi pi-link text-[var(--primary-color)]" />
|
||||||
URL Gerada
|
URL Gerada
|
||||||
</h2>
|
</div>
|
||||||
|
|
||||||
<div v-if="generatedUrl" class="flex flex-col gap-4">
|
<div v-if="generatedUrl" class="flex flex-col gap-4">
|
||||||
<div class="flex flex-col gap-1">
|
<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">
|
<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)" />
|
<Button icon="pi pi-copy" severity="secondary" outlined v-tooltip.top="'Copiar URL'" @click="copyUrl(generatedUrl)" />
|
||||||
</div>
|
</div>
|
||||||
</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" />
|
<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>
|
<strong>{{ expiresLabel }}</strong>
|
||||||
</div>
|
</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" />
|
<i class="pi pi-key" />
|
||||||
<span>{{ tokenPreview }}</span>
|
<span>{{ tokenPreview }}</span>
|
||||||
</div>
|
</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" />
|
<i class="pi pi-comment mt-0.5 flex-shrink-0" />
|
||||||
<span class="italic">{{ sessionNote }}</span>
|
<span class="italic">{{ sessionNote }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Message severity="info" :closable="false" class="text-sm">
|
<Message severity="info" :closable="false">
|
||||||
Envie este link ao terapeuta ou acesse diretamente para monitorar os logs da agenda em tempo real.
|
<div class="text-[1rem]">Envie este link ao terapeuta ou acesse diretamente para monitorar os logs da agenda em tempo real.</div>
|
||||||
</Message>
|
</Message>
|
||||||
</div>
|
</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" />
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -378,12 +394,12 @@ function sessionStatusLabel (session) {
|
|||||||
</span>
|
</span>
|
||||||
</template>
|
</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">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h2 class="text-base font-semibold flex items-center gap-2 m-0">
|
<div class="text-[1rem] font-semibold flex items-center gap-2">
|
||||||
<i class="pi pi-circle-fill text-green-500 text-xs" />
|
<i class="pi pi-circle-fill text-green-500" />
|
||||||
Sessões em vigor
|
Sessões em vigor
|
||||||
</h2>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
icon="pi pi-refresh"
|
icon="pi pi-refresh"
|
||||||
severity="secondary"
|
severity="secondary"
|
||||||
@@ -405,15 +421,15 @@ function sessionStatusLabel (session) {
|
|||||||
<Column header="Tenant" style="min-width: 200px">
|
<Column header="Tenant" style="min-width: 200px">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<div class="flex flex-col gap-0.5">
|
<div class="flex flex-col gap-0.5">
|
||||||
<span class="font-medium text-sm">{{ tenantName(data.tenant_id) }}</span>
|
<span class="font-medium text-[1rem]">{{ tenantName(data.tenant_id) }}</span>
|
||||||
<span class="font-mono text-xs text-surface-400">{{ data.tenant_id }}</span>
|
<span class="font-mono text-[1rem] text-[var(--text-color-secondary)]">{{ data.tenant_id }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<Column header="Token">
|
<Column header="Token">
|
||||||
<template #body="{ data }">
|
<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>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
@@ -427,14 +443,14 @@ function sessionStatusLabel (session) {
|
|||||||
|
|
||||||
<Column header="Criada em">
|
<Column header="Criada em">
|
||||||
<template #body="{ data }">
|
<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>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<Column header="Nota">
|
<Column header="Nota">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<span v-if="data._note" class="text-xs italic text-surface-500">{{ data._note }}</span>
|
<span v-if="data._note" class="text-[1rem] italic text-[var(--text-color-secondary)]">{{ data._note }}</span>
|
||||||
<span v-else class="text-xs text-surface-300">—</span>
|
<span v-else class="text-[1rem] text-[var(--text-color-secondary)]">—</span>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
@@ -452,12 +468,12 @@ function sessionStatusLabel (session) {
|
|||||||
|
|
||||||
<!-- ── Tab 2: Histórico ───────────────────────────────────── -->
|
<!-- ── Tab 2: Histórico ───────────────────────────────────── -->
|
||||||
<TabPanel header="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">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h2 class="text-base font-semibold flex items-center gap-2 m-0">
|
<div class="text-[1rem] font-semibold flex items-center gap-2">
|
||||||
<i class="pi pi-history text-primary" />
|
<i class="pi pi-history text-[var(--primary-color)]" />
|
||||||
Últimas 100 sessões
|
Últimas 100 sessões
|
||||||
</h2>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
icon="pi pi-refresh"
|
icon="pi pi-refresh"
|
||||||
severity="secondary"
|
severity="secondary"
|
||||||
@@ -480,41 +496,41 @@ function sessionStatusLabel (session) {
|
|||||||
>
|
>
|
||||||
<Column header="Status" style="width: 110px">
|
<Column header="Status" style="width: 110px">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<Tag :value="sessionStatusLabel(data)" :severity="sessionStatusSeverity(data)" class="text-xs" />
|
<Tag :value="sessionStatusLabel(data)" :severity="sessionStatusSeverity(data)" />
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<Column header="Tenant" style="min-width: 180px">
|
<Column header="Tenant" style="min-width: 180px">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<div class="flex flex-col gap-0.5">
|
<div class="flex flex-col gap-0.5">
|
||||||
<span class="font-medium text-sm">{{ tenantName(data.tenant_id) }}</span>
|
<span class="font-medium text-[1rem]">{{ tenantName(data.tenant_id) }}</span>
|
||||||
<span class="font-mono text-xs text-surface-400">{{ data.tenant_id }}</span>
|
<span class="font-mono text-[1rem] text-[var(--text-color-secondary)]">{{ data.tenant_id }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<Column header="Token">
|
<Column header="Token">
|
||||||
<template #body="{ data }">
|
<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>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<Column header="Criada em" sortable field="created_at">
|
<Column header="Criada em" sortable field="created_at">
|
||||||
<template #body="{ data }">
|
<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>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<Column header="Expirava em">
|
<Column header="Expirava em">
|
||||||
<template #body="{ data }">
|
<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>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<Column header="Nota">
|
<Column header="Nota">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<span v-if="data._note" class="text-xs italic text-surface-500">{{ data._note }}</span>
|
<span v-if="data._note" class="text-[1rem] italic text-[var(--text-color-secondary)]">{{ data._note }}</span>
|
||||||
<span v-else class="text-xs text-surface-300">—</span>
|
<span v-else class="text-[1rem] text-[var(--text-color-secondary)]">—</span>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
|
|||||||
@@ -581,35 +581,28 @@ onBeforeUnmount(() => {
|
|||||||
<Toast />
|
<Toast />
|
||||||
<ConfirmDialog />
|
<ConfirmDialog />
|
||||||
|
|
||||||
<!-- Info decorativa (scrolls away naturalmente) -->
|
<!-- Sentinel -->
|
||||||
<div class="flex items-start gap-4 px-4 pb-3">
|
<div ref="heroSentinelRef" class="h-px" />
|
||||||
<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 -->
|
<!-- Hero sticky -->
|
||||||
<div ref="heroSentinelRef" class="intents-hero-sentinel" />
|
<div
|
||||||
|
ref="heroEl"
|
||||||
<!-- hero -->
|
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"
|
||||||
<div ref="heroEl" class="intents-hero mb-4" :class="{ 'intents-hero--stuck': heroStuck }">
|
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
|
||||||
<div class="intents-hero__blobs" aria-hidden="true">
|
>
|
||||||
<div class="intents-hero__blob intents-hero__blob--1" />
|
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
|
||||||
<div class="intents-hero__blob intents-hero__blob--2" />
|
<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>
|
||||||
|
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
|
||||||
<div class="intents-hero__inner">
|
<div class="min-w-0">
|
||||||
<!-- Título -->
|
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Intenções de assinatura</div>
|
||||||
<div class="intents-hero__info min-w-0">
|
<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 class="intents-hero__title">Intenções de assinatura</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Ações desktop (≥ 1200px) -->
|
<!-- 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
|
<Button
|
||||||
label="Atualizar"
|
label="Atualizar"
|
||||||
icon="pi pi-refresh"
|
icon="pi pi-refresh"
|
||||||
@@ -633,7 +626,7 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Ações mobile (< 1200px) -->
|
<!-- Ações mobile (< 1200px) -->
|
||||||
<div class="intents-hero__actions--mobile">
|
<div class="flex xl:hidden shrink-0">
|
||||||
<Button
|
<Button
|
||||||
label="Ações"
|
label="Ações"
|
||||||
icon="pi pi-ellipsis-v"
|
icon="pi pi-ellipsis-v"
|
||||||
@@ -647,120 +640,116 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- content -->
|
<!-- 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: Resumo + Filtros -->
|
||||||
<Card class="mb-4">
|
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
||||||
<template #title>
|
<div class="flex items-center justify-between gap-3 flex-wrap mb-4">
|
||||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
<div class="flex items-center gap-2">
|
||||||
<div class="flex items-center gap-2">
|
<i class="pi pi-filter text-[var(--text-color-secondary)]" />
|
||||||
<i class="pi pi-filter text-color-secondary" />
|
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Busca & Filtros</div>
|
||||||
<span>Busca & Filtros</span>
|
</div>
|
||||||
</div>
|
<!-- contagens -->
|
||||||
<!-- contagens -->
|
<div class="flex flex-wrap gap-2 items-center">
|
||||||
<div class="flex flex-wrap gap-2 items-center">
|
<Tag :value="`Total: ${totals.total}`" severity="secondary" rounded />
|
||||||
<Tag :value="`Total: ${totals.total}`" severity="secondary" rounded />
|
<Tag :value="`Novas: ${totals.new}`" severity="info" rounded />
|
||||||
<Tag :value="`Novas: ${totals.new}`" severity="info" rounded />
|
<Tag :value="`Aguardando: ${totals.waiting}`" severity="warning" rounded />
|
||||||
<Tag :value="`Aguardando: ${totals.waiting}`" severity="warning" rounded />
|
<Tag :value="`Pagas: ${totals.paid}`" severity="success" rounded />
|
||||||
<Tag :value="`Pagas: ${totals.paid}`" severity="success" rounded />
|
<Tag :value="`Canceladas: ${totals.canceled}`" severity="danger" rounded />
|
||||||
<Tag :value="`Canceladas: ${totals.canceled}`" severity="danger" rounded />
|
<div class="text-[1rem] text-[var(--text-color-secondary)]">
|
||||||
<span class="text-xs text-color-secondary">
|
<template v-if="lastRefreshAt">· {{ fmtDate(lastRefreshAt) }}</template>
|
||||||
<template v-if="lastRefreshAt">· {{ fmtDate(lastRefreshAt) }}</template>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
|
|
||||||
<template #content>
|
<div class="grid grid-cols-12 gap-3">
|
||||||
<div class="grid grid-cols-12 gap-3">
|
<!-- Busca -->
|
||||||
<!-- Busca -->
|
<div class="col-span-12 md:col-span-5">
|
||||||
<div class="col-span-12 md:col-span-5">
|
<FloatLabel variant="on" class="w-full">
|
||||||
<FloatLabel variant="on" class="w-full">
|
<IconField class="w-full">
|
||||||
<IconField class="w-full">
|
<InputIcon class="pi pi-search" />
|
||||||
<InputIcon class="pi pi-search" />
|
<InputText
|
||||||
<InputText
|
v-model="q"
|
||||||
v-model="q"
|
id="si-search"
|
||||||
id="si-search"
|
class="w-full pr-10"
|
||||||
class="w-full pr-10"
|
variant="filled"
|
||||||
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
|
|
||||||
:disabled="acting"
|
:disabled="acting"
|
||||||
|
placeholder="ex.: email@dominio.com"
|
||||||
|
@keyup.enter="refresh"
|
||||||
/>
|
/>
|
||||||
<label>Status</label>
|
</IconField>
|
||||||
</FloatLabel>
|
<label for="si-search">Buscar por e-mail / plano / tenant_id</label>
|
||||||
</div>
|
</FloatLabel>
|
||||||
|
|
||||||
<!-- 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>
|
||||||
</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
|
<DataTable
|
||||||
:value="filteredRows"
|
:value="filteredRows"
|
||||||
@@ -768,7 +757,6 @@ onBeforeUnmount(() => {
|
|||||||
paginator
|
paginator
|
||||||
:rows="20"
|
:rows="20"
|
||||||
:rowsPerPageOptions="[10, 20, 50]"
|
:rowsPerPageOptions="[10, 20, 50]"
|
||||||
class="text-sm intents-table"
|
|
||||||
responsiveLayout="scroll"
|
responsiveLayout="scroll"
|
||||||
sortField="created_at"
|
sortField="created_at"
|
||||||
:sortOrder="-1"
|
:sortOrder="-1"
|
||||||
@@ -776,7 +764,7 @@ onBeforeUnmount(() => {
|
|||||||
>
|
>
|
||||||
<Column field="id" header="Intent ID" style="min-width: 18rem">
|
<Column field="id" header="Intent ID" style="min-width: 18rem">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<span class="text-xs">{{ data.id }}</span>
|
<div class="text-[1rem]">{{ data.id }}</div>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
@@ -786,9 +774,9 @@ onBeforeUnmount(() => {
|
|||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<span class="font-medium">{{ data.plan_key || '—' }}</span>
|
<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) }}
|
{{ intervalLabel(data.interval) }} • {{ moneyBRL(data.amount_cents) }}
|
||||||
</small>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
@@ -803,9 +791,9 @@ onBeforeUnmount(() => {
|
|||||||
:value="c.label"
|
:value="c.label"
|
||||||
rounded
|
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>
|
||||||
<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 }}
|
tenant_id: {{ data.tenant_id }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -862,13 +850,13 @@ onBeforeUnmount(() => {
|
|||||||
:dismissableMask="!acting"
|
:dismissableMask="!acting"
|
||||||
:draggable="false"
|
:draggable="false"
|
||||||
>
|
>
|
||||||
<div v-if="selected" class="text-sm">
|
<div v-if="selected" class="text-[1rem]">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="font-semibold">{{ selected.email }}</div>
|
<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) }}
|
Plano: {{ selected.plan_key }} • Intervalo: {{ intervalLabel(selected.interval) }} • Valor: {{ moneyBRL(selected.amount_cents) }}
|
||||||
</div>
|
</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 }}
|
tenant_id: {{ selected.tenant_id }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -901,21 +889,21 @@ onBeforeUnmount(() => {
|
|||||||
:dismissableMask="!acting"
|
:dismissableMask="!acting"
|
||||||
:draggable="false"
|
:draggable="false"
|
||||||
>
|
>
|
||||||
<div v-if="selectedSub" class="text-sm">
|
<div v-if="selectedSub" class="text-[1rem]">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<div class="font-semibold">
|
<div class="font-semibold">
|
||||||
{{ selectedSub.plan_key || '—' }} • {{ intervalLabel(selectedSub.interval) }}
|
{{ selectedSub.plan_key || '—' }} • {{ intervalLabel(selectedSub.interval) }}
|
||||||
</div>
|
</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) }}
|
Período: {{ fmtDate(selectedSub.current_period_start) }} → {{ fmtDate(selectedSub.current_period_end) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-color-secondary mt-1">
|
<div class="text-[var(--text-color-secondary)] mt-1">
|
||||||
owner(user_id): {{ selectedSub.user_id }}
|
owner(user_id): {{ selectedSub.user_id }}
|
||||||
<span v-if="selectedSub.tenant_id"> • tenant_id: {{ selectedSub.tenant_id }}</span>
|
<span v-if="selectedSub.tenant_id"> • tenant_id: {{ selectedSub.tenant_id }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-color-secondary mt-1">
|
<div class="text-[var(--text-color-secondary)] mt-1">
|
||||||
subscription_id: {{ selectedSub.id }}
|
subscription_id: {{ selectedSub.id }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -975,68 +963,3 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</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 { layoutConfig, isDarkTheme } = useLayout()
|
||||||
const tenantStore = useTenantStore()
|
const tenantStore = useTenantStore()
|
||||||
|
|
||||||
// ─── período ─────────────────────────────────────────────────────────────────
|
// ── Período ───────────────────────────────────────────────
|
||||||
|
|
||||||
const PERIODS = [
|
const PERIODS = [
|
||||||
{ label: 'Esta semana', value: 'week' },
|
{ label: 'Esta semana', value: 'week' },
|
||||||
{ label: 'Este mês', value: 'month' },
|
{ label: 'Este mês', value: 'month' },
|
||||||
{ label: 'Últimos 3 meses', value: '3months' },
|
{ label: 'Últimos 3 meses', value: '3months' },
|
||||||
{ label: 'Últimos 6 meses', value: '6months' },
|
{ label: 'Últimos 6 meses', value: '6months' },
|
||||||
]
|
]
|
||||||
@@ -21,44 +20,36 @@ const selectedPeriod = ref('month')
|
|||||||
function periodRange (period) {
|
function periodRange (period) {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
let start, end
|
let start, end
|
||||||
|
|
||||||
if (period === 'week') {
|
if (period === 'week') {
|
||||||
const dow = now.getDay() // 0=Dom
|
start = new Date(now); start.setDate(now.getDate() - now.getDay()); start.setHours(0, 0, 0, 0)
|
||||||
start = new Date(now)
|
end = new Date(now); end.setHours(23, 59, 59, 999)
|
||||||
start.setDate(now.getDate() - dow)
|
|
||||||
start.setHours(0, 0, 0, 0)
|
|
||||||
end = new Date(now)
|
|
||||||
end.setHours(23, 59, 59, 999)
|
|
||||||
} else if (period === 'month') {
|
} else if (period === 'month') {
|
||||||
start = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0)
|
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') {
|
} else if (period === '3months') {
|
||||||
start = new Date(now.getFullYear(), now.getMonth() - 2, 1, 0, 0, 0, 0)
|
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') {
|
} else if (period === '6months') {
|
||||||
start = new Date(now.getFullYear(), now.getMonth() - 5, 1, 0, 0, 0, 0)
|
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 }
|
return { start, end }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── dados ───────────────────────────────────────────────────────────────────
|
// ── Dados ─────────────────────────────────────────────────
|
||||||
|
const loading = ref(false)
|
||||||
const loading = ref(false)
|
const sessions = ref([])
|
||||||
const sessions = ref([])
|
|
||||||
const loadError = ref('')
|
const loadError = ref('')
|
||||||
|
|
||||||
async function loadSessions () {
|
async function loadSessions () {
|
||||||
const uid = tenantStore.user?.id || null
|
const uid = tenantStore.user?.id || null
|
||||||
const tenantId = tenantStore.activeTenantId || null
|
const tenantId = tenantStore.activeTenantId || null
|
||||||
if (!uid || !tenantId) return
|
if (!uid || !tenantId) return
|
||||||
|
|
||||||
const { start, end } = periodRange(selectedPeriod.value)
|
const { start, end } = periodRange(selectedPeriod.value)
|
||||||
|
loading.value = true
|
||||||
loading.value = true
|
|
||||||
loadError.value = ''
|
loadError.value = ''
|
||||||
sessions.value = []
|
sessions.value = []
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
@@ -70,7 +61,6 @@ async function loadSessions () {
|
|||||||
.lte('inicio_em', end.toISOString())
|
.lte('inicio_em', end.toISOString())
|
||||||
.order('inicio_em', { ascending: false })
|
.order('inicio_em', { ascending: false })
|
||||||
.limit(500)
|
.limit(500)
|
||||||
|
|
||||||
if (error) throw error
|
if (error) throw error
|
||||||
sessions.value = data || []
|
sessions.value = data || []
|
||||||
} catch (e) {
|
} 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)
|
// ── Filtro de status na tabela ────────────────────────────
|
||||||
const realizadas = computed(() => sessions.value.filter(s => s.status === 'realizado').length)
|
const filtroTabela = ref(null) // null = todos
|
||||||
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)
|
|
||||||
|
|
||||||
// ─── 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) {
|
function isoWeek (d) {
|
||||||
const dt = new Date(d)
|
const dt = new Date(d)
|
||||||
const day = dt.getDay() || 7
|
const day = dt.getDay() || 7
|
||||||
dt.setDate(dt.getDate() + 4 - day)
|
dt.setDate(dt.getDate() + 4 - day)
|
||||||
const yearStart = new Date(dt.getFullYear(), 0, 1)
|
const yearStart = new Date(dt.getFullYear(), 0, 1)
|
||||||
const wk = Math.ceil((((dt - yearStart) / 86400000) + 1) / 7)
|
const wk = Math.ceil((((dt - yearStart) / 86400000) + 1) / 7)
|
||||||
return `${dt.getFullYear()}-S${String(wk).padStart(2, '0')}`
|
return `${dt.getFullYear()}-S${String(wk).padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function isoMonth (d) {
|
function isoMonth (d) {
|
||||||
const dt = new Date(d)
|
const dt = new Date(d)
|
||||||
const yy = dt.getFullYear()
|
return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}`
|
||||||
const mm = String(dt.getMonth() + 1).padStart(2, '0')
|
|
||||||
return `${yy}-${mm}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function monthLabel (key) {
|
function monthLabel (key) {
|
||||||
const [y, m] = key.split('-')
|
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}`
|
return `${names[Number(m) - 1]}/${y}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const chartData = computed(() => {
|
const chartData = computed(() => {
|
||||||
const groupBy = selectedPeriod.value === 'week' ? isoWeek : isoMonth
|
const groupBy = selectedPeriod.value === 'week' ? isoWeek : isoMonth
|
||||||
const labelFn = selectedPeriod.value === 'week'
|
const labelFn = selectedPeriod.value === 'week' ? k => k : monthLabel
|
||||||
? k => k
|
|
||||||
: monthLabel
|
|
||||||
|
|
||||||
const buckets = {}
|
const buckets = {}
|
||||||
for (const s of sessions.value) {
|
for (const s of sessions.value) {
|
||||||
const key = groupBy(s.inicio_em)
|
const key = groupBy(s.inicio_em)
|
||||||
if (!buckets[key]) buckets[key] = { realizado: 0, faltou: 0, cancelado: 0, outros: 0 }
|
if (!buckets[key]) buckets[key] = { realizado: 0, faltou: 0, cancelado: 0, outros: 0 }
|
||||||
const st = s.status || 'agendado'
|
const st = s.status || 'agendado'
|
||||||
if (st === 'realizado') buckets[key].realizado++
|
if (st === 'realizado') buckets[key].realizado++
|
||||||
else if (st === 'faltou') buckets[key].faltou++
|
else if (st === 'faltou') buckets[key].faltou++
|
||||||
else if (st === 'cancelado') buckets[key].cancelado++
|
else if (st === 'cancelado') buckets[key].cancelado++
|
||||||
else buckets[key].outros++
|
else buckets[key].outros++
|
||||||
}
|
}
|
||||||
|
|
||||||
const keys = Object.keys(buckets).sort()
|
const keys = Object.keys(buckets).sort()
|
||||||
const labels = keys.map(labelFn)
|
|
||||||
const ds = getComputedStyle(document.documentElement)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
labels,
|
labels: keys.map(labelFn),
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{ label: 'Realizadas', backgroundColor: '#22c55e', data: keys.map(k => buckets[k].realizado), barThickness: 20 },
|
||||||
label: 'Realizadas',
|
{ label: 'Faltas', backgroundColor: '#ef4444', data: keys.map(k => buckets[k].faltou), barThickness: 20 },
|
||||||
backgroundColor: '#22c55e',
|
{ label: 'Canceladas', backgroundColor: '#f97316', data: keys.map(k => buckets[k].cancelado), barThickness: 20 },
|
||||||
data: keys.map(k => buckets[k].realizado),
|
{ label: 'Outros', backgroundColor: '#93c5fd', data: keys.map(k => buckets[k].outros), barThickness: 20 },
|
||||||
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,
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const chartOptions = computed(() => {
|
const chartOptions = computed(() => {
|
||||||
const ds = getComputedStyle(document.documentElement)
|
const ds = getComputedStyle(document.documentElement)
|
||||||
const borderColor = ds.getPropertyValue('--surface-border') || '#e2e8f0'
|
const borderColor = ds.getPropertyValue('--surface-border') || '#e2e8f0'
|
||||||
const textMutedColor = ds.getPropertyValue('--text-color-secondary') || '#64748b'
|
const textMutedColor = ds.getPropertyValue('--text-color-secondary') || '#64748b'
|
||||||
return {
|
return {
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
plugins: {
|
plugins: { legend: { labels: { color: textMutedColor } } },
|
||||||
legend: { labels: { color: textMutedColor } }
|
|
||||||
},
|
|
||||||
scales: {
|
scales: {
|
||||||
x: {
|
x: { stacked: true, ticks: { color: textMutedColor }, grid: { color: 'transparent' } },
|
||||||
stacked: true,
|
y: { stacked: true, ticks: { color: textMutedColor, precision: 0 }, grid: { color: borderColor, drawTicks: false } }
|
||||||
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 = {
|
const STATUS_LABEL = {
|
||||||
agendado: 'Agendado',
|
agendado: 'Agendado', realizado: 'Realizado', faltou: 'Faltou',
|
||||||
realizado: 'Realizado',
|
cancelado: 'Cancelado', remarcado: 'Remarcado', bloqueado: 'Bloqueado',
|
||||||
faltou: 'Faltou',
|
|
||||||
cancelado: 'Cancelado',
|
|
||||||
remarcado: 'Remarcado',
|
|
||||||
bloqueado: 'Bloqueado',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_SEVERITY = {
|
const STATUS_SEVERITY = {
|
||||||
agendado: 'info',
|
agendado: 'info', realizado: 'success', faltou: 'danger',
|
||||||
realizado: 'success',
|
cancelado: 'warn', remarcado: 'secondary', bloqueado: 'secondary',
|
||||||
faltou: 'danger',
|
|
||||||
cancelado: 'warn',
|
|
||||||
remarcado: 'secondary',
|
|
||||||
bloqueado: 'secondary',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtDateTimeBR (iso) {
|
function fmtDateTimeBR (iso) {
|
||||||
@@ -220,131 +185,252 @@ function fmtDateTimeBR (iso) {
|
|||||||
const mi = String(d.getMinutes()).padStart(2, '0')
|
const mi = String(d.getMinutes()).padStart(2, '0')
|
||||||
return `${dd}/${mm}/${yy} ${hh}:${mi}`
|
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) {
|
// ── Watch & mount ─────────────────────────────────────────
|
||||||
return s.titulo_custom || s.titulo || (s.tipo ? s.tipo : 'Sessão')
|
watch(selectedPeriod, () => { filtroTabela.value = null; loadSessions() })
|
||||||
}
|
|
||||||
|
|
||||||
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([() => layoutConfig.primary, () => layoutConfig.surface, isDarkTheme], () => {})
|
watch([() => layoutConfig.primary, () => layoutConfig.surface, isDarkTheme], () => {})
|
||||||
|
|
||||||
onMounted(loadSessions)
|
onMounted(loadSessions)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-6 p-4">
|
<!-- Sentinel -->
|
||||||
<!-- Cabeçalho -->
|
<div class="h-px" />
|
||||||
<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>
|
HERO sticky
|
||||||
<p class="text-sm text-slate-500 mt-1">Visão geral das suas sessões</p>
|
═══════════════════════════════════════════════════════ -->
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
<SelectButton
|
<!-- Seletor de período -->
|
||||||
v-model="selectedPeriod"
|
<div class="flex-1 min-w-0 hidden xl:flex items-center mx-2">
|
||||||
:options="PERIODS"
|
<SelectButton
|
||||||
option-label="label"
|
v-model="selectedPeriod"
|
||||||
option-value="value"
|
:options="PERIODS"
|
||||||
:allow-empty="false"
|
option-label="label"
|
||||||
class="shrink-0"
|
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>
|
</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 -->
|
<!-- Erro -->
|
||||||
<Message v-if="loadError" severity="error">{{ loadError }}</Message>
|
<Message v-if="loadError" severity="error">{{ loadError }}</Message>
|
||||||
|
|
||||||
<!-- Loading -->
|
<!-- Loading skeleton -->
|
||||||
<div v-if="loading" class="flex items-center gap-2 text-slate-500">
|
<div v-if="loading" class="flex flex-col gap-3">
|
||||||
<i class="pi pi-spin pi-spinner" /> Carregando…
|
<!-- 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>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- Cards de resumo -->
|
|
||||||
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-3">
|
<!-- ── QUICK-STATS clicáveis ────────────────────── -->
|
||||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 flex flex-col gap-1">
|
<div class="flex flex-wrap gap-2">
|
||||||
<span class="text-xs text-slate-500 uppercase tracking-wide">Total</span>
|
<div
|
||||||
<span class="text-3xl font-bold text-slate-800">{{ total }}</span>
|
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>
|
||||||
<div class="rounded-2xl border border-green-100 bg-green-50 p-4 flex flex-col gap-1">
|
</div>
|
||||||
<span class="text-xs text-green-700 uppercase tracking-wide">Realizadas</span>
|
|
||||||
<span class="text-3xl font-bold text-green-700">{{ realizadas }}</span>
|
<!-- 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>
|
||||||
<div class="rounded-2xl border border-red-100 bg-red-50 p-4 flex flex-col gap-1">
|
<div class="p-4">
|
||||||
<span class="text-xs text-red-600 uppercase tracking-wide">Faltas</span>
|
<Chart type="bar" :data="chartData" :options="chartOptions" class="h-64" />
|
||||||
<span class="text-3xl font-bold text-red-600">{{ faltas }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-2xl border border-orange-100 bg-orange-50 p-4 flex flex-col gap-1">
|
</div>
|
||||||
<span class="text-xs text-orange-600 uppercase tracking-wide">Canceladas</span>
|
|
||||||
<span class="text-3xl font-bold text-orange-600">{{ canceladas }}</span>
|
<!-- ── TABELA ───────────────────────────────────── -->
|
||||||
</div>
|
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
|
||||||
<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>
|
<!-- Cabeçalho da seção -->
|
||||||
<span class="text-3xl font-bold text-blue-600">{{ agendadas }}</span>
|
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
|
||||||
</div>
|
<div class="flex items-center gap-2">
|
||||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 flex flex-col gap-1">
|
<i class="pi pi-table text-[var(--text-color-secondary)] opacity-60" />
|
||||||
<span class="text-xs text-slate-500 uppercase tracking-wide">Taxa realização</span>
|
<span class="font-semibold text-[1rem] text-[var(--text-color)]">Sessões no período</span>
|
||||||
<span class="text-3xl font-bold text-slate-800">
|
<span
|
||||||
{{ taxaRealizacao != null ? `${taxaRealizacao}%` : '—' }}
|
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Gráfico -->
|
<!-- Empty state (sem dados no período) -->
|
||||||
<div v-if="total > 0" class="rounded-2xl border border-slate-200 bg-white p-4">
|
<div
|
||||||
<h2 class="text-base font-semibold text-slate-700 mb-4">
|
v-if="!sessions.length"
|
||||||
Sessões por {{ selectedPeriod === 'week' ? 'semana' : 'mês' }}
|
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)]"
|
||||||
</h2>
|
>
|
||||||
<Chart type="bar" :data="chartData" :options="chartOptions" class="h-64" />
|
<div class="relative">
|
||||||
</div>
|
<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" />
|
||||||
<!-- Tabela -->
|
</div>
|
||||||
<div class="rounded-2xl border border-slate-200 bg-white overflow-hidden">
|
<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">
|
||||||
<div class="px-4 py-3 border-b border-slate-100 flex items-center justify-between">
|
<i class="pi pi-times text-[0.58rem] text-[var(--text-color-secondary)] opacity-50" />
|
||||||
<h2 class="text-base font-semibold text-slate-700">Sessões no período</h2>
|
</div>
|
||||||
<span class="text-sm text-slate-500">{{ total }} registro{{ total !== 1 ? 's' : '' }}</span>
|
</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>
|
||||||
|
|
||||||
<div v-if="!sessions.length" class="px-4 py-8 text-center text-slate-500 text-sm">
|
<!-- Empty state (filtro sem resultado) -->
|
||||||
Nenhuma sessão encontrada para o período selecionado.
|
<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>
|
</div>
|
||||||
|
|
||||||
|
<!-- DataTable -->
|
||||||
<DataTable
|
<DataTable
|
||||||
v-else
|
v-else
|
||||||
:value="sessions"
|
:value="sessionsFiltradas"
|
||||||
:rows="20"
|
:rows="20"
|
||||||
paginator
|
paginator
|
||||||
:rows-per-page-options="[10, 20, 50]"
|
:rows-per-page-options="[10, 20, 50]"
|
||||||
scrollable
|
scrollable
|
||||||
scroll-height="480px"
|
scroll-height="480px"
|
||||||
class="text-sm"
|
class="rel-datatable"
|
||||||
>
|
>
|
||||||
<Column field="inicio_em" header="Data / Hora" :sortable="true" style="min-width: 140px">
|
<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>
|
||||||
|
|
||||||
<Column header="Paciente" style="min-width: 160px">
|
<Column header="Paciente" style="min-width: 160px">
|
||||||
<template #body="{ data }">{{ patientName(data) }}</template>
|
<template #body="{ data }">{{ patientName(data) }}</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<Column header="Sessão" style="min-width: 160px">
|
<Column header="Sessão" style="min-width: 160px">
|
||||||
<template #body="{ data }">{{ sessionTitle(data) }}</template>
|
<template #body="{ data }">{{ sessionTitle(data) }}</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<Column field="modalidade" header="Modalidade" style="min-width: 110px">
|
<Column field="modalidade" header="Modalidade" style="min-width: 110px">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
{{ data.modalidade === 'online' ? 'Online' : data.modalidade === 'presencial' ? 'Presencial' : data.modalidade || '—' }}
|
{{ data.modalidade === 'online' ? 'Online' : data.modalidade === 'presencial' ? 'Presencial' : data.modalidade || '—' }}
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<Column field="status" header="Status" style="min-width: 110px">
|
<Column field="status" header="Status" style="min-width: 110px">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<Tag
|
<Tag
|
||||||
@@ -355,6 +441,13 @@ onMounted(loadSessions)
|
|||||||
</Column>
|
</Column>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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