Layout 100%, Notificações, SetupWizard

This commit is contained in:
Leonardo
2026-03-17 21:08:14 -03:00
parent 84d65e49c0
commit 66f67cd40f
77 changed files with 35823 additions and 15023 deletions
+6 -2
View File
@@ -10,6 +10,7 @@ import AppRail from './AppRail.vue'
import AppRailPanel from './AppRailPanel.vue'
import AppRailSidebar from './AppRailSidebar.vue'
import AjudaDrawer from '@/components/AjudaDrawer.vue'
import SupportDebugBanner from '@/support/components/SupportDebugBanner.vue'
import { fetchDocsForPath, useAjuda } from '@/composables/useAjuda'
@@ -25,7 +26,7 @@ import { useEntitlementsStore } from '@/stores/entitlementsStore'
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore'
const route = useRoute()
const { layoutConfig, layoutState, hideMobileMenu, isDesktop } = useLayout()
const { layoutConfig, layoutState, hideMobileMenu, isDesktop, effectiveVariant } = useLayout()
const layoutArea = computed(() => route.meta?.area || null)
provide('layoutArea', layoutArea)
@@ -100,7 +101,7 @@ onBeforeUnmount(() => {
</template>
<!-- Layout Rail -->
<template v-else-if="layoutConfig.variant === 'rail'">
<template v-else-if="effectiveVariant === 'rail'">
<div class="l2-root">
<!-- Rail de ícones: oculto em mobile ( 1200px) via CSS -->
<AppRail />
@@ -142,6 +143,9 @@ onBeforeUnmount(() => {
<AjudaDrawer />
<Toast />
</template>
<!-- Global fora de todos os branches, persiste em qualquer layout/rota -->
<SupportDebugBanner />
</template>
<style>
+58 -196
View File
@@ -20,6 +20,7 @@ const railSections = computed(() => {
const model = menuStore.model || []
return model
.filter(s => s.label && Array.isArray(s.items) && s.items.length)
.filter(s => s.label.toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '').trim() !== 'inicio')
.map(s => ({
key: s.label,
label: s.label,
@@ -38,7 +39,21 @@ const initials = computed(() => {
return (a + b).toUpperCase()
})
const userName = computed(() => sessionUser.value?.user_metadata?.full_name || sessionUser.value?.email || 'Conta')
const userName = computed(() => sessionUser.value?.user_metadata?.full_name || sessionUser.value?.email || 'Conta')
// ── Início (fixo) ────────────────────────────────────────────
function selectHome () {
if (layoutState.railSectionKey === '__home__' && layoutState.railPanelOpen) {
layoutState.railPanelOpen = false
} else {
layoutState.railSectionKey = '__home__'
layoutState.railPanelOpen = true
}
}
const isHomeActive = computed(() =>
layoutState.railSectionKey === '__home__' && layoutState.railPanelOpen
)
// ── Seleção de seção ─────────────────────────────────────────
function selectSection (section) {
@@ -52,7 +67,6 @@ function selectSection (section) {
function isActiveSectionOrChild (section) {
if (layoutState.railSectionKey === section.key && layoutState.railPanelOpen) return true
// verifica se algum filho está ativo
const active = String(layoutState.activePath || '')
return section.items.some(i => {
const p = typeof i.to === 'string' ? i.to : ''
@@ -77,21 +91,33 @@ async function signOut () {
</script>
<template>
<aside class="rail">
<aside class="rail w-[60px] shrink-0 h-screen flex flex-col items-center border-r border-[var(--surface-border)] bg-[var(--surface-card)] z-50 select-none">
<!-- Brand -->
<div class="rail__brand">
<span class="rail__psi">Ψ</span>
<div class="w-full h-14 shrink-0 grid place-items-center border-b border-[var(--surface-border)]">
<span class="text-[1.35rem] font-extrabold leading-none text-[var(--primary-color)] [text-shadow:0_0_20px_color-mix(in_srgb,var(--primary-color)_40%,transparent)]">Ψ</span>
</div>
<!-- Nav icons -->
<nav class="rail__nav" role="navigation" aria-label="Menu principal">
<nav class="flex-1 w-full flex flex-col items-center gap-1 py-2.5 overflow-y-auto overflow-x-hidden [scrollbar-width:none] [&::-webkit-scrollbar]:hidden" role="navigation" aria-label="Menu principal">
<!-- Início fixo -->
<button
class="rail-btn relative w-10 h-10 rounded-[10px] grid place-items-center border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer text-base shrink-0 transition-[background,color,transform] duration-150 hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)] hover:scale-105"
:class="isHomeActive ? 'rail-btn--active bg-[color-mix(in_srgb,var(--primary-color)_12%,transparent)] !text-[var(--primary-color)]' : ''"
v-tooltip.right="{ value: 'Início', showDelay: 0 }"
aria-label="Início"
@click="selectHome"
>
<i class="pi pi-fw pi-home" />
</button>
<button
v-for="section in railSections"
:key="section.key"
class="rail__btn"
:class="{ 'rail__btn--active': isActiveSectionOrChild(section) }"
v-tooltip.right="{ value: section.label, showDelay: 400 }"
class="rail-btn relative w-10 h-10 rounded-[10px] grid place-items-center border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer text-base shrink-0 transition-[background,color,transform] duration-150 hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)] hover:scale-105"
:class="isActiveSectionOrChild(section) ? 'rail-btn--active bg-[color-mix(in_srgb,var(--primary-color)_12%,transparent)] !text-[var(--primary-color)]' : ''"
v-tooltip.right="{ value: section.label, showDelay: 0 }"
:aria-label="section.label"
@click="selectSection(section)"
>
@@ -100,49 +126,48 @@ async function signOut () {
</nav>
<!-- Rodapé -->
<div class="rail__foot">
<!-- Configurações de layout -->
<div class="w-full flex flex-col items-center gap-1.5 py-2 pb-3 border-t border-[var(--surface-border)]">
<button
class="rail__btn rail__btn--sm"
v-tooltip.right="{ value: 'Meu Perfil', showDelay: 400 }"
aria-label="Meu Perfil"
@click="goTo('/account/profile')"
class="w-9 h-9 rounded-[10px] grid place-items-center border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer text-[0.875rem] shrink-0 transition-[background,color,transform] duration-150 hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)] hover:scale-105"
v-tooltip.right="{ value: 'Configurações', showDelay: 0 }"
aria-label="Configurações"
@click="goTo('/configuracoes')"
>
<i class="pi pi-fw pi-cog" />
</button>
<!-- Avatar / user -->
<button
class="rail__av-btn"
v-tooltip.right="{ value: userName, showDelay: 400 }"
class="w-9 h-9 rounded-[10px] border-none cursor-pointer overflow-hidden shrink-0 bg-[var(--surface-ground)] grid place-items-center transition-[transform,box-shadow] duration-150 hover:scale-105 hover:shadow-[0_0_0_2px_var(--primary-color)]"
v-tooltip.right="{ value: userName, showDelay: 0 }"
:aria-label="userName"
@click="toggleUserPop"
>
<img v-if="avatarUrl" :src="avatarUrl" class="rail__av-img" :alt="userName" />
<span v-else class="rail__av-init">{{ initials }}</span>
<img v-if="avatarUrl" :src="avatarUrl" class="w-full h-full object-cover" :alt="userName" />
<span v-else class="text-[1rem] font-bold text-[var(--text-color)]">{{ initials }}</span>
</button>
</div>
<!-- Popover usuário -->
<Popover ref="userPop" appendTo="body">
<div class="rail-pop">
<div class="rail-pop__user">
<div class="rail-pop__av">
<img v-if="avatarUrl" :src="avatarUrl" class="rail-pop__av-img" />
<span v-else class="rail-pop__av-init">{{ initials }}</span>
<div class="min-w-[210px] p-1 flex flex-col gap-0.5">
<div class="flex items-center gap-2.5 px-2.5 py-2 pb-2.5">
<div class="w-9 h-9 rounded-[9px] overflow-hidden shrink-0 bg-[var(--surface-ground)] grid place-items-center border border-[var(--surface-border)]">
<img v-if="avatarUrl" :src="avatarUrl" class="w-full h-full object-cover" />
<span v-else class="text-[1rem] font-bold text-[var(--text-color)]">{{ initials }}</span>
</div>
<div class="min-w-0">
<div class="rail-pop__name">{{ userName }}</div>
<div class="rail-pop__email">{{ sessionUser?.email }}</div>
<div class="text-[0.83rem] font-semibold text-[var(--text-color)] truncate">{{ userName }}</div>
<div class="text-[0.68rem] text-[var(--text-color-secondary)] truncate">{{ sessionUser?.email }}</div>
</div>
</div>
<div class="rail-pop__divider" />
<div class="h-px bg-[var(--surface-border)] my-0.5" />
<Button label="Meu Perfil" icon="pi pi-user" text class="w-full justify-start" @click="goTo('/account/profile')" />
<Button label="Segurança" icon="pi pi-shield" text class="w-full justify-start" @click="goTo('/account/security')" />
<Button label="Meu Perfil" icon="pi pi-user" text class="w-full justify-start" @click="goTo('/account/profile')" />
<Button label="Segurança" icon="pi pi-shield" text class="w-full justify-start" @click="goTo('/account/security')" />
<div class="rail-pop__divider" />
<div class="h-px bg-[var(--surface-border)] my-0.5" />
<Button label="Sair" icon="pi pi-sign-out" severity="danger" text class="w-full justify-start" @click="signOut" />
</div>
@@ -151,78 +176,8 @@ async function signOut () {
</template>
<style scoped>
/* ─── Rail container ─────────────────────────────────────── */
.rail {
width: 60px;
flex-shrink: 0;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
border-right: 1px solid var(--surface-border);
background: var(--surface-card);
z-index: 50;
user-select: none;
}
/* ─── Brand ──────────────────────────────────────────────── */
.rail__brand {
width: 100%;
height: 56px;
display: grid;
place-items: center;
border-bottom: 1px solid var(--surface-border);
flex-shrink: 0;
}
.rail__psi {
font-size: 1.35rem;
font-weight: 800;
color: var(--primary-color);
text-shadow: 0 0 20px color-mix(in srgb, var(--primary-color) 40%, transparent);
line-height: 1;
}
/* ─── Nav ────────────────────────────────────────────────── */
.rail__nav {
flex: 1;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 10px 0;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: none;
}
.rail__nav::-webkit-scrollbar { display: none; }
/* ─── Buttons ────────────────────────────────────────────── */
.rail__btn {
width: 40px;
height: 40px;
border-radius: 10px;
display: grid;
place-items: center;
border: none;
background: transparent;
color: var(--text-color-secondary);
cursor: pointer;
font-size: 1rem;
transition: background 0.15s, color 0.15s, transform 0.12s;
position: relative;
flex-shrink: 0;
}
.rail__btn:hover {
background: var(--surface-ground);
color: var(--text-color);
transform: scale(1.08);
}
.rail__btn--active {
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
color: var(--primary-color);
}
.rail__btn--active::before {
/* Indicador lateral do botão ativo — pseudo-elemento não expressável em Tailwind */
.rail-btn--active::before {
content: '';
position: absolute;
left: -10px;
@@ -233,97 +188,4 @@ async function signOut () {
border-radius: 0 3px 3px 0;
background: var(--primary-color);
}
.rail__btn--sm {
width: 36px;
height: 36px;
font-size: 0.875rem;
}
/* ─── Footer ─────────────────────────────────────────────── */
.rail__foot {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 8px 0 12px;
border-top: 1px solid var(--surface-border);
}
/* ─── Avatar button ──────────────────────────────────────── */
.rail__av-btn {
width: 36px;
height: 36px;
border-radius: 10px;
border: none;
cursor: pointer;
overflow: hidden;
transition: transform 0.12s, box-shadow 0.15s;
background: var(--surface-ground);
display: grid;
place-items: center;
flex-shrink: 0;
}
.rail__av-btn:hover {
transform: scale(1.08);
box-shadow: 0 0 0 2px var(--primary-color);
}
.rail__av-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.rail__av-init {
font-size: 0.78rem;
font-weight: 700;
color: var(--text-color);
}
/* ─── Popover ────────────────────────────────────────────── */
.rail-pop {
min-width: 210px;
padding: 4px;
display: flex;
flex-direction: column;
gap: 2px;
}
.rail-pop__user {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px 10px;
}
.rail-pop__av {
width: 36px;
height: 36px;
border-radius: 9px;
overflow: hidden;
flex-shrink: 0;
background: var(--surface-ground);
display: grid;
place-items: center;
border: 1px solid var(--surface-border);
}
.rail-pop__av-img { width: 100%; height: 100%; object-fit: cover; }
.rail-pop__av-init { font-size: 0.78rem; font-weight: 700; color: var(--text-color); }
.rail-pop__name {
font-size: 0.83rem;
font-weight: 600;
color: var(--text-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rail-pop__email {
font-size: 0.68rem;
color: var(--text-color-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rail-pop__divider {
height: 1px;
background: var(--surface-border);
margin: 2px 0;
}
</style>
</style>
+324 -168
View File
@@ -1,10 +1,10 @@
<!-- src/layout/AppRailPanel.vue Painel expansível do Layout 2 -->
<script setup>
import { computed } from 'vue'
import { computed, ref, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useMenuStore } from '@/stores/menuStore'
import { useLayout } from './composables/layout'
import { useMenuStore } from '@/stores/menuStore'
import { useLayout } from './composables/layout'
import { useEntitlementsStore } from '@/stores/entitlementsStore'
const menuStore = useMenuStore()
@@ -19,9 +19,25 @@ const currentSection = computed(() => {
return model.find(s => s.label === layoutState.railSectionKey) || null
})
// ── Items da seção (com suporte a children) ──────────────────
const sectionItems = computed(() => currentSection.value?.items || [])
// Todos os grupos do menu
const allSections = computed(() => {
const model = menuStore.model || []
return model.filter(s => s.label && Array.isArray(s.items) && s.items.length)
})
// "Início" = chave especial __home__
const isHome = computed(() => layoutState.railSectionKey === '__home__')
// Seções visíveis: tudo em Início, só a selecionada nos demais
const visibleSections = computed(() =>
isHome.value ? allSections.value : (currentSection.value ? [currentSection.value] : [])
)
const panelTitle = computed(() =>
isHome.value ? 'Início' : currentSection.value?.label || 'Menu'
)
// ── Helpers ──────────────────────────────────────────────────
function isLocked (item) {
if (!item.proBadge || !item.feature) return false
try { return !entitlements.has(item.feature) } catch { return false }
@@ -57,59 +73,324 @@ function navigate (item) {
function closePanel () {
layoutState.railPanelOpen = false
}
// ── Busca (todo o menu) ──────────────────────────────────────
const query = ref('')
const showResults = ref(false)
const activeIndex = ref(-1)
const forcedOpen = ref(false)
const searchEl = ref(null)
const searchWrapEl = ref(null)
const RECENT_KEY = 'menu_search_recent'
const recent = ref([])
function loadRecent () {
try { recent.value = JSON.parse(localStorage.getItem(RECENT_KEY) || '[]') } catch { recent.value = [] }
}
function saveRecent (q) {
const v = String(q || '').trim()
if (!v) return
const list = [v, ...recent.value.filter(x => x !== v)].slice(0, 8)
recent.value = list
localStorage.setItem(RECENT_KEY, JSON.stringify(list))
}
function clearRecent () {
recent.value = []
try { localStorage.removeItem(RECENT_KEY) } catch {}
}
loadRecent()
watch(query, (v) => {
const hasText = !!v?.trim()
if (hasText) { forcedOpen.value = false; showResults.value = true; return }
showResults.value = forcedOpen.value
})
function clearSearch () {
query.value = ''
activeIndex.value = -1
showResults.value = false
forcedOpen.value = false
}
function norm (s) {
return String(s || '').toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '').trim()
}
function isVisibleItem (it) {
const v = it?.visible
if (typeof v === 'function') return !!v()
if (v === undefined || v === null) return true
return v !== false
}
function flattenMenu (items, trail = []) {
const out = []
for (const it of (items || [])) {
if (!isVisibleItem(it)) continue
const nextTrail = [...trail, it?.label].filter(Boolean)
if (it?.to && !it?.items?.length) {
out.push({ label: it.label || it.to, to: it.to, icon: it.icon, trail: nextTrail, proBadge: !!(it.__showProBadge ?? it.proBadge), feature: it.feature || null })
}
if (it?.items?.length) out.push(...flattenMenu(it.items, nextTrail))
}
return out
}
const allLinks = computed(() => flattenMenu(menuStore.model || []))
const results = computed(() => {
const q = norm(query.value)
if (!q) return []
const wantPro = q === 'pro' || q.startsWith('pro ') || q.includes(' pro')
return allLinks.value
.filter(r => {
const hay = `${norm(r.label)} ${norm(r.trail.join(' > '))} ${norm(r.to)}`
if (hay.includes(q)) return true
if (wantPro && (r.proBadge || r.feature)) return true
return false
})
.slice(0, 12)
})
watch(results, (list) => { activeIndex.value = list.length ? 0 : -1 })
function escapeHtml (s) {
return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;')
}
function highlight (text, q) {
const queryNorm = norm(q)
const raw = String(text || '')
if (!queryNorm) return escapeHtml(raw)
const rawNorm = norm(raw)
const idx = rawNorm.indexOf(queryNorm)
if (idx < 0) return escapeHtml(raw)
const before = escapeHtml(raw.slice(0, idx))
const mid = escapeHtml(raw.slice(idx, idx + queryNorm.length))
const after = escapeHtml(raw.slice(idx + queryNorm.length))
return `${before}<mark class="px-1 rounded bg-yellow-200/40">${mid}</mark>${after}`
}
function onSearchKeydown (e) {
if (e.key === 'Escape') { showResults.value = false; forcedOpen.value = false; return }
if (e.key === 'ArrowDown') {
e.preventDefault()
if (!results.value.length) return
showResults.value = true
activeIndex.value = (activeIndex.value + 1) % results.value.length
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
if (!results.value.length) return
showResults.value = true
activeIndex.value = (activeIndex.value - 1 + results.value.length) % results.value.length
return
}
if (e.key === 'Enter' && showResults.value && results.value.length && activeIndex.value >= 0) {
e.preventDefault()
goToResult(results.value[activeIndex.value])
}
}
function onSearchFocus () {
if (!query.value?.trim()) { forcedOpen.value = true; showResults.value = true }
}
function applyRecent (q) {
query.value = q
forcedOpen.value = true
showResults.value = true
activeIndex.value = 0
nextTick(() => searchEl.value?.$el?.querySelector?.('input')?.focus?.())
}
function onDocMouseDown (e) {
if (!showResults.value) return
if (!searchWrapEl.value?.contains(e.target)) { showResults.value = false; forcedOpen.value = false }
}
onMounted(() => document.addEventListener('mousedown', onDocMouseDown))
onBeforeUnmount(() => document.removeEventListener('mousedown', onDocMouseDown))
async function goToResult (r) {
saveRecent(query.value)
query.value = ''
showResults.value = false
activeIndex.value = -1
forcedOpen.value = false
layoutState.activePath = typeof r.to === 'string' ? r.to : router.resolve(r.to).path
await router.push(r.to)
}
</script>
<template>
<Transition name="panel-slide">
<aside
v-if="layoutState.railPanelOpen && currentSection"
class="rp"
v-if="layoutState.railPanelOpen"
class="w-[260px] shrink-0 h-screen flex flex-col border-r border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden"
aria-label="Menu lateral"
>
<!-- Header -->
<div class="rp__head">
<span class="rp__title">{{ currentSection.label }}</span>
<button class="rp__close" aria-label="Fechar painel" @click="closePanel">
<div class="h-14 shrink-0 flex items-center justify-between px-4 border-b border-[var(--surface-border)]">
<span class="text-[0.9rem] font-bold tracking-tight text-[var(--text-color)]">
{{ panelTitle }}
</span>
<button
class="w-7 h-7 rounded-lg border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer grid place-items-center text-xs transition-colors hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)]"
aria-label="Fechar painel"
@click="closePanel"
>
<i class="pi pi-times" />
</button>
</div>
<!-- Items -->
<nav class="rp__nav">
<template v-for="item in sectionItems" :key="item.to || item.label">
<!-- Item com filhos (sub-seção) -->
<div v-if="item.items?.length" class="rp__group">
<div class="rp__group-label">{{ item.label }}</div>
<button
v-for="child in item.items"
:key="child.to || child.label"
class="rp__item"
:class="{
'rp__item--active': isActive(child),
'rp__item--locked': isLocked(child)
}"
@click="navigate(child)"
>
<i v-if="child.icon" :class="child.icon" class="rp__item-icon" />
<span class="rp__item-label">{{ child.label }}</span>
<span v-if="isLocked(child)" class="rp__pro">PRO</span>
</button>
<!-- Busca no Início -->
<div v-if="isHome" ref="searchWrapEl" class="shrink-0 px-2.5 pt-2.5 border-b border-[var(--surface-border)]">
<!-- Campo -->
<div class="relative">
<div aria-hidden="true" style="position:absolute;left:-9999px;top:-9999px;width:1px;height:1px;overflow:hidden;">
<input type="email" name="email" autocomplete="username" tabindex="-1" disabled />
<input type="password" name="password" autocomplete="current-password" tabindex="-1" disabled />
</div>
<!-- Item folha -->
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText
ref="searchEl"
id="rp_menu_search"
name="rp_menu_search"
type="text"
inputmode="search"
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
data-lpignore="true"
data-1p-ignore="true"
v-model="query"
class="w-full pr-8"
variant="filled"
@focus="onSearchFocus"
@keydown="onSearchKeydown"
/>
</IconField>
<label for="rp_menu_search">Encontrar menu...</label>
</FloatLabel>
<button
v-else
class="rp__item"
:class="{
'rp__item--active': isActive(item),
'rp__item--locked': isLocked(item)
}"
@click="navigate(item)"
v-if="query.trim()"
type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 bg-transparent border-none cursor-pointer opacity-60 hover:opacity-100 text-[var(--text-color-secondary)] text-xs grid place-items-center p-0.5"
@mousedown.prevent="clearSearch"
aria-label="Limpar busca"
>
<i v-if="item.icon" :class="item.icon" class="rp__item-icon" />
<span class="rp__item-label">{{ item.label }}</span>
<span v-if="isLocked(item)" class="rp__pro">PRO</span>
<i class="pi pi-times" />
</button>
</div>
<!-- Recentes -->
<div
v-if="showResults && !query.trim() && recent.length"
class="mt-1.5 mb-2.5 border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] shadow-sm overflow-hidden"
>
<div class="flex items-center justify-between px-2.5 py-1.5 text-[1rem] opacity-65 text-[var(--text-color-secondary)]">
<span>Recentes</span>
<button type="button" class="bg-transparent border-none cursor-pointer opacity-70 hover:opacity-100 text-inherit text-[1rem]" @mousedown.prevent="clearRecent" aria-label="Limpar recentes">
<i class="pi pi-trash" />
</button>
</div>
<button
v-for="q in recent" :key="q"
class="w-full flex items-center gap-2 px-2.5 py-1.5 bg-transparent border-none cursor-pointer text-[var(--text-color)] text-[1rem] text-left transition-colors hover:bg-[var(--surface-hover)]"
type="button"
@click.stop.prevent="applyRecent(q)"
>
<i class="pi pi-history text-[0.8rem] opacity-70 shrink-0" />
<span class="flex-1">{{ q }}</span>
</button>
</div>
<!-- Resultados de busca -->
<div
v-else-if="showResults && results.length"
class="mt-1.5 mb-2.5 border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] shadow-sm overflow-hidden"
>
<button
v-for="(r, i) in results" :key="String(r.to)"
type="button"
@mousedown.prevent="goToResult(r)"
class="w-full flex items-center gap-2 px-2.5 py-1.5 bg-transparent border-none cursor-pointer text-[var(--text-color)] text-[1rem] text-left transition-colors"
:class="i === activeIndex ? 'bg-[var(--surface-hover)]' : 'hover:bg-[var(--surface-hover)]'"
>
<i v-if="r.icon" :class="r.icon" class="text-[0.8rem] opacity-70 shrink-0" />
<div class="flex flex-col flex-1">
<span class="font-medium leading-tight" v-html="highlight(r.label, query)" />
<small class="text-[0.68rem] opacity-60">{{ r.trail.join(' > ') }}</small>
</div>
<span v-if="r.proBadge" class="text-[0.58rem] font-extrabold uppercase tracking-widest px-1.5 py-px rounded border border-[var(--surface-border)] text-[var(--text-color-secondary)] opacity-70">PRO</span>
</button>
</div>
<div v-else-if="showResults && query && !results.length" class="py-2 pb-2.5 text-[1rem] opacity-60 text-[var(--text-color-secondary)]">
Nenhum item encontrado.
</div>
<div v-else class="pb-2.5" />
</div>
<!-- Nav: todo o menu -->
<nav class="flex-1 overflow-y-auto px-2 py-2.5 flex flex-col gap-0.5 [scrollbar-width:thin] [scrollbar-color:var(--surface-border)_transparent]">
<template v-for="section in visibleSections" :key="section.label">
<!-- Label da seção exibe quando mostrando múltiplas seções -->
<div
v-if="visibleSections.length > 1"
class="text-[0.62rem] font-extrabold uppercase tracking-widest text-[var(--text-color-secondary)] opacity-55 px-2.5 pb-1.5 pt-3 first:pt-1"
>
{{ section.label }}
</div>
<template v-for="item in section.items" :key="item.to || item.label">
<!-- Sub-grupo -->
<div v-if="item.items?.length" class="flex flex-col gap-px">
<div class="text-[0.6rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] opacity-40 px-2.5 pb-1 pt-2">
{{ item.label }}
</div>
<button
v-for="child in item.items"
:key="child.to || child.label"
class="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-[9px] border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer text-left text-[0.83rem] font-medium transition-colors hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)]"
:class="{
'!bg-[color-mix(in_srgb,var(--primary-color)_10%,transparent)] !text-[var(--primary-color)] !font-semibold': isActive(child),
'opacity-55': isLocked(child)
}"
@click="navigate(child)"
>
<i v-if="child.icon" :class="child.icon" class="text-[1rem] shrink-0 opacity-75" />
<span class="flex-1">{{ child.label }}</span>
<span v-if="isLocked(child)" class="text-[0.58rem] font-extrabold uppercase tracking-widest px-1.5 py-px rounded border border-[var(--surface-border)] text-[var(--text-color-secondary)] opacity-70">PRO</span>
</button>
</div>
<!-- Item folha -->
<button
v-else
class="w-full flex items-center gap-2.5 px-2.5 py-2 rounded-[9px] border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer text-left text-[0.83rem] font-medium transition-colors hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)]"
:class="{
'!bg-[color-mix(in_srgb,var(--primary-color)_10%,transparent)] !text-[var(--primary-color)] !font-semibold': isActive(item),
'opacity-55': isLocked(item)
}"
@click="navigate(item)"
>
<i v-if="item.icon" :class="item.icon" class="text-[1rem] shrink-0 opacity-75" />
<span class="flex-1">{{ item.label }}</span>
<span v-if="isLocked(item)" class="text-[0.58rem] font-extrabold uppercase tracking-widest px-1.5 py-px rounded border border-[var(--surface-border)] text-[var(--text-color-secondary)] opacity-70">PRO</span>
</button>
</template>
</template>
</nav>
</aside>
@@ -117,134 +398,9 @@ function closePanel () {
</template>
<style scoped>
/* ─── Panel ──────────────────────────────────────────────── */
.rp {
width: 260px;
flex-shrink: 0;
height: 100vh;
display: flex;
flex-direction: column;
border-right: 1px solid var(--surface-border);
background: var(--surface-card);
overflow: hidden;
}
/* ─── Header ─────────────────────────────────────────────── */
.rp__head {
height: 56px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
border-bottom: 1px solid var(--surface-border);
}
.rp__title {
font-size: 0.9rem;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--text-color);
}
.rp__close {
width: 28px;
height: 28px;
border-radius: 7px;
border: none;
background: transparent;
color: var(--text-color-secondary);
cursor: pointer;
display: grid;
place-items: center;
font-size: 0.75rem;
transition: background 0.15s, color 0.15s;
}
.rp__close:hover {
background: var(--surface-ground);
color: var(--text-color);
}
/* ─── Nav list ───────────────────────────────────────────── */
.rp__nav {
flex: 1;
overflow-y: auto;
padding: 10px 8px;
display: flex;
flex-direction: column;
gap: 2px;
scrollbar-width: thin;
scrollbar-color: var(--surface-border) transparent;
}
/* ─── Group ──────────────────────────────────────────────── */
.rp__group {
display: flex;
flex-direction: column;
gap: 1px;
margin-top: 12px;
}
.rp__group:first-child { margin-top: 0; }
.rp__group-label {
font-size: 0.62rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-color-secondary);
opacity: 0.55;
padding: 2px 10px 6px;
}
/* ─── Item ───────────────────────────────────────────────── */
.rp__item {
width: 100%;
display: flex;
align-items: center;
gap: 9px;
padding: 8px 10px;
border-radius: 9px;
border: none;
background: transparent;
color: var(--text-color-secondary);
cursor: pointer;
text-align: left;
font-size: 0.83rem;
font-weight: 500;
transition: background 0.13s, color 0.13s;
}
.rp__item:hover {
background: var(--surface-ground);
color: var(--text-color);
}
.rp__item--active {
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
color: var(--primary-color);
font-weight: 600;
}
.rp__item--locked {
opacity: 0.55;
}
.rp__item-icon {
font-size: 0.85rem;
flex-shrink: 0;
opacity: 0.75;
}
.rp__item-label { flex: 1; }
.rp__pro {
font-size: 0.58rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.1em;
padding: 1px 6px;
border-radius: 4px;
border: 1px solid var(--surface-border);
color: var(--text-color-secondary);
opacity: 0.7;
}
/* ─── Slide transition ───────────────────────────────────── */
.panel-slide-enter-active,
.panel-slide-leave-active {
transition: width 0.22s cubic-bezier(0.22, 1, 0.36, 1),
opacity 0.18s ease;
transition: width 0.22s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.18s ease;
overflow: hidden;
}
.panel-slide-enter-from,
@@ -252,4 +408,4 @@ function closePanel () {
width: 0 !important;
opacity: 0;
}
</style>
</style>
+376 -3
View File
@@ -1,6 +1,6 @@
<!-- src/layout/AppRailSidebar.vue Drawer mobile para Layout Rail -->
<script setup>
import { computed, ref, watch } from 'vue'
import { computed, ref, watch, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useMenuStore } from '@/stores/menuStore'
@@ -37,6 +37,154 @@ watch(() => layoutState.mobileMenuActive, (open) => {
}
})
function expandAll () {
openSections.value = sections.value.map(s => s.key)
}
function collapseAll () {
openSections.value = []
}
// ── Busca ────────────────────────────────────────────────────
const query = ref('')
const showResults = ref(false)
const activeIndex = ref(-1)
const forcedOpen = ref(false)
const searchEl = ref(null)
const searchWrapEl = ref(null)
const RECENT_KEY = 'menu_search_recent'
const recent = ref([])
function loadRecent () {
try { recent.value = JSON.parse(localStorage.getItem(RECENT_KEY) || '[]') } catch { recent.value = [] }
}
function saveRecent (q) {
const v = String(q || '').trim()
if (!v) return
const list = [v, ...recent.value.filter(x => x !== v)].slice(0, 8)
recent.value = list
localStorage.setItem(RECENT_KEY, JSON.stringify(list))
}
function clearRecent () {
recent.value = []
try { localStorage.removeItem(RECENT_KEY) } catch {}
}
loadRecent()
watch(query, (v) => {
const hasText = !!v?.trim()
if (hasText) { forcedOpen.value = false; showResults.value = true; return }
showResults.value = forcedOpen.value
})
function clearSearch () {
query.value = ''
activeIndex.value = -1
showResults.value = false
forcedOpen.value = false
}
function norm (s) {
return String(s || '').toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '').trim()
}
function isVisibleItem (it) {
const v = it?.visible
if (typeof v === 'function') return !!v()
if (v === undefined || v === null) return true
return v !== false
}
function flattenMenu (items, trail = []) {
const out = []
for (const it of (items || [])) {
if (!isVisibleItem(it)) continue
const nextTrail = [...trail, it?.label].filter(Boolean)
if (it?.to && !it?.items?.length) {
out.push({ label: it.label || it.to, to: it.to, icon: it.icon, trail: nextTrail, proBadge: !!(it.__showProBadge ?? it.proBadge), feature: it.feature || null })
}
if (it?.items?.length) out.push(...flattenMenu(it.items, nextTrail))
}
return out
}
const allLinks = computed(() => flattenMenu(menuStore.model || []))
const results = computed(() => {
const q = norm(query.value)
if (!q) return []
const wantPro = q === 'pro' || q.startsWith('pro ') || q.includes(' pro')
return allLinks.value
.filter(r => {
const hay = `${norm(r.label)} ${norm(r.trail.join(' > '))} ${norm(r.to)}`
if (hay.includes(q)) return true
if (wantPro && (r.proBadge || r.feature)) return true
return false
})
.slice(0, 12)
})
watch(results, (list) => { activeIndex.value = list.length ? 0 : -1 })
function escapeHtml (s) {
return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;')
}
function highlight (text, q) {
const queryNorm = norm(q)
const raw = String(text || '')
if (!queryNorm) return escapeHtml(raw)
const rawNorm = norm(raw)
const idx = rawNorm.indexOf(queryNorm)
if (idx < 0) return escapeHtml(raw)
const before = escapeHtml(raw.slice(0, idx))
const mid = escapeHtml(raw.slice(idx, idx + queryNorm.length))
const after = escapeHtml(raw.slice(idx + queryNorm.length))
return `${before}<mark class="px-1 rounded bg-yellow-200/40">${mid}</mark>${after}`
}
function onSearchKeydown (e) {
if (e.key === 'Escape') { showResults.value = false; forcedOpen.value = false; return }
if (e.key === 'ArrowDown') {
e.preventDefault()
if (!results.value.length) return
showResults.value = true
activeIndex.value = (activeIndex.value + 1) % results.value.length
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
if (!results.value.length) return
showResults.value = true
activeIndex.value = (activeIndex.value - 1 + results.value.length) % results.value.length
return
}
if (e.key === 'Enter' && showResults.value && results.value.length && activeIndex.value >= 0) {
e.preventDefault()
goToResult(results.value[activeIndex.value])
}
}
function onSearchFocus () {
if (!query.value?.trim()) { forcedOpen.value = true; showResults.value = true }
}
function applyRecent (q) {
query.value = q
forcedOpen.value = true
showResults.value = true
activeIndex.value = 0
nextTick(() => searchEl.value?.$el?.querySelector?.('input')?.focus?.())
}
async function goToResult (r) {
saveRecent(query.value)
query.value = ''
showResults.value = false
activeIndex.value = -1
forcedOpen.value = false
await router.push(r.to)
hideMobileMenu()
}
function isSectionOpen (key) {
return openSections.value.includes(key)
}
@@ -99,6 +247,115 @@ watch(() => route.path, () => hideMobileMenu())
</button>
</div>
<!-- Busca + ações -->
<div ref="searchWrapEl" class="rs__search-area">
<!-- Campo de busca -->
<div class="rs__search-field">
<div
aria-hidden="true"
style="position:absolute; left:-9999px; top:-9999px; width:1px; height:1px; overflow:hidden;"
>
<input type="email" name="email" autocomplete="username" tabindex="-1" disabled />
<input type="password" name="password" autocomplete="current-password" tabindex="-1" disabled />
</div>
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText
ref="searchEl"
id="rs_menu_search"
name="rs_menu_search"
type="search"
inputmode="search"
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
data-lpignore="true"
data-1p-ignore="true"
v-model="query"
class="w-full pr-8"
variant="filled"
@focus="onSearchFocus"
@keydown="onSearchKeydown"
/>
</IconField>
<label for="rs_menu_search">Encontrar menu...</label>
</FloatLabel>
<button
v-if="query.trim()"
type="button"
class="rs__search-clear"
@mousedown.prevent="clearSearch"
aria-label="Limpar busca"
>
<i class="pi pi-times" />
</button>
</div>
<!-- Dropdown de resultados recentes -->
<div
v-if="showResults && !query.trim() && recent.length"
class="rs__dropdown"
>
<div class="rs__dropdown-header">
<span>Recentes</span>
<button type="button" class="rs__dropdown-action" @mousedown.prevent="clearRecent" aria-label="Limpar recentes">
<i class="pi pi-trash" />
</button>
</div>
<button
v-for="q in recent"
:key="q"
class="rs__dropdown-item"
type="button"
@click.stop.prevent="applyRecent(q)"
>
<i class="pi pi-history rs__dropdown-item-icon" />
<span class="flex-1">{{ q }}</span>
</button>
</div>
<!-- Dropdown de resultados de busca -->
<div
v-else-if="showResults && results.length"
class="rs__dropdown"
>
<button
v-for="(r, i) in results"
:key="String(r.to)"
type="button"
@mousedown.prevent="goToResult(r)"
:class="['rs__dropdown-item', i === activeIndex ? 'rs__dropdown-item--active' : '']"
>
<i v-if="r.icon" :class="r.icon" class="rs__dropdown-item-icon" />
<div class="flex flex-col flex-1">
<span class="rs__dropdown-item-label" v-html="highlight(r.label, query)" />
<small class="rs__dropdown-item-trail">{{ r.trail.join(' > ') }}</small>
</div>
<span v-if="r.proBadge" class="rs__pro">PRO</span>
</button>
</div>
<div v-else-if="showResults && query && !results.length" class="rs__no-results">
Nenhum item encontrado.
</div>
<!-- Botões expandir/contrair -->
<div class="rs__actions">
<button class="rs__action-btn" type="button" @click="expandAll">
<i class="pi pi-chevron-down" />
<span>Expandir tudo</span>
</button>
<button class="rs__action-btn" type="button" @click="collapseAll">
<i class="pi pi-chevron-up" />
<span>Contrair tudo</span>
</button>
</div>
</div>
<!-- Nav -->
<nav class="rs__nav">
<template v-for="section in sections" :key="section.key">
@@ -205,7 +462,123 @@ watch(() => route.path, () => hideMobileMenu())
color: var(--text-color);
}
/* ── Nav list ─────────────────────────────────────────────── */
/* ── Search area ──────────────────────────────────────────── */
.rs__search-area {
flex-shrink: 0;
padding: 10px 12px 0;
border-bottom: 1px solid var(--surface-border);
}
.rs__search-field {
position: relative;
}
.rs__search-clear {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
background: transparent;
border: none;
cursor: pointer;
opacity: 0.6;
color: var(--text-color-secondary);
font-size: 0.75rem;
display: grid;
place-items: center;
padding: 2px;
}
.rs__search-clear:hover { opacity: 1; }
/* ── Dropdown ─────────────────────────────────────────────── */
.rs__dropdown {
margin-top: 6px;
border: 1px solid var(--surface-border);
border-radius: 10px;
background: var(--surface-card);
box-shadow: 0 2px 8px rgba(0,0,0,.08);
overflow: hidden;
}
.rs__dropdown-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
font-size: 0.72rem;
opacity: 0.65;
color: var(--text-color-secondary);
}
.rs__dropdown-action {
background: transparent;
border: none;
cursor: pointer;
opacity: 0.7;
color: inherit;
font-size: 0.72rem;
}
.rs__dropdown-action:hover { opacity: 1; }
.rs__dropdown-item {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
padding: 7px 12px;
background: transparent;
border: none;
cursor: pointer;
color: var(--text-color);
font-size: 0.82rem;
text-align: left;
transition: background 0.12s;
}
.rs__dropdown-item:hover,
.rs__dropdown-item--active { background: var(--surface-hover); }
.rs__dropdown-item-icon {
font-size: 0.82rem;
opacity: 0.7;
flex-shrink: 0;
}
.rs__dropdown-item-label { font-weight: 500; line-height: 1.3; }
.rs__dropdown-item-trail {
font-size: 0.7rem;
opacity: 0.6;
}
.rs__no-results {
padding: 8px 4px;
font-size: 0.8rem;
opacity: 0.6;
color: var(--text-color-secondary);
}
/* ── Botões expandir/contrair ─────────────────────────────── */
.rs__actions {
display: flex;
gap: 6px;
padding: 8px 0 10px;
}
.rs__action-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
padding: 5px 8px;
border-radius: 8px;
border: 1px solid var(--surface-border);
background: transparent;
color: var(--text-color-secondary);
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: background 0.13s, color 0.13s;
}
.rs__action-btn:hover {
background: var(--surface-ground);
color: var(--text-color);
}
.rs__action-btn .pi {
font-size: 0.65rem;
}
.rs__nav {
flex: 1;
overflow-y: auto;
@@ -309,4 +682,4 @@ watch(() => route.path, () => hideMobileMenu())
.rs-slide-leave-to {
transform: translateX(-100%);
}
</style>
</style>
+46
View File
@@ -17,6 +17,12 @@ const { canSee } = useRoleGuard()
import { useAjuda } from '@/composables/useAjuda'
const { openDrawer: openAjudaDrawer, closeDrawer: closeAjudaDrawer, drawerOpen: ajudaDrawerOpen } = useAjuda()
import { useNotifications } from '@/composables/useNotifications'
import { useNotificationStore } from '@/stores/notificationStore'
import NotificationDrawer from '@/components/notifications/NotificationDrawer.vue'
const notificationStore = useNotificationStore()
useNotifications()
function toggleAjuda () {
ajudaDrawerOpen.value ? closeAjudaDrawer() : openAjudaDrawer()
}
@@ -585,6 +591,25 @@ onMounted(async () => {
:baseZIndex="3000"
/>
<!-- Notificações -->
<div class="relative">
<button
type="button"
class="rail-topbar__btn"
title="Notificações"
@click="notificationStore.drawerOpen = true"
>
<i class="pi pi-bell" />
<span
v-if="notificationStore.unreadCount > 0"
class="rail-topbar__notification-badge"
>
{{ notificationStore.unreadCount > 99 ? '99+' : notificationStore.unreadCount }}
</span>
</button>
<NotificationDrawer />
</div>
<!-- Ajuda -->
<button
type="button"
@@ -700,6 +725,27 @@ onMounted(async () => {
.config-panel {
z-index: 200;
}
/* Badge de notificações */
.rail-topbar__notification-badge {
position: absolute;
top: 0;
right: 0;
min-width: 1rem;
height: 1rem;
padding: 0 0.25rem;
border-radius: 999px;
background: #ef4444;
color: #fff;
font-size: 0.62rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
pointer-events: none;
transform: translate(25%, -25%);
}
.topbar-ctx-row {
display: flex;
align-items: center;
+213 -128
View File
@@ -24,7 +24,7 @@ const secoes = [
{
key: 'bloqueios',
label: 'Bloqueios',
desc: 'Feriados nacionais, municipais e períodos bloqueados para pacientes.',
desc: 'Feriados nacionais, municipais e períodos bloqueados.',
icon: 'pi pi-ban',
to: '/configuracoes/bloqueios',
tags: ['Feriados', 'Períodos', 'Recorrentes']
@@ -32,7 +32,7 @@ const secoes = [
{
key: 'agendador',
label: 'Agendador Online',
desc: 'Link público para pacientes solicitarem horários. Aprovação, identidade visual e pagamento.',
desc: 'Link público para pacientes solicitarem horários.',
icon: 'pi pi-calendar-clock',
to: '/configuracoes/agendador',
tags: ['PRO', 'Link', 'Pix', 'LGPD']
@@ -40,7 +40,7 @@ const secoes = [
{
key: 'pagamento',
label: 'Pagamento',
desc: 'Formas de pagamento aceitas: Pix, depósito bancário, dinheiro, cartão e convênio.',
desc: 'Formas de pagamento: Pix, depósito, dinheiro, cartão, convênio.',
icon: 'pi pi-wallet',
to: '/configuracoes/pagamento',
tags: ['Pix', 'TED', 'Cartão', 'Convênio']
@@ -48,7 +48,7 @@ const secoes = [
{
key: 'precificacao',
label: 'Precificação',
desc: 'Valor padrão da sessão e preços específicos por tipo de compromisso.',
desc: 'Valor padrão da sessão e preços por tipo de compromisso.',
icon: 'pi pi-tag',
to: '/configuracoes/precificacao',
tags: ['Valores', 'Sessão', 'Compromisso']
@@ -56,7 +56,7 @@ const secoes = [
{
key: 'descontos',
label: 'Descontos por Paciente',
desc: 'Descontos recorrentes aplicados automaticamente por paciente.',
desc: 'Descontos recorrentes aplicados automaticamente.',
icon: 'pi pi-percentage',
to: '/configuracoes/descontos',
tags: ['Desconto', 'Paciente', 'Automático']
@@ -64,7 +64,7 @@ const secoes = [
{
key: 'excecoes-financeiras',
label: 'Exceções Financeiras',
desc: 'O que cobrar em faltas, cancelamentos e outras situações excepcionais.',
desc: 'O que cobrar em faltas, cancelamentos e situações excepcionais.',
icon: 'pi pi-exclamation-triangle',
to: '/configuracoes/excecoes-financeiras',
tags: ['Falta', 'Cancelamento', 'Cobrança']
@@ -72,37 +72,11 @@ const secoes = [
{
key: 'convenios',
label: 'Convênios',
desc: 'Cadastre os convênios que você atende e seus valores de tabela.',
desc: 'Cadastre os convênios que você atende e seus valores.',
icon: 'pi pi-id-card',
to: '/configuracoes/convenios',
tags: ['Convênio', 'Plano de Saúde', 'Tabela']
},
// Ative quando criar as rotas/páginas
// {
// key: 'clinica',
// label: 'Clínica',
// desc: 'Padrões clínicos, status e preferências de atendimento.',
// icon: 'pi pi-heart',
// to: '/configuracoes/clinica',
// tags: ['Status', 'Modelos', 'Preferências']
// },
// {
// key: 'intake',
// label: 'Cadastros & Intake',
// desc: 'Link externo, campos do formulário e mensagens padrão.',
// icon: 'pi pi-file-edit',
// to: '/configuracoes/intake',
// tags: ['Formulário', 'Campos', 'Textos']
// },
// {
// key: 'conta',
// label: 'Conta',
// desc: 'Perfil, segurança e preferências da conta.',
// icon: 'pi pi-user',
// to: '/configuracoes/conta',
// tags: ['Perfil', 'Segurança', 'Preferências']
// }
]
const activeTo = computed(() => {
@@ -113,6 +87,8 @@ const activeTo = computed(() => {
return hit?.to || '/configuracoes/agenda'
})
const activeSecao = computed(() => secoes.find(s => s.to === activeTo.value))
function ir(to) {
if (!to) return
if (route.path !== to) router.push(to)
@@ -134,105 +110,87 @@ onBeforeUnmount(() => { _observer?.disconnect() })
<!-- Sentinel -->
<div ref="headerSentinelRef" class="cfg-sentinel" />
<!-- Hero sticky -->
<div ref="headerEl" class="cfg-hero mb-4" :class="{ 'cfg-hero--stuck': headerStuck }">
<!-- Hero compacto padrão Compromissos -->
<div ref="headerEl" class="cfg-hero mx-3 md:mx-4 mb-3" :class="{ 'cfg-hero--stuck': headerStuck }">
<div class="cfg-hero__blobs" aria-hidden="true">
<div class="cfg-hero__blob cfg-hero__blob--1" />
<div class="cfg-hero__blob cfg-hero__blob--2" />
<div class="cfg-hero__blob cfg-hero__blob--3" />
</div>
<div class="cfg-hero__row1">
<div class="cfg-hero__inner">
<!-- Brand -->
<div class="cfg-hero__brand">
<div class="cfg-hero__icon"><i class="pi pi-cog text-lg" /></div>
<div class="min-w-0">
<div class="cfg-hero__icon"><i class="pi pi-cog text-base" /></div>
<div class="min-w-0 lg:block">
<div class="cfg-hero__title">Configurações</div>
<div class="cfg-hero__sub">Defina como sua agenda e clínica funcionam</div>
<div class="cfg-hero__sub">
<span v-if="activeSecao">
<i :class="activeSecao.icon" class="text-xs mr-1 opacity-60" />{{ activeSecao.label }}
</span>
<span v-else>Configurações gerais</span>
</div>
</div>
</div>
<div class="hidden xl:flex items-center gap-2 shrink-0">
<Button label="Voltar" icon="pi pi-arrow-left" severity="secondary" outlined class="rounded-full" @click="router.back()" />
</div>
<div class="flex xl:hidden items-center shrink-0">
<Button icon="pi pi-arrow-left" severity="secondary" outlined class="h-9 w-9 rounded-full" @click="router.back()" />
<!-- Ações -->
<div class="cfg-hero__actions">
<Button icon="pi pi-arrow-left" severity="secondary" outlined class="h-9 w-9 rounded-full" v-tooltip.bottom="'Voltar'" @click="router.back()" />
</div>
</div>
</div>
<div class="pt-0">
<!-- Stats: seções como cards clicáveis -->
<div class="flex flex-wrap gap-2 px-3 md:px-4 mb-3">
<button
v-for="s in secoes"
:key="s.key"
class="cfg-sec-card"
:class="{ 'cfg-sec-card--active': activeTo === s.to }"
@click="ir(s.to)"
>
<i :class="s.icon" class="cfg-sec-card__icon" />
<span class="cfg-sec-card__label">{{ s.label }}</span>
</button>
</div>
<div class="grid grid-cols-12 gap-4">
<!-- SIDEBAR (seções) -->
<div class="col-span-12 lg:col-span-4 xl:col-span-3">
<Card class="h-full">
<template #title>
<div class="flex items-center gap-2">
<i class="pi pi-cog" />
<span>Seções</span>
<!-- Layout: sidebar + conteúdo -->
<div class="flex flex-col xl:flex-row gap-3 px-3 md:px-4 pb-5 items-start">
<!-- Sidebar: lista de seções (oculto no mobile temos os cards acima) -->
<div class="hidden xl:flex flex-col gap-1 w-[260px] shrink-0">
<div class="cfg-sidebar-wrap">
<div class="cfg-sidebar-head">
<i class="pi pi-cog text-xs opacity-60" />
<span>Seções</span>
</div>
<div class="flex flex-col gap-0.5">
<button
v-for="s in secoes"
:key="s.key"
class="cfg-nav-item"
:class="{ 'cfg-nav-item--active': activeTo === s.to }"
@click="ir(s.to)"
>
<i :class="s.icon" class="cfg-nav-item__icon" />
<div class="cfg-nav-item__body">
<span class="cfg-nav-item__label">{{ s.label }}</span>
<span class="cfg-nav-item__desc">{{ s.desc }}</span>
</div>
</template>
<template #content>
<div class="flex flex-col gap-2">
<button
v-for="s in secoes"
:key="s.key"
type="button"
class="w-full text-left p-3 rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] hover:bg-[var(--surface-hover)] transition flex items-start justify-between gap-3"
:class="activeTo === s.to ? 'ring-1 ring-primary/40 border-primary/40' : ''"
@click="ir(s.to)"
>
<div class="flex gap-3">
<div class="mt-1">
<i :class="[s.icon, 'text-lg']" style="opacity:.85" />
</div>
<div>
<div class="text-900 font-medium leading-none">{{ s.label }}</div>
<div class="text-600 text-sm mt-2 leading-snug">{{ s.desc }}</div>
<div v-if="s.tags?.length" class="mt-3 flex flex-wrap gap-2">
<span
v-for="t in s.tags"
:key="t"
class="text-xs px-2 py-1 rounded-full border border-[var(--surface-border)] text-600"
>
{{ t }}
</span>
</div>
</div>
</div>
<i class="pi pi-angle-right mt-1" style="opacity:.55" />
</button>
<Divider class="my-2" />
<Button
label="Voltar"
icon="pi pi-arrow-left"
severity="secondary"
outlined
class="w-full md:hidden"
@click="router.back()"
/>
</div>
</template>
</Card>
</div>
<!-- CONTEÚDO (seção selecionada) -->
<div class="col-span-12 lg:col-span-8 xl:col-span-9">
<!-- Aqui entra /configuracoes/agenda etc -->
<router-view />
<i class="pi pi-chevron-right cfg-nav-item__arrow" />
</button>
</div>
</div>
</div>
<!-- Conteúdo da seção -->
<div class="flex-1 min-w-0 w-full">
<router-view />
</div>
</div>
</template>
<style scoped>
/* ── Hero ─────────────────────────────────────────────── */
.cfg-sentinel { height: 1px; }
.cfg-hero {
@@ -240,36 +198,163 @@ onBeforeUnmount(() => { _observer?.disconnect() })
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border-radius: 6px;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.25rem 1.5rem;
padding: 10px 12px;
}
.cfg-hero--stuck {
margin-left: 0; margin-right: 0;
border-top-left-radius: 0; border-top-right-radius: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.cfg-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.cfg-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
.cfg-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(52,211,153,0.10); }
.cfg-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(99,102,241,0.09); }
.cfg-hero__blob--3 { width: 14rem; height: 14rem; bottom: -2rem; right: 8rem; background: rgba(217,70,239,0.07); }
.cfg-hero__blob { position: absolute; border-radius: 50%; filter: blur(60px); }
.cfg-hero__blob--1 { width: 16rem; height: 16rem; top: -4rem; right: -2rem; background: rgba(52,211,153,0.10); }
.cfg-hero__blob--2 { width: 18rem; height: 18rem; top: 0; left: -4rem; background: rgba(99,102,241,0.09); }
.cfg-hero__row1 {
.cfg-hero__inner {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1rem;
}
.cfg-hero__brand {
display: flex; align-items: center; gap: 0.75rem;
flex: 1; min-width: 0;
}
.cfg-hero__brand { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
.cfg-hero__icon {
display: grid; place-items: center;
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem; flex-shrink: 0;
width: 2.25rem; height: 2.25rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
}
.cfg-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
.cfg-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
.cfg-hero__title { font-size: 1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
.cfg-hero__sub { font-size: 0.75rem; color: var(--text-color-secondary); }
.cfg-hero__actions { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; }
/* Breadcrumb seção ativa */
.cfg-breadcrumb { display: flex; align-items: center; gap: 0.5rem; }
.cfg-breadcrumb__active {
display: inline-flex; align-items: center; gap: 0.4rem;
padding: 0.25rem 0.75rem; border-radius: 999px;
border: 1px solid var(--primary-color, #6366f1);
background: color-mix(in srgb, var(--primary-color) 8%, transparent);
color: var(--primary-color, #6366f1);
font-size: 0.8rem; font-weight: 600;
}
/* ── Cards de seção (stats row) ────────────────────────── */
.cfg-sec-card {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 0.875rem;
border-radius: 6px;
border: 1px solid var(--surface-border);
background: var(--surface-card);
cursor: pointer;
transition: border-color 0.15s, background 0.15s, box-shadow 0.15s;
white-space: nowrap;
}
.cfg-sec-card:hover {
border-color: color-mix(in srgb, var(--primary-color) 40%, transparent);
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.cfg-sec-card--active {
border-color: var(--primary-color, #6366f1);
background: color-mix(in srgb, var(--primary-color) 8%, var(--surface-card));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 12%, transparent);
}
.cfg-sec-card__icon {
font-size: 0.78rem;
color: var(--text-color-secondary);
opacity: 0.75;
}
.cfg-sec-card--active .cfg-sec-card__icon {
color: var(--primary-color, #6366f1);
opacity: 1;
}
.cfg-sec-card__label {
font-size: 0.78rem;
font-weight: 600;
color: var(--text-color);
}
.cfg-sec-card--active .cfg-sec-card__label {
color: var(--primary-color, #6366f1);
}
/* ── Sidebar nav ──────────────────────────────────────── */
.cfg-sidebar-wrap {
border: 1px solid var(--surface-border);
border-radius: 6px;
background: var(--surface-card);
overflow: hidden;
}
.cfg-sidebar-head {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.625rem 0.875rem;
border-bottom: 1px solid var(--surface-border);
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-color-secondary);
opacity: 0.65;
}
.cfg-nav-item {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.625rem 0.875rem;
border: none;
background: transparent;
cursor: pointer;
transition: background 0.12s;
width: 100%;
text-align: left;
border-bottom: 1px solid var(--surface-border);
}
.cfg-nav-item:last-child { border-bottom: none; }
.cfg-nav-item:hover { background: var(--surface-hover); }
.cfg-nav-item--active {
background: color-mix(in srgb, var(--primary-color) 6%, var(--surface-card));
}
.cfg-nav-item__icon {
font-size: 0.85rem;
color: var(--text-color-secondary);
opacity: 0.6;
flex-shrink: 0;
width: 16px;
text-align: center;
}
.cfg-nav-item--active .cfg-nav-item__icon {
color: var(--primary-color, #6366f1);
opacity: 1;
}
.cfg-nav-item__body {
flex: 1; min-width: 0;
display: flex; flex-direction: column; gap: 1px;
}
.cfg-nav-item__label {
font-size: 0.82rem;
font-weight: 600;
color: var(--text-color);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.cfg-nav-item--active .cfg-nav-item__label {
color: var(--primary-color, #6366f1);
}
.cfg-nav-item__desc {
font-size: 0.7rem;
color: var(--text-color-secondary);
opacity: 0.7;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.cfg-nav-item__arrow {
font-size: 0.6rem;
color: var(--text-color-secondary);
opacity: 0.3;
flex-shrink: 0;
}
.cfg-nav-item--active .cfg-nav-item__arrow {
color: var(--primary-color, #6366f1);
opacity: 0.6;
}
</style>
+21 -4
View File
@@ -1,4 +1,4 @@
import { computed, reactive } from 'vue'
import { computed, reactive, ref, onMounted, onBeforeUnmount } from 'vue'
// ── resolve variant salvo no localStorage ───────────────────
function _loadVariant () {
@@ -55,6 +55,14 @@ function syncDarkFromDomOnce () {
} catch {}
}
// ── reactive mobile state (atualiza no resize) ───────────────
const _isMobileRef = ref(typeof window !== 'undefined' ? window.innerWidth <= 1200 : false)
if (typeof window !== 'undefined') {
const _onResize = () => { _isMobileRef.value = window.innerWidth <= 1200 }
window.addEventListener('resize', _onResize, { passive: true })
}
export function useLayout () {
// ✅ garante coerência sempre que alguém usar useLayout()
syncDarkFromDomOnce()
@@ -82,13 +90,13 @@ export function useLayout () {
const isRailMobile = () => window.innerWidth <= 1200
const toggleMenu = () => {
// No Rail, o botão hamburguer (≤1200px) controla a sidebar mobile
if (layoutConfig.variant === 'rail') {
// No Rail, em desktop, o botão hamburguer controla a sidebar mobile do rail
if (layoutConfig.variant === 'rail' && !_isMobileRef.value) {
layoutState.mobileMenuActive = !layoutState.mobileMenuActive
return
}
// Layout clássico — comportamento original
// Layout clássico (ou mobile com qualquer variant) — comportamento original
if (isDesktop()) {
if (layoutConfig.menuMode === 'static') {
layoutState.staticMenuInactive = !layoutState.staticMenuInactive
@@ -160,6 +168,13 @@ export function useLayout () {
const isDarkTheme = computed(() => layoutConfig.darkTheme)
const hasOpenOverlay = computed(() => layoutState.overlayMenuActive)
// ── Em mobile (≤ 1200px) sempre usa o layout clássico, ───────
// independente de layoutConfig.variant
const isMobile = computed(() => _isMobileRef.value)
const effectiveVariant = computed(() =>
_isMobileRef.value ? 'classic' : layoutConfig.variant
)
return {
layoutConfig,
layoutState,
@@ -173,6 +188,8 @@ export function useLayout () {
setVariant,
isDesktop,
isRailMobile,
isMobile,
effectiveVariant,
hasOpenOverlay
}
}
+130 -185
View File
@@ -267,94 +267,81 @@ const loading = computed(() => loadingF.value || loadingB.value)
<template>
<Toast />
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3">
<!-- Cabeçalho do ano -->
<div class="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] px-5 py-4">
<div>
<div class="font-semibold text-base">Bloqueios da agenda</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">
Feriados e períodos em que não é possível agendar com pacientes.
</div>
<!-- Subheader degradê -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-ban" /></div>
<div class="min-w-0 flex-1">
<div class="cfg-subheader__title">Bloqueios</div>
<div class="cfg-subheader__sub">Feriados e períodos em que não é possível agendar com pacientes</div>
</div>
<div class="flex items-center gap-2">
<Button icon="pi pi-chevron-left" text rounded severity="secondary" @click="anoAnterior" />
<span class="font-bold text-lg w-14 text-center">{{ ano }}</span>
<Button icon="pi pi-chevron-right" text rounded severity="secondary" @click="anoProximo" />
<!-- Nav de ano -->
<div class="flex items-center gap-1 shrink-0 relative z-10">
<Button icon="pi pi-chevron-left" text rounded size="small" severity="secondary" @click="anoAnterior" />
<span class="font-bold text-sm w-12 text-center text-[var(--primary-color)]">{{ ano }}</span>
<Button icon="pi pi-chevron-right" text rounded size="small" severity="secondary" @click="anoProximo" />
</div>
</div>
<!-- Stats rápidos -->
<div class="grid grid-cols-3 gap-3">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 text-center">
<div class="text-2xl font-bold text-blue-500">{{ nacionais.length }}</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Feriados nacionais</div>
<!-- Stats + ações -->
<div class="flex flex-wrap items-center gap-2">
<div class="blk-stat blk-stat--blue">
<div class="blk-stat__value">{{ nacionais.length }}</div>
<div class="blk-stat__label">Nacionais</div>
</div>
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 text-center">
<div class="text-2xl font-bold text-orange-500">{{ municipais.length }}</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Feriados municipais</div>
<div class="blk-stat blk-stat--orange">
<div class="blk-stat__value">{{ municipais.length }}</div>
<div class="blk-stat__label">Municipais</div>
</div>
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 text-center">
<div class="text-2xl font-bold text-red-500">{{ bloqueios.length }}</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Bloqueios</div>
<div class="blk-stat blk-stat--red">
<div class="blk-stat__value">{{ bloqueios.length }}</div>
<div class="blk-stat__label">Bloqueios</div>
</div>
<div class="ml-auto flex gap-2">
<Button icon="pi pi-map-marker" label="Feriado municipal" severity="secondary" outlined class="rounded-full" size="small" @click="abrirFeriadoMunicipal" />
<Button icon="pi pi-ban" label="Novo bloqueio" class="rounded-full" size="small" @click="abrirAddBloqueio" />
</div>
</div>
<!-- Ações -->
<div class="flex flex-wrap gap-2">
<Button
icon="pi pi-map-marker"
label="Adicionar feriado municipal"
severity="secondary"
outlined
class="rounded-full"
@click="abrirFeriadoMunicipal"
/>
<Button
icon="pi pi-ban"
label="Adicionar bloqueio"
class="rounded-full"
@click="abrirAddBloqueio"
/>
</div>
<!-- Loading -->
<!-- Loading -->
<div v-if="loading" class="flex items-center justify-center py-16">
<i class="pi pi-spinner pi-spin text-2xl opacity-40" />
</div>
<template v-else>
<!-- Feriados Nacionais (somente leitura) -->
<!-- Feriados Nacionais -->
<div class="blk-group">
<div class="blk-group__head">
<i class="pi pi-flag text-blue-500" />
<div class="blk-group__head-icon" style="background:color-mix(in srgb,#3b82f6 12%,transparent);color:#3b82f6">
<i class="pi pi-flag" />
</div>
<span>Feriados Nacionais</span>
<span class="blk-group__count">{{ nacionais.length }}</span>
<span class="ml-auto mr-0 text-xs text-[var(--text-color-secondary)] font-normal">gerado automaticamente</span>
<span class="ml-auto text-xs text-[var(--text-color-secondary)] opacity-60 font-normal">gerado automaticamente</span>
</div>
<div class="blk-list">
<div v-for="f in nacionais" :key="f.data + f.nome" class="blk-item">
<div class="blk-item__date">{{ fmtDateShort(f.data) }}</div>
<div class="blk-item__title">{{ f.nome }}</div>
<Tag v-if="f.movel" value="Móvel" severity="secondary" class="text-xs shrink-0" />
<Tag v-if="f.movel" value="Móvel" severity="secondary" class="text-xs shrink-0 ml-auto" />
</div>
</div>
</div>
<!-- Feriados Municipais -->
<!-- Feriados Municipais -->
<div class="blk-group">
<div class="blk-group__head">
<i class="pi pi-map-marker text-orange-500" />
<div class="blk-group__head-icon" style="background:color-mix(in srgb,#f97316 12%,transparent);color:#f97316">
<i class="pi pi-map-marker" />
</div>
<span>Feriados Municipais</span>
<span class="blk-group__count">{{ municipais.length }}</span>
</div>
<div v-if="!municipais.length" class="blk-empty">
Nenhum feriado municipal cadastrado para {{ ano }}.
</div>
<div v-else class="blk-list">
<div v-for="f in municipais" :key="f.id" class="blk-item">
<div class="blk-item__date">{{ fmtDate(f.data) }}</div>
@@ -367,23 +354,23 @@ const loading = computed(() => loadingF.value || loadingB.value)
</div>
</div>
<!-- Bloqueios -->
<!-- Bloqueios -->
<div class="blk-group">
<div class="blk-group__head">
<i class="pi pi-ban text-red-500" />
<div class="blk-group__head-icon" style="background:color-mix(in srgb,#ef4444 12%,transparent);color:#ef4444">
<i class="pi pi-ban" />
</div>
<span>Bloqueios</span>
<span class="blk-group__count">{{ bloqueios.length }}</span>
</div>
<div v-if="!bloqueios.length" class="blk-empty">
Nenhum bloqueio cadastrado para {{ ano }}.
</div>
<div v-else class="blk-list">
<div v-for="b in bloqueios" :key="b.id" class="blk-item">
<div class="blk-item__date">{{ fmtPeriodo(b) }}</div>
<div class="blk-item__title">{{ b.titulo }}</div>
<Tag v-if="b.recorrente" value="Recorrente" severity="warn" class="text-xs" />
<Tag v-if="b.recorrente" value="Recorrente" severity="warn" class="text-xs shrink-0" />
<div v-if="b.observacao" class="blk-item__obs">{{ b.observacao }}</div>
<div class="blk-item__actions">
<Button icon="pi pi-pencil" text rounded size="small" severity="secondary" @click="abrirEditBloqueio(b)" />
@@ -396,222 +383,180 @@ const loading = computed(() => loadingF.value || loadingB.value)
</template>
</div>
<!-- Dialog feriado municipal -->
<Dialog
v-model:visible="fdlgOpen"
modal
:draggable="false"
header="Cadastrar feriado municipal"
:style="{ width: '420px' }"
>
<!-- Dialog feriado municipal -->
<Dialog v-model:visible="fdlgOpen" modal :draggable="false" pt:mask:class="backdrop-blur-xs" header="Cadastrar feriado municipal" :style="{ width: '420px', maxWidth: '95vw' }">
<div class="flex flex-col gap-4 pt-1">
<div>
<label class="blk-label">Nome do feriado *</label>
<InputText v-model="fform.nome" class="w-full mt-1" placeholder="Ex.: Aniversário da cidade, Padroeiro…" />
</div>
<div>
<label class="blk-label">Data *</label>
<DatePicker
v-model="fform.data"
showIcon fluid iconDisplay="input"
dateFormat="dd/mm/yy"
:manualInput="false"
class="mt-1"
>
<DatePicker v-model="fform.data" showIcon fluid iconDisplay="input" dateFormat="dd/mm/yy" :manualInput="false" class="mt-1">
<template #inputicon="sp"><i class="pi pi-calendar" @click="sp.clickCallback" /></template>
</DatePicker>
</div>
<div>
<label class="blk-label">Observação <span class="opacity-60">(opcional)</span></label>
<Textarea v-model="fform.observacao" class="w-full mt-1" rows="2" autoResize placeholder="Nota interna…" />
</div>
<div v-if="fform.data && fform.nome && isDuplicata(dateToISO(fform.data), fform.nome)"
class="text-sm text-red-500 flex items-center gap-2">
<i class="pi pi-exclamation-triangle" />
existe um feriado com esse nome nessa data.
<div v-if="fform.data && fform.nome && isDuplicata(dateToISO(fform.data), fform.nome)" class="text-sm text-red-500 flex items-center gap-2">
<i class="pi pi-exclamation-triangle" /> existe um feriado com esse nome nessa data.
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined @click="fdlgOpen = false" />
<Button
label="Cadastrar"
icon="pi pi-check"
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" @click="fdlgOpen = false" />
<Button label="Cadastrar" icon="pi pi-check" class="rounded-full"
:disabled="!fformValid || (fform.data && fform.nome && isDuplicata(dateToISO(fform.data), fform.nome))"
:loading="fsaving"
@click="salvarFeriado"
/>
:loading="fsaving" @click="salvarFeriado" />
</template>
</Dialog>
<!-- Dialog bloqueio add/edit -->
<Dialog
v-model:visible="dlgOpen"
modal
:draggable="false"
<!-- Dialog bloqueio -->
<Dialog v-model:visible="dlgOpen" modal :draggable="false" pt:mask:class="backdrop-blur-xs"
:header="dlgMode === 'edit' ? 'Editar bloqueio' : 'Novo bloqueio'"
:style="{ width: '480px' }"
>
:style="{ width: '480px', maxWidth: '95vw' }">
<div class="flex flex-col gap-4 pt-1">
<div>
<label class="blk-label">Título *</label>
<InputText v-model="form.titulo" class="w-full mt-1" placeholder="Ex.: Recesso, Férias, Licença…" />
</div>
<div class="flex gap-3">
<div class="flex-1">
<label class="blk-label">Data início *</label>
<DatePicker
v-model="form.data_inicio"
showIcon fluid iconDisplay="input"
dateFormat="dd/mm/yy"
:manualInput="false"
class="mt-1"
>
<DatePicker v-model="form.data_inicio" showIcon fluid iconDisplay="input" dateFormat="dd/mm/yy" :manualInput="false" class="mt-1">
<template #inputicon="sp"><i class="pi pi-calendar" @click="sp.clickCallback" /></template>
</DatePicker>
</div>
<div class="flex-1">
<label class="blk-label">Data fim <span class="opacity-60">(opcional)</span></label>
<DatePicker
v-model="form.data_fim"
showIcon fluid iconDisplay="input"
dateFormat="dd/mm/yy"
:manualInput="false"
:minDate="form.data_inicio || undefined"
class="mt-1"
>
<DatePicker v-model="form.data_fim" showIcon fluid iconDisplay="input" dateFormat="dd/mm/yy" :manualInput="false" :minDate="form.data_inicio || undefined" class="mt-1">
<template #inputicon="sp"><i class="pi pi-calendar" @click="sp.clickCallback" /></template>
</DatePicker>
</div>
</div>
<div class="flex gap-3">
<div class="flex-1">
<label class="blk-label">Hora início <span class="opacity-60">(opcional)</span></label>
<DatePicker
v-model="form.hora_inicio"
showIcon fluid iconDisplay="input"
timeOnly hourFormat="24" :stepMinute="15" :manualInput="false"
class="mt-1"
>
<DatePicker v-model="form.hora_inicio" showIcon fluid iconDisplay="input" timeOnly hourFormat="24" :stepMinute="15" :manualInput="false" class="mt-1">
<template #inputicon="sp"><i class="pi pi-clock" @click="sp.clickCallback" /></template>
</DatePicker>
</div>
<div class="flex-1">
<label class="blk-label">Hora fim</label>
<DatePicker
v-model="form.hora_fim"
showIcon fluid iconDisplay="input"
timeOnly hourFormat="24" :stepMinute="15" :manualInput="false"
class="mt-1"
>
<label class="blk-label">Hora fim <span class="opacity-60">(opcional)</span></label>
<DatePicker v-model="form.hora_fim" showIcon fluid iconDisplay="input" timeOnly hourFormat="24" :stepMinute="15" :manualInput="false" class="mt-1">
<template #inputicon="sp"><i class="pi pi-clock" @click="sp.clickCallback" /></template>
</DatePicker>
</div>
</div>
<div>
<label class="blk-label">Observação <span class="opacity-60">(opcional)</span></label>
<Textarea v-model="form.observacao" class="w-full mt-1" rows="2" autoResize placeholder="Nota interna…" />
<Textarea v-model="form.observacao" class="w-full mt-1" rows="2" autoResize />
</div>
</div>
<template #footer>
<Button label="Cancelar" icon="pi pi-times" severity="secondary" outlined @click="dlgOpen = false" />
<Button
:label="dlgMode === 'edit' ? 'Salvar' : 'Adicionar'"
icon="pi pi-check"
:disabled="!formValid"
:loading="saving"
@click="salvarBloqueio"
/>
<Button label="Cancelar" icon="pi pi-times" severity="secondary" outlined class="rounded-full" @click="dlgOpen = false" />
<Button :label="dlgMode === 'edit' ? 'Salvar' : 'Adicionar'" icon="pi pi-check" class="rounded-full"
:disabled="!formValid" :loading="saving" @click="salvarBloqueio" />
</template>
</Dialog>
</template>
<style scoped>
/* ── Grupos ──────────────────────────────────────────────── */
/* ── Subheader degradê ────────────────────────────── */
.cfg-subheader {
display: flex; align-items: center; gap: 0.65rem;
padding: 0.875rem 1rem; border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--primary-color, #6366f1) 30%, transparent);
background: linear-gradient(135deg,
color-mix(in srgb, var(--primary-color, #6366f1) 12%, var(--surface-card)) 0%,
color-mix(in srgb, var(--primary-color, #6366f1) 4%, var(--surface-card)) 60%,
var(--surface-card) 100%);
position: relative; overflow: hidden;
}
.cfg-subheader::before {
content: ''; position: absolute;
top: -20px; right: -20px; width: 80px; height: 80px; border-radius: 50%;
background: color-mix(in srgb, var(--primary-color, #6366f1) 15%, transparent);
filter: blur(20px); pointer-events: none;
}
.cfg-subheader__icon {
display: grid; place-items: center;
width: 2rem; height: 2rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color, #6366f1) 20%, transparent);
color: var(--primary-color, #6366f1); font-size: 0.85rem;
}
.cfg-subheader__title { font-size: 0.95rem; font-weight: 700; color: var(--primary-color, #6366f1); letter-spacing: -0.01em; }
.cfg-subheader__sub { font-size: 0.75rem; color: var(--text-color-secondary); opacity: 0.85; }
/* ── Stats ────────────────────────────────────────── */
.blk-stat {
display: flex; flex-direction: column; gap: 0.1rem;
padding: 0.5rem 0.875rem; border-radius: 6px;
border: 1px solid var(--surface-border);
background: var(--surface-card); min-width: 72px;
}
.blk-stat__value { font-size: 1.35rem; font-weight: 700; line-height: 1; }
.blk-stat__label { font-size: 0.7rem; color: var(--text-color-secondary); opacity: 0.75; }
.blk-stat--blue .blk-stat__value { color: #3b82f6; }
.blk-stat--orange .blk-stat__value { color: #f97316; }
.blk-stat--red .blk-stat__value { color: #ef4444; }
/* ── Grupos ──────────────────────────────────────── */
.blk-group {
border-radius: 1.25rem;
border-radius: 6px;
border: 1px solid var(--surface-border);
background: var(--surface-card);
overflow: hidden;
}
.blk-group__head {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.875rem 1.25rem;
display: flex; align-items: center; gap: 0.5rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--surface-border);
font-weight: 600;
font-size: 0.9rem;
font-weight: 600; font-size: 0.88rem;
background: var(--surface-ground);
}
.blk-group__head-icon {
display: grid; place-items: center;
width: 1.75rem; height: 1.75rem; border-radius: 6px; flex-shrink: 0;
font-size: 0.78rem;
}
.blk-group__count {
font-size: 0.75rem;
background: var(--surface-ground);
font-size: 0.7rem;
background: var(--surface-card);
border: 1px solid var(--surface-border);
border-radius: 999px;
padding: 1px 8px;
border-radius: 999px; padding: 1px 8px;
color: var(--text-color-secondary);
}
/* ── Itens ───────────────────────────────────────────────── */
/* ── Itens ──────────────────────────────────────── */
.blk-empty {
padding: 1.25rem;
font-size: 0.875rem;
color: var(--text-color-secondary);
}
.blk-list {
display: flex;
flex-direction: column;
}
.blk-list { display: flex; flex-direction: column; }
.blk-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1.25rem;
display: flex; align-items: center; gap: 0.75rem;
padding: 0.625rem 1rem;
border-bottom: 1px solid var(--surface-border);
flex-wrap: wrap;
flex-wrap: wrap; transition: background 0.1s;
}
.blk-item:last-child { border-bottom: none; }
.blk-item:hover { background: var(--surface-hover); }
.blk-item__date {
font-size: 0.8rem;
color: var(--text-color-secondary);
white-space: nowrap;
min-width: 5.5rem;
font-size: 0.75rem; color: var(--text-color-secondary);
white-space: nowrap; min-width: 5.5rem;
font-variant-numeric: tabular-nums;
}
.blk-item__title {
flex: 1;
font-weight: 500;
font-size: 0.875rem;
min-width: 0;
}
.blk-item__title { flex: 1; font-weight: 500; font-size: 0.85rem; min-width: 0; }
.blk-item__obs {
font-size: 0.75rem;
color: var(--text-color-secondary);
width: 100%;
padding-left: 6.25rem;
margin-top: -0.25rem;
}
.blk-item__actions {
display: flex;
gap: 0.25rem;
margin-left: auto;
font-size: 0.72rem; color: var(--text-color-secondary);
width: 100%; padding-left: 6.25rem; margin-top: -0.25rem;
}
.blk-item__actions { display: flex; gap: 0.25rem; margin-left: auto; }
/* ── Dialog ──────────────────────────────────────────────── */
.blk-label {
font-size: 0.75rem;
color: var(--text-color-secondary);
font-weight: 500;
}
</style>
/* ── Dialog labels ──────────────────────────────── */
.blk-label { font-size: 0.75rem; color: var(--text-color-secondary); font-weight: 500; }
</style>
@@ -796,6 +796,15 @@ const jornadaEndDate = computed({
<!-- COLUNA ESQUERDA: CARDS -->
<div class="flex flex-col gap-3 xl:w-[58%]">
<!-- Subheader -->
<div class="cfg-subheader">
<i class="pi pi-calendar cfg-subheader__icon" />
<div class="min-w-0">
<div class="cfg-subheader__title">Agenda</div>
<div class="cfg-subheader__sub">Horários semanais, duração e intervalo padrão</div>
</div>
</div>
<!-- CARD 1: JORNADA -->
<div class="cfg-card" :class="{ 'cfg-card--open': expandedCard === 'jornada' }">
@@ -952,7 +961,7 @@ const jornadaEndDate = computed({
<div
v-for="d in selectedDays"
:key="d.value"
class="flex items-center gap-3 p-2 rounded-xl bg-[var(--surface-ground)]"
class="flex items-center gap-3 p-2 rounded-[6px] bg-[var(--surface-ground)]"
>
<span class="w-10 text-sm font-medium">{{ d.short }}</span>
<div class="w-32">
@@ -1060,7 +1069,7 @@ const jornadaEndDate = computed({
</div>
<!-- Campos manuais (personalizado) -->
<div v-if="showAdvancedRitmo || !durationPresets.some(p => isActivePreset(p))" class="mb-5 p-4 rounded-2xl bg-[var(--surface-ground)]">
<div v-if="showAdvancedRitmo || !durationPresets.some(p => isActivePreset(p))" class="mb-5 p-4 rounded-[6px] bg-[var(--surface-ground)]">
<div class="cfg-label mb-3">Personalizado</div>
<div class="flex flex-row gap-6">
<div class="flex flex-col gap-1">
@@ -1121,7 +1130,7 @@ const jornadaEndDate = computed({
<div class="border-t border-[var(--surface-border)] pt-4">
<!-- Aviso slots órfãos -->
<div v-if="orphanSlotDays.length" class="flex items-start gap-2 mb-4 p-3 rounded-xl bg-[var(--yellow-50)] border border-[var(--yellow-200)] text-sm text-[var(--yellow-800)]">
<div v-if="orphanSlotDays.length" class="flex items-start gap-2 mb-4 p-3 rounded-[6px] bg-[var(--yellow-50)] border border-[var(--yellow-200)] text-sm text-[var(--yellow-800)]">
<i class="pi pi-exclamation-triangle mt-0.5 shrink-0" />
<span>
slots configurados para <b>{{ orphanSlotDays.join(', ') }}</b>, mas esses dias não estão mais na sua jornada.
@@ -1130,7 +1139,7 @@ const jornadaEndDate = computed({
</div>
<!-- Toggle ativo -->
<div class="flex items-center justify-between mb-5 p-4 rounded-2xl bg-[var(--surface-ground)]">
<div class="flex items-center justify-between mb-5 p-4 rounded-[6px] bg-[var(--surface-ground)]">
<div>
<div class="font-medium">Permitir que pacientes agendem online</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">
@@ -1172,7 +1181,7 @@ const jornadaEndDate = computed({
<template v-if="previewDay != null">
<!-- Área cinza: ações rápidas + slots -->
<div class="mx-3 mb-2 p-3 rounded-xl bg-[var(--surface-ground)]">
<div class="mx-3 mb-2 p-3 rounded-[6px] bg-[var(--surface-ground)]">
<div v-if="(slotsByDay[previewDay] || []).length === 0" class="text-sm text-[var(--text-color-secondary)] py-1">
Nenhum slot disponível para este dia. Configure a jornada primeiro.
</div>
@@ -1242,7 +1251,7 @@ const jornadaEndDate = computed({
<!-- COLUNA DIREITA: PREVIEW -->
<div class="xl:w-[42%] xl:sticky xl:top-4 xl:self-start">
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden shadow-sm">
<div class="rounded-[6px] border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden shadow-sm">
<!-- Header do preview -->
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border)]">
@@ -1307,7 +1316,7 @@ const jornadaEndDate = computed({
<style scoped>
/* ── Cards ─────────────────────────────────────────────────── */
.cfg-card {
border-radius: 1.25rem;
border-radius: 6px;
border: 1px solid var(--surface-border);
background: var(--surface-card);
overflow: hidden;
@@ -1508,4 +1517,52 @@ const jornadaEndDate = computed({
.toggle-switch--on .toggle-switch__thumb {
transform: translateX(1.25rem);
}
</style>
/* ── Subheader de seção ──────────────────────────────── */
.cfg-subheader {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.875rem 1rem;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--primary-color, #6366f1) 30%, transparent);
background: linear-gradient(
135deg,
color-mix(in srgb, var(--primary-color, #6366f1) 12%, var(--surface-card)) 0%,
color-mix(in srgb, var(--primary-color, #6366f1) 4%, var(--surface-card)) 60%,
var(--surface-card) 100%
);
overflow: hidden;
position: relative;
}
/* Brilho sutil no canto */
.cfg-subheader::before {
content: '';
position: absolute;
top: -20px; right: -20px;
width: 80px; height: 80px;
border-radius: 50%;
background: color-mix(in srgb, var(--primary-color, #6366f1) 15%, transparent);
filter: blur(20px);
pointer-events: none;
}
.cfg-subheader__icon {
display: grid; place-items: center;
width: 2rem; height: 2rem;
border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color, #6366f1) 20%, transparent);
color: var(--primary-color, #6366f1);
font-size: 0.85rem;
}
.cfg-subheader__title {
font-size: 0.95rem;
font-weight: 700;
color: var(--primary-color, #6366f1);
letter-spacing: -0.01em;
}
.cfg-subheader__sub {
font-size: 0.75rem;
color: var(--text-color-secondary);
opacity: 0.85;
}
</style>
@@ -17,7 +17,7 @@ const hasLinkPersonalizado = computed(() => entitlements.can('agendador.link_per
// ── Estado ─────────────────────────────────────────────────────
const loading = ref(true)
const ownerId = ref(null)
const expandedCard = ref(null)
const expandedCard = ref(new Set())
const savingCard = ref(null)
// ── Upload de imagens ────────────────────────────────────────────
@@ -62,8 +62,8 @@ async function onFileSelected (event, field) {
// ── Expand / Collapse all ────────────────────────────────────────
const CARDS = ['identidade', 'perfil', 'fluxo', 'pagamento', 'triagem', 'textos']
function expandAll () { expandedCard.value = CARDS[0] } // abre o primeiro como ponto de entrada
function collapseAll () { expandedCard.value = null }
function expandAll () { expandedCard.value = new Set(CARDS) }
function collapseAll () { expandedCard.value = new Set() }
// ── Defaults ───────────────────────────────────────────────────
const DEFAULT_CFG = {
@@ -405,7 +405,7 @@ async function saveCard (cardKey) {
)
toast.add({ severity: 'success', summary: 'Salvo', life: 2500 })
expandedCard.value = null
expandedCard.value = new Set()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e.message, life: 4000 })
} finally {
@@ -474,7 +474,10 @@ function buildPayload (cardKey) {
}
function toggleCard (key) {
expandedCard.value = expandedCard.value === key ? null : key
const s = new Set(expandedCard.value)
if (s.has(key)) s.delete(key)
else s.add(key)
expandedCard.value = s
}
onMounted(load)
@@ -490,43 +493,27 @@ onMounted(load)
<template v-else>
<!-- HEADER SECUNDÁRIO -->
<div class="flex items-center justify-between gap-3 px-1">
<div>
<div class="text-base font-semibold">Configurações do Agendador</div>
<div class="text-xs text-surface-400 mt-0.5">Personalize a aparência, fluxo e comportamento do seu agendador público.</div>
<!-- Subheader -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-calendar-clock" /></div>
<div class="min-w-0">
<div class="cfg-subheader__title">Agendador Online</div>
<div class="cfg-subheader__sub">Personalize a aparência, fluxo e comportamento do seu agendador público</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<Button
size="small"
icon="pi pi-arrows-v"
label="Expandir tudo"
severity="secondary"
outlined
class="rounded-full"
@click="expandAll"
/>
<Button
size="small"
icon="pi pi-minus"
label="Contrair"
severity="secondary"
outlined
class="rounded-full"
@click="collapseAll"
/>
<div class="cfg-subheader__actions">
<Button size="small" icon="pi pi-arrows-v" label="Expandir" severity="secondary" outlined class="rounded-full" @click="expandAll" />
<Button size="small" icon="pi pi-minus" label="Contrair" severity="secondary" outlined class="rounded-full" @click="collapseAll" />
</div>
</div>
<!-- CARD: STATUS / ATIVAR -->
<Card>
<template #content>
<div class="agd-card">
<div class="flex flex-col gap-4">
<!-- Cabeçalho PRO -->
<div class="flex items-start justify-between gap-4">
<div class="flex items-center gap-3">
<div class="grid place-items-center w-11 h-11 rounded-2xl shrink-0"
<div class="grid place-items-center w-11 h-11 rounded-[6px] shrink-0"
:class="cfg.ativo ? 'bg-green-100 dark:bg-green-900/30 text-green-600' : 'bg-surface-100 text-surface-400'">
<i class="pi pi-calendar-clock text-xl" />
</div>
@@ -586,9 +573,9 @@ onMounted(load)
</div>
<!-- Link personalizado bloqueado -->
<div v-if="!hasLinkPersonalizado" class="mt-3 flex items-center gap-3 p-3 rounded-xl border border-dashed
<div v-if="!hasLinkPersonalizado" class="mt-3 flex items-center gap-3 p-3 rounded-[6px] border border-dashed
border-surface-300 dark:border-surface-600 bg-surface-50 dark:bg-surface-800/50">
<div class="grid place-items-center w-9 h-9 rounded-xl bg-amber-100 dark:bg-amber-900/30 text-amber-500 shrink-0">
<div class="grid place-items-center w-9 h-9 rounded-[6px] bg-amber-100 dark:bg-amber-900/30 text-amber-500 shrink-0">
<i class="pi pi-lock text-base" />
</div>
<div class="flex-1 min-w-0">
@@ -617,34 +604,27 @@ onMounted(load)
Você controla quem pode agendar e quais horários ficam disponíveis.
</div>
</div>
</template>
</Card>
</div>
<!-- CARD: IDENTIDADE VISUAL -->
<Card class="overflow-hidden">
<template #content>
<!-- Cabeçalho do card -->
<button
type="button"
class="w-full flex items-center justify-between gap-3 text-left"
@click="toggleCard('identidade')"
>
<div class="flex items-center gap-3">
<div class="grid place-items-center w-9 h-9 rounded-xl bg-purple-100 dark:bg-purple-900/30 text-purple-600 shrink-0">
<i class="pi pi-palette" />
</div>
<div>
<div class="font-semibold leading-none">Identidade Visual</div>
<div v-if="expandedCard !== 'identidade'" class="text-xs text-surface-400 mt-1">{{ resumoIdentidade }}</div>
</div>
</div>
<i class="pi transition-transform duration-200 text-surface-400 shrink-0"
:class="expandedCard === 'identidade' ? 'pi-angle-up' : 'pi-angle-down'" />
</button>
<div class="agd-accordion" :class="{ 'agd-accordion--open': expandedCard.has('identidade') }">
<button
type="button"
class="agd-accordion__header"
@click="toggleCard('identidade')"
>
<div class="agd-accordion__icon bg-purple-100 dark:bg-purple-900/30 text-purple-600">
<i class="pi pi-palette" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="agd-accordion__title">Identidade Visual</div>
<div v-if="!expandedCard.has('identidade')" class="agd-accordion__summary">{{ resumoIdentidade }}</div>
</div>
<i class="pi agd-accordion__chevron"
:class="expandedCard.has('identidade') ? 'pi-chevron-up' : 'pi-chevron-down'" />
</button>
<!-- Conteúdo expandido -->
<template v-if="expandedCard === 'identidade'">
<Divider />
<div v-if="expandedCard.has('identidade')" class="agd-accordion__body">
<div class="flex flex-col gap-5">
<!-- Nome de exibição (aqui pois é parte da identidade) -->
@@ -660,7 +640,7 @@ onMounted(load)
<div class="flex items-center gap-3">
<ColorPicker v-model="cfg.cor_primaria" format="hex" />
<InputText v-model="cfg.cor_primaria" placeholder="#4b6bff" class="w-32 font-mono" maxlength="7" />
<div class="w-10 h-10 rounded-xl border border-surface-200 shrink-0"
<div class="w-10 h-10 rounded-[6px] border border-surface-200 shrink-0"
:style="{ background: cfg.cor_primaria }" />
</div>
<div class="text-xs text-surface-400 mt-1">Botões e destaques do agendador.</div>
@@ -708,7 +688,7 @@ onMounted(load)
@change="e => onFileSelected(e, 'header')" />
</div>
<InputText v-model="cfg.imagem_header_url" placeholder="ou cole uma URL pública..." class="w-full text-xs" />
<div v-if="cfg.imagem_header_url" class="rounded-xl overflow-hidden h-20 w-full">
<div v-if="cfg.imagem_header_url" class="rounded-[6px] overflow-hidden h-20 w-full">
<img :src="cfg.imagem_header_url" alt="Header" class="w-full h-full object-cover" />
</div>
</div>
@@ -728,7 +708,7 @@ onMounted(load)
@change="e => onFileSelected(e, 'fundo')" />
</div>
<InputText v-model="cfg.imagem_fundo_url" placeholder="ou cole uma URL pública..." class="w-full text-xs" />
<div v-if="cfg.imagem_fundo_url" class="rounded-xl overflow-hidden h-28 w-full">
<div v-if="cfg.imagem_fundo_url" class="rounded-[6px] overflow-hidden h-28 w-full">
<img :src="cfg.imagem_fundo_url" alt="Fundo" class="w-full h-full object-cover" />
</div>
</div>
@@ -744,33 +724,28 @@ onMounted(load)
/>
</div>
</div>
</template>
</template>
</Card>
</div>
</div>
<!-- CARD: PERFIL PÚBLICO -->
<Card>
<template #content>
<button
type="button"
class="w-full flex items-center justify-between gap-3 text-left"
@click="toggleCard('perfil')"
>
<div class="flex items-center gap-3">
<div class="grid place-items-center w-9 h-9 rounded-xl bg-blue-100 dark:bg-blue-900/30 text-blue-600 shrink-0">
<i class="pi pi-map-marker" />
</div>
<div>
<div class="font-semibold leading-none">Perfil Público</div>
<div v-if="expandedCard !== 'perfil'" class="text-xs text-surface-400 mt-1">{{ resumoPerfil }}</div>
</div>
</div>
<i class="pi transition-transform duration-200 text-surface-400 shrink-0"
:class="expandedCard === 'perfil' ? 'pi-angle-up' : 'pi-angle-down'" />
</button>
<div class="agd-accordion" :class="{ 'agd-accordion--open': expandedCard.has('perfil') }">
<button
type="button"
class="agd-accordion__header"
@click="toggleCard('perfil')"
>
<div class="agd-accordion__icon bg-blue-100 dark:bg-blue-900/30 text-blue-600">
<i class="pi pi-map-marker" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="agd-accordion__title">Perfil Público</div>
<div v-if="!expandedCard.has('perfil')" class="agd-accordion__summary">{{ resumoPerfil }}</div>
</div>
<i class="pi agd-accordion__chevron"
:class="expandedCard.has('perfil') ? 'pi-chevron-up' : 'pi-chevron-down'" />
</button>
<template v-if="expandedCard === 'perfil'">
<Divider />
<div v-if="expandedCard.has('perfil')" class="agd-accordion__body">
<div class="flex flex-col gap-5">
<!-- Endereço -->
@@ -780,7 +755,7 @@ onMounted(load)
</div>
<!-- Botão Como Chegar -->
<div class="flex items-center justify-between p-3 bg-surface-50 dark:bg-surface-800 rounded-xl">
<div class="flex items-center justify-between p-3 bg-surface-50 dark:bg-surface-800 rounded-[6px]">
<div>
<div class="font-medium text-sm">Botão "Como chegar"</div>
<div class="text-xs text-surface-400 mt-0.5">Exibe um botão que abre o mapa para o paciente.</div>
@@ -804,33 +779,28 @@ onMounted(load)
/>
</div>
</div>
</template>
</template>
</Card>
</div>
</div>
<!-- CARD: FLUXO DE AGENDAMENTO -->
<Card>
<template #content>
<button
type="button"
class="w-full flex items-center justify-between gap-3 text-left"
@click="toggleCard('fluxo')"
>
<div class="flex items-center gap-3">
<div class="grid place-items-center w-9 h-9 rounded-xl bg-cyan-100 dark:bg-cyan-900/30 text-cyan-600 shrink-0">
<i class="pi pi-sitemap" />
</div>
<div>
<div class="font-semibold leading-none">Fluxo de Agendamento</div>
<div v-if="expandedCard !== 'fluxo'" class="text-xs text-surface-400 mt-1">{{ resumoFluxo }}</div>
</div>
</div>
<i class="pi transition-transform duration-200 text-surface-400 shrink-0"
:class="expandedCard === 'fluxo' ? 'pi-angle-up' : 'pi-angle-down'" />
</button>
<div class="agd-accordion" :class="{ 'agd-accordion--open': expandedCard.has('fluxo') }">
<button
type="button"
class="agd-accordion__header"
@click="toggleCard('fluxo')"
>
<div class="agd-accordion__icon bg-cyan-100 dark:bg-cyan-900/30 text-cyan-600">
<i class="pi pi-sitemap" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="agd-accordion__title">Fluxo de Agendamento</div>
<div v-if="!expandedCard.has('fluxo')" class="agd-accordion__summary">{{ resumoFluxo }}</div>
</div>
<i class="pi agd-accordion__chevron"
:class="expandedCard.has('fluxo') ? 'pi-chevron-up' : 'pi-chevron-down'" />
</button>
<template v-if="expandedCard === 'fluxo'">
<Divider />
<div v-if="expandedCard.has('fluxo')" class="agd-accordion__body">
<div class="flex flex-col gap-5">
<!-- Modo de aprovação -->
@@ -840,7 +810,7 @@ onMounted(load)
<div
v-for="opt in modoOptions"
:key="opt.value"
class="flex items-center gap-3 p-3 rounded-xl border cursor-pointer transition"
class="flex items-center gap-3 p-3 rounded-[6px] border cursor-pointer transition"
:class="cfg.modo_aprovacao === opt.value
? 'border-primary bg-primary/5 dark:bg-primary/10'
: 'border-surface-200 dark:border-surface-700 hover:border-surface-300'"
@@ -957,33 +927,28 @@ onMounted(load)
/>
</div>
</div>
</template>
</template>
</Card>
</div>
</div>
<!-- CARD: PAGAMENTO -->
<Card>
<template #content>
<button
type="button"
class="w-full flex items-center justify-between gap-3 text-left"
@click="toggleCard('pagamento')"
>
<div class="flex items-center gap-3">
<div class="grid place-items-center w-9 h-9 rounded-xl bg-green-100 dark:bg-green-900/30 text-green-600 shrink-0">
<i class="pi pi-credit-card" />
</div>
<div>
<div class="font-semibold leading-none">Pagamento</div>
<div v-if="expandedCard !== 'pagamento'" class="text-xs text-surface-400 mt-1">{{ resumoPagamento }}</div>
</div>
</div>
<i class="pi transition-transform duration-200 text-surface-400 shrink-0"
:class="expandedCard === 'pagamento' ? 'pi-angle-up' : 'pi-angle-down'" />
</button>
<div class="agd-accordion" :class="{ 'agd-accordion--open': expandedCard.has('pagamento') }">
<button
type="button"
class="agd-accordion__header"
@click="toggleCard('pagamento')"
>
<div class="agd-accordion__icon bg-green-100 dark:bg-green-900/30 text-green-600">
<i class="pi pi-credit-card" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="agd-accordion__title">Pagamento</div>
<div v-if="!expandedCard.has('pagamento')" class="agd-accordion__summary">{{ resumoPagamento }}</div>
</div>
<i class="pi agd-accordion__chevron"
:class="expandedCard.has('pagamento') ? 'pi-chevron-up' : 'pi-chevron-down'" />
</button>
<template v-if="expandedCard === 'pagamento'">
<Divider />
<div v-if="expandedCard.has('pagamento')" class="agd-accordion__body">
<div class="flex flex-col gap-5">
<!-- Modo de pagamento -->
@@ -994,13 +959,13 @@ onMounted(load)
v-for="modo in modosPagamento"
:key="modo.value"
type="button"
class="flex items-center gap-3 p-3 rounded-xl border text-left transition"
class="flex items-center gap-3 p-3 rounded-[6px] border text-left transition"
:class="cfg.pagamento_modo === modo.value
? 'border-primary bg-primary/5 ring-1 ring-primary/30'
: 'border-surface-border bg-surface-50 dark:bg-surface-800 hover:bg-surface-100 dark:hover:bg-surface-700'"
@click="cfg.pagamento_modo = modo.value"
>
<div class="grid place-items-center w-9 h-9 rounded-lg shrink-0"
<div class="grid place-items-center w-9 h-9 rounded-[6px] shrink-0"
:class="cfg.pagamento_modo === modo.value ? 'bg-primary/15 text-primary' : 'bg-surface-200 dark:bg-surface-700 text-surface-400'">
<i :class="['pi', modo.icon]" />
</div>
@@ -1023,7 +988,7 @@ onMounted(load)
<RouterLink to="/configuracoes/pagamento" class="underline">Configurações Pagamento</RouterLink>.
</p>
<div v-if="!algumMetodoConfigurado" class="rounded-xl border border-orange-200 bg-orange-50 dark:bg-orange-900/20 p-3 text-sm text-orange-700 dark:text-orange-300">
<div v-if="!algumMetodoConfigurado" class="rounded-[6px] border border-orange-200 bg-orange-50 dark:bg-orange-900/20 p-3 text-sm text-orange-700 dark:text-orange-300">
<i class="pi pi-exclamation-triangle mr-1" />
Nenhuma forma de pagamento configurada ainda.
<RouterLink to="/configuracoes/pagamento" class="underline font-medium ml-1">Configurar agora</RouterLink>
@@ -1033,7 +998,7 @@ onMounted(load)
<label
v-for="m in metodosDisponiveis"
:key="m.key"
class="flex items-center gap-3 p-3 rounded-xl border cursor-pointer transition select-none"
class="flex items-center gap-3 p-3 rounded-[6px] border cursor-pointer transition select-none"
:class="[
!m.ativo ? 'opacity-40 cursor-not-allowed border-surface-border bg-surface-50 dark:bg-surface-800' :
isMetodoVisivel(m.key) ? 'border-primary bg-primary/5 ring-1 ring-primary/20' : 'border-surface-border bg-surface-50 dark:bg-surface-800 hover:bg-surface-100 dark:hover:bg-surface-700'
@@ -1125,39 +1090,34 @@ onMounted(load)
/>
</div>
</div>
</template>
</template>
</Card>
</div>
</div>
<!-- CARD: TRIAGEM & CONFORMIDADE -->
<Card>
<template #content>
<button
type="button"
class="w-full flex items-center justify-between gap-3 text-left"
@click="toggleCard('triagem')"
>
<div class="flex items-center gap-3">
<div class="grid place-items-center w-9 h-9 rounded-xl bg-orange-100 dark:bg-orange-900/30 text-orange-600 shrink-0">
<i class="pi pi-shield" />
</div>
<div>
<div class="font-semibold leading-none">Triagem & Conformidade</div>
<div v-if="expandedCard !== 'triagem'" class="text-xs text-surface-400 mt-1">{{ resumoTriagem }}</div>
</div>
</div>
<i class="pi transition-transform duration-200 text-surface-400 shrink-0"
:class="expandedCard === 'triagem' ? 'pi-angle-up' : 'pi-angle-down'" />
</button>
<div class="agd-accordion" :class="{ 'agd-accordion--open': expandedCard.has('triagem') }">
<button
type="button"
class="agd-accordion__header"
@click="toggleCard('triagem')"
>
<div class="agd-accordion__icon bg-orange-100 dark:bg-orange-900/30 text-orange-600">
<i class="pi pi-shield" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="agd-accordion__title">Triagem & Conformidade</div>
<div v-if="!expandedCard.has('triagem')" class="agd-accordion__summary">{{ resumoTriagem }}</div>
</div>
<i class="pi agd-accordion__chevron"
:class="expandedCard.has('triagem') ? 'pi-chevron-up' : 'pi-chevron-down'" />
</button>
<template v-if="expandedCard === 'triagem'">
<Divider />
<div v-if="expandedCard.has('triagem')" class="agd-accordion__body">
<div class="flex flex-col gap-4">
<div class="text-sm font-semibold text-surface-600">Campos extras no formulário</div>
<!-- Triagem: motivo -->
<div class="flex items-center justify-between p-3 bg-surface-50 dark:bg-surface-800 rounded-xl">
<div class="flex items-center justify-between p-3 bg-surface-50 dark:bg-surface-800 rounded-[6px]">
<div>
<div class="font-medium text-sm">Motivo da consulta</div>
<div class="text-xs text-surface-400 mt-0.5">Campo de texto livre opcional para o paciente informar o motivo.</div>
@@ -1166,7 +1126,7 @@ onMounted(load)
</div>
<!-- Triagem: como conheceu -->
<div class="flex items-center justify-between p-3 bg-surface-50 dark:bg-surface-800 rounded-xl">
<div class="flex items-center justify-between p-3 bg-surface-50 dark:bg-surface-800 rounded-[6px]">
<div>
<div class="font-medium text-sm">Como nos conheceu?</div>
<div class="text-xs text-surface-400 mt-0.5">Pergunta de origem (indicação, redes sociais, busca).</div>
@@ -1179,7 +1139,7 @@ onMounted(load)
<div class="text-sm font-semibold text-surface-600">Segurança & LGPD</div>
<!-- Verificação de email -->
<div class="flex items-center justify-between p-3 bg-surface-50 dark:bg-surface-800 rounded-xl">
<div class="flex items-center justify-between p-3 bg-surface-50 dark:bg-surface-800 rounded-[6px]">
<div>
<div class="font-medium text-sm">Verificação de e-mail</div>
<div class="text-xs text-surface-400 mt-0.5">
@@ -1190,7 +1150,7 @@ onMounted(load)
</div>
<!-- Aceite LGPD -->
<div class="flex items-center justify-between p-3 bg-surface-50 dark:bg-surface-800 rounded-xl">
<div class="flex items-center justify-between p-3 bg-surface-50 dark:bg-surface-800 rounded-[6px]">
<div>
<div class="font-medium text-sm">Aceite obrigatório de termos (LGPD)</div>
<div class="text-xs text-surface-400 mt-0.5">
@@ -1209,33 +1169,28 @@ onMounted(load)
/>
</div>
</div>
</template>
</template>
</Card>
</div>
</div>
<!-- CARD: TEXTOS DA JORNADA -->
<Card>
<template #content>
<button
type="button"
class="w-full flex items-center justify-between gap-3 text-left"
@click="toggleCard('textos')"
>
<div class="flex items-center gap-3">
<div class="grid place-items-center w-9 h-9 rounded-xl bg-pink-100 dark:bg-pink-900/30 text-pink-600 shrink-0">
<i class="pi pi-file-edit" />
</div>
<div>
<div class="font-semibold leading-none">Textos da Jornada</div>
<div v-if="expandedCard !== 'textos'" class="text-xs text-surface-400 mt-1">{{ resumoTextos }}</div>
</div>
</div>
<i class="pi transition-transform duration-200 text-surface-400 shrink-0"
:class="expandedCard === 'textos' ? 'pi-angle-up' : 'pi-angle-down'" />
</button>
<div class="agd-accordion" :class="{ 'agd-accordion--open': expandedCard.has('textos') }">
<button
type="button"
class="agd-accordion__header"
@click="toggleCard('textos')"
>
<div class="agd-accordion__icon bg-pink-100 dark:bg-pink-900/30 text-pink-600">
<i class="pi pi-file-edit" />
</div>
<div class="min-w-0 flex-1 text-left">
<div class="agd-accordion__title">Textos da Jornada</div>
<div v-if="!expandedCard.has('textos')" class="agd-accordion__summary">{{ resumoTextos }}</div>
</div>
<i class="pi agd-accordion__chevron"
:class="expandedCard.has('textos') ? 'pi-chevron-up' : 'pi-chevron-down'" />
</button>
<template v-if="expandedCard === 'textos'">
<Divider />
<div v-if="expandedCard.has('textos')" class="agd-accordion__body">
<div class="flex flex-col gap-5">
<!-- Mensagem de boas-vindas -->
@@ -1289,28 +1244,119 @@ onMounted(load)
/>
</div>
</div>
</template>
</template>
</Card>
</div>
</div>
</template>
</div>
</template>
<style scoped>
/* ── Upload zone ──────────────────────────────────── */
.agd-upload-zone {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.75rem 1rem;
border: 1.5px dashed var(--surface-border);
border-radius: 0.875rem;
border-radius: 6px;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
background: var(--surface-ground);
}
.agd-upload-zone:hover {
border-color: var(--p-primary-500, #6366f1);
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 5%, transparent);
border-color: var(--primary-color, #6366f1);
background: color-mix(in srgb, var(--primary-color, #6366f1) 5%, transparent);
}
</style>
/* ── Subheader degradê ────────────────────────────── */
.cfg-subheader {
display: flex; align-items: center; gap: 0.65rem;
padding: 0.875rem 1rem; border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--primary-color, #6366f1) 30%, transparent);
background: linear-gradient(135deg,
color-mix(in srgb, var(--primary-color, #6366f1) 12%, var(--surface-card)) 0%,
color-mix(in srgb, var(--primary-color, #6366f1) 4%, var(--surface-card)) 60%,
var(--surface-card) 100%);
position: relative; overflow: hidden;
}
.cfg-subheader::before {
content: ''; position: absolute;
top: -20px; right: -20px;
width: 80px; height: 80px; border-radius: 50%;
background: color-mix(in srgb, var(--primary-color, #6366f1) 15%, transparent);
filter: blur(20px); pointer-events: none;
}
.cfg-subheader__icon {
display: grid; place-items: center;
width: 2rem; height: 2rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color, #6366f1) 20%, transparent);
color: var(--primary-color, #6366f1); font-size: 0.85rem;
}
.cfg-subheader__title { font-size: 0.95rem; font-weight: 700; letter-spacing: -0.01em; color: var(--primary-color, #6366f1); }
.cfg-subheader__sub { font-size: 0.75rem; color: var(--text-color-secondary); opacity: 0.85; }
.cfg-subheader__actions { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; flex-shrink: 0; position: relative; z-index: 1; }
/* ── Card status (sem accordion) ─────────────────── */
.agd-card {
border: 1px solid var(--surface-border);
border-radius: 6px;
background: var(--surface-card);
padding: 1rem;
}
/* ── Accordion cards ──────────────────────────────── */
.agd-accordion {
border: 1px solid var(--surface-border);
border-radius: 6px;
background: var(--surface-card);
overflow: hidden;
transition: border-color 0.15s;
}
.agd-accordion--open {
border-color: color-mix(in srgb, var(--primary-color, #6366f1) 35%, transparent);
}
.agd-accordion__header {
display: flex; align-items: center; gap: 0.75rem;
width: 100%; padding: 0.875rem 1rem;
background: transparent; border: none; cursor: pointer;
transition: background 0.12s;
text-align: left;
}
.agd-accordion__header:hover { background: var(--surface-hover); }
.agd-accordion--open .agd-accordion__header {
background: color-mix(in srgb, var(--primary-color, #6366f1) 4%, var(--surface-card));
border-bottom: 1px solid var(--surface-border);
}
.agd-accordion__icon {
display: grid; place-items: center;
width: 2rem; height: 2rem; border-radius: 6px; flex-shrink: 0;
}
.agd-accordion__title {
font-size: 0.88rem; font-weight: 700;
color: var(--text-color);
}
.agd-accordion--open .agd-accordion__title {
color: var(--primary-color, #6366f1);
}
.agd-accordion__summary {
font-size: 0.72rem; color: var(--text-color-secondary);
opacity: 0.75; margin-top: 1px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.agd-accordion__chevron {
font-size: 0.7rem; color: var(--text-color-secondary);
opacity: 0.5; flex-shrink: 0;
transition: transform 0.2s;
}
.agd-accordion--open .agd-accordion__chevron {
color: var(--primary-color, #6366f1); opacity: 0.8;
}
.agd-accordion__body {
padding: 1rem;
display: flex; flex-direction: column; gap: 1.25rem;
}
</style>
@@ -239,32 +239,19 @@ onMounted(async () => {
<template>
<Toast />
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3">
<!-- Header -->
<Card>
<template #content>
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-3">
<div class="cfg-icon-box">
<i class="pi pi-id-card text-lg" />
</div>
<div>
<div class="text-900 font-semibold text-lg">Convênios</div>
<div class="text-600 text-sm">
Cadastre os convênios que você atende e seus procedimentos com valores de tabela.
</div>
</div>
</div>
<Button
label="Novo convênio"
icon="pi pi-plus"
:disabled="pageLoading || addingNew"
@click="addingNew = true; cancelEdit()"
/>
</div>
</template>
</Card>
<!-- Subheader -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-id-card" /></div>
<div class="min-w-0">
<div class="cfg-subheader__title">Convênios</div>
<div class="cfg-subheader__sub">Convênios e planos de saúde que você atende</div>
</div>
<div class="cfg-subheader__actions">
<Button label="Novo convênio" icon="pi pi-plus" size="small" :disabled="pageLoading || addingNew" class="rounded-full" @click="addingNew = true; cancelEdit()" />
</div>
</div>
<!-- Loading -->
<div v-if="pageLoading || loading" class="flex justify-center py-10">
@@ -273,15 +260,13 @@ onMounted(async () => {
<template v-else>
<!-- Formulário novo convênio -->
<Card v-if="addingNew">
<template #title>
<div class="flex items-center gap-2">
<i class="pi pi-plus-circle text-primary-500" />
<span>Novo convênio</span>
</div>
</template>
<template #content>
<!-- Form novo convênio -->
<div v-if="addingNew" class="cfg-wrap">
<div class="cfg-wrap__head">
<div class="cfg-wrap__icon"><i class="pi pi-plus" /></div>
<span class="cfg-wrap__title">Novo convênio</span>
</div>
<div class="cfg-wrap__body">
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12 sm:col-span-6">
<FloatLabel variant="on">
@@ -296,30 +281,30 @@ onMounted(async () => {
</FloatLabel>
</div>
</div>
<div class="flex gap-2 justify-end mt-4">
<Button label="Cancelar" severity="secondary" outlined @click="addingNew = false; newForm = emptyForm()" />
<Button label="Salvar" icon="pi pi-check" :loading="savingNew" @click="saveNew" />
<div class="flex gap-2 justify-end mt-3">
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" @click="addingNew = false; newForm = emptyForm()" />
<Button label="Salvar" icon="pi pi-check" class="rounded-full" :loading="savingNew" @click="saveNew" />
</div>
</template>
</Card>
</div>
</div>
<!-- Lista vazia -->
<Card v-if="!plans.length && !addingNew">
<template #content>
<div class="text-center py-6 text-color-secondary">
<i class="pi pi-id-card text-4xl opacity-30 mb-3 block" />
<div class="font-medium mb-1">Nenhum convênio cadastrado</div>
<div class="text-sm">Clique em "Novo convênio" para começar.</div>
</div>
</template>
</Card>
<div v-if="!plans.length && !addingNew" class="cfg-empty">
<i class="pi pi-id-card text-3xl opacity-25" />
<div class="text-sm font-medium">Nenhum convênio cadastrado</div>
<div class="text-xs opacity-70">Clique em "Novo convênio" para começar.</div>
</div>
<!-- Lista de convênios -->
<Card v-for="plan in plans" :key="plan.id" :class="{ 'opacity-60': !plan.active }">
<template #content>
<!-- Modo edição do plano -->
<template v-if="editingId === plan.id">
<div
v-for="plan in plans"
:key="plan.id"
class="cfg-wrap"
:class="{ 'opacity-60': !plan.active }"
>
<!-- Modo edição do plano -->
<template v-if="editingId === plan.id">
<div class="cfg-wrap__body">
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12 sm:col-span-6">
<FloatLabel variant="on">
@@ -334,169 +319,138 @@ onMounted(async () => {
</FloatLabel>
</div>
</div>
<div class="flex gap-2 justify-end mt-4">
<Button label="Cancelar" severity="secondary" outlined @click="cancelEdit" />
<Button label="Salvar" icon="pi pi-check" :loading="savingEdit" @click="saveEdit" />
<div class="flex gap-2 justify-end mt-3">
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" @click="cancelEdit" />
<Button label="Salvar" icon="pi pi-check" class="rounded-full" :loading="savingEdit" @click="saveEdit" />
</div>
</template>
</div>
</template>
<!-- Modo leitura -->
<template v-else>
<!-- Cabeçalho do plano -->
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-3 min-w-0">
<div class="cfg-icon-box-sm shrink-0">
<i class="pi pi-id-card" />
</div>
<div class="min-w-0">
<div class="font-semibold text-900">{{ plan.name }}</div>
<div v-if="plan.notes" class="text-sm text-color-secondary italic truncate">{{ plan.notes }}</div>
</div>
</div>
<div class="flex items-center gap-2 shrink-0 flex-wrap">
<Tag :value="plan.active ? 'Ativo' : 'Inativo'" :severity="plan.active ? 'success' : 'secondary'" />
<Button
:label="`Procedimentos (${totalProcedimentos(plan)})`"
:icon="expandedPlanId === plan.id ? 'pi pi-chevron-up' : 'pi pi-chevron-down'"
severity="secondary"
outlined
size="small"
@click="expandedPlanId === plan.id ? (expandedPlanId = null, addingServicePlanId = null) : (expandedPlanId = plan.id, addingServicePlanId = null)"
/>
<Button
:icon="plan.active ? 'pi pi-eye-slash' : 'pi pi-eye'"
:severity="plan.active ? 'secondary' : 'success'"
outlined
size="small"
v-tooltip.top="plan.active ? 'Desativar' : 'Ativar'"
@click="togglePlan(plan)"
/>
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" v-tooltip.top="'Editar'" @click="startEdit(plan)" />
<Button icon="pi pi-trash" severity="danger" outlined size="small" v-tooltip.top="'Desativar'" @click="removePlan(plan.id)" />
<!-- Modo leitura -->
<template v-else>
<!-- Cabeçalho do plano -->
<div class="cnv-plan-head">
<div class="flex items-center gap-2.5 min-w-0 flex-1">
<div class="cfg-wrap__icon shrink-0"><i class="pi pi-id-card" /></div>
<div class="min-w-0">
<div class="font-semibold text-sm">{{ plan.name }}</div>
<div v-if="plan.notes" class="text-xs text-[var(--text-color-secondary)] opacity-70 truncate">{{ plan.notes }}</div>
</div>
</div>
<div class="flex items-center gap-1.5 shrink-0 flex-wrap">
<Tag :value="plan.active ? 'Ativo' : 'Inativo'" :severity="plan.active ? 'success' : 'secondary'" />
<Button
:label="`Procedimentos (${totalProcedimentos(plan)})`"
:icon="expandedPlanId === plan.id ? 'pi pi-chevron-up' : 'pi pi-chevron-down'"
severity="secondary" outlined size="small" class="rounded-full"
@click="togglePanel(plan.id)"
/>
<Button
:icon="plan.active ? 'pi pi-eye-slash' : 'pi pi-eye'"
:severity="plan.active ? 'secondary' : 'success'"
outlined size="small"
v-tooltip.top="plan.active ? 'Desativar' : 'Ativar'"
@click="togglePlan(plan)"
/>
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" v-tooltip.top="'Editar'" @click="startEdit(plan)" />
<Button icon="pi pi-trash" severity="danger" outlined size="small" v-tooltip.top="'Remover'" @click="removePlan(plan.id)" />
</div>
</div>
<!-- Painel expansível: procedimentos -->
<div v-if="expandedPlanId === plan.id" class="mt-4 border-t border-surface pt-4">
<!-- Painel procedimentos expandível -->
<div v-if="expandedPlanId === plan.id" class="cnv-procedures">
<!-- Lista de procedimentos (ativos e inativos) -->
<div v-if="plan.insurance_plan_services?.length" class="mb-3 flex flex-col gap-1">
<template v-for="ps in plan.insurance_plan_services" :key="ps.id">
<!-- Lista de procedimentos -->
<div v-if="plan.insurance_plan_services?.length" class="cnv-proc-list">
<template v-for="ps in plan.insurance_plan_services" :key="ps.id">
<!-- Modo edição inline do procedimento -->
<div v-if="editingServiceId === ps.id" class="flex flex-wrap gap-2 items-end py-2 border-b border-surface">
<div class="flex-1 min-w-[140px]">
<label class="text-xs text-color-secondary mb-1 block">Nome</label>
<!-- Edição inline do procedimento -->
<div v-if="editingServiceId === ps.id" class="cnv-proc-edit">
<div class="grid grid-cols-12 gap-2 flex-1">
<div class="col-span-12 sm:col-span-6">
<label class="cnv-label">Nome</label>
<InputText v-model="editServiceForm.name" class="w-full" size="small" />
</div>
<div class="w-36">
<label class="text-xs text-color-secondary mb-1 block">Valor (R$)</label>
<InputNumber
v-model="editServiceForm.value"
mode="currency" currency="BRL" locale="pt-BR"
:min="0" :minFractionDigits="2"
class="w-full" size="small"
/>
</div>
<div class="flex gap-2">
<Button label="Cancelar" severity="secondary" outlined size="small" @click="cancelEditService" />
<Button label="Salvar" icon="pi pi-check" size="small" :loading="savingServiceEdit" @click="saveServiceEdit" />
<div class="col-span-12 sm:col-span-6">
<label class="cnv-label">Valor (R$)</label>
<InputNumber v-model="editServiceForm.value" mode="currency" currency="BRL" locale="pt-BR" :min="0" :minFractionDigits="2" class="w-full" size="small" />
</div>
</div>
<!-- Modo leitura do procedimento -->
<div
v-else
class="flex items-center justify-between gap-2 py-2 border-b border-surface last:border-0"
:class="{ 'opacity-60': !ps.active }"
>
<div class="flex items-center gap-2 min-w-0">
<Tag v-if="!ps.active" value="Inativo" severity="secondary" class="text-xs" />
<span class="text-sm font-medium text-900 truncate">{{ ps.name }}</span>
</div>
<div class="flex items-center gap-3 shrink-0">
<span class="text-sm font-semibold text-primary-500">{{ fmtBRL(ps.value) }}</span>
<Button
:icon="ps.active ? 'pi pi-eye-slash' : 'pi pi-eye'"
:severity="ps.active ? 'secondary' : 'success'"
text size="small"
v-tooltip.top="ps.active ? 'Desativar' : 'Ativar'"
@click="onToggleService(ps)"
/>
<Button
icon="pi pi-pencil"
severity="secondary" text size="small"
v-tooltip.top="'Editar'"
@click="startEditService(ps)"
/>
<Button
icon="pi pi-trash"
severity="danger" text size="small"
v-tooltip.top="'Remover definitivamente'"
@click="deleteService(ps.id)"
/>
</div>
</div>
</template>
</div>
<div v-else-if="addingServicePlanId !== plan.id" class="text-sm text-color-secondary mb-3 italic">
Nenhum procedimento cadastrado.
</div>
<!-- Formulário adicionar procedimento -->
<div v-if="addingServicePlanId === plan.id" class="mt-3">
<!-- Cards de serviços para auto-preencher -->
<div v-if="services.filter(s => s.active).length" class="mb-3">
<div class="text-xs text-color-secondary mb-2">Clique num serviço para pré-preencher:</div>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
<button
v-for="svc in services.filter(s => s.active)"
:key="svc.id"
class="svc-quick-card"
@click="fillFromService(svc)"
>
<span class="svc-quick-name">{{ svc.name }}</span>
<span class="svc-quick-price">{{ fmtBRL(svc.price) }}</span>
</button>
<div class="flex gap-2 justify-end mt-2">
<Button label="Cancelar" severity="secondary" outlined size="small" class="rounded-full" @click="cancelEditService" />
<Button label="Salvar" icon="pi pi-check" size="small" class="rounded-full" :loading="savingServiceEdit" @click="saveServiceEdit" />
</div>
</div>
<div class="flex flex-wrap gap-2 items-end">
<div class="flex-1 min-w-[140px]">
<label class="text-xs text-color-secondary mb-1 block">Nome do procedimento *</label>
<InputText v-model="newServiceForm.name" placeholder="Ex: Consulta" class="w-full" size="small" />
<!-- Leitura do procedimento -->
<div v-else class="cnv-proc-row" :class="{ 'opacity-50': !ps.active }">
<div class="flex items-center gap-2 min-w-0 flex-1">
<Tag v-if="!ps.active" value="Inativo" severity="secondary" class="text-xs shrink-0" />
<span class="text-sm font-medium truncate">{{ ps.name }}</span>
</div>
<div class="w-36">
<label class="text-xs text-color-secondary mb-1 block">Valor (R$) *</label>
<InputNumber
v-model="newServiceForm.value"
mode="currency" currency="BRL" locale="pt-BR"
:min="0" :minFractionDigits="2"
class="w-full" size="small"
/>
</div>
<div class="flex gap-2">
<Button label="Cancelar" severity="secondary" outlined size="small" @click="cancelAddService" />
<Button label="Adicionar" icon="pi pi-check" size="small" :loading="savingService" @click="saveService(plan.id)" />
<div class="flex items-center gap-2 shrink-0">
<span class="text-sm font-semibold text-[var(--primary-color)]">{{ fmtBRL(ps.value) }}</span>
<Button :icon="ps.active ? 'pi pi-eye-slash' : 'pi pi-eye'" :severity="ps.active ? 'secondary' : 'success'" text size="small" v-tooltip.top="ps.active ? 'Desativar' : 'Ativar'" @click="onToggleService(ps)" />
<Button icon="pi pi-pencil" severity="secondary" text size="small" v-tooltip.top="'Editar'" @click="startEditService(ps)" />
<Button icon="pi pi-trash" severity="danger" text size="small" v-tooltip.top="'Remover'" @click="deleteService(ps.id)" />
</div>
</div>
</div>
<Button
v-if="addingServicePlanId !== plan.id"
label="Adicionar procedimento"
icon="pi pi-plus"
severity="secondary"
outlined
size="small"
class="mt-2"
@click="startAddService(plan.id)"
/>
</template>
</div>
<div v-else-if="addingServicePlanId !== plan.id" class="text-sm text-[var(--text-color-secondary)] italic px-1 py-2">
Nenhum procedimento cadastrado.
</div>
</template>
<!-- Form adicionar procedimento -->
<div v-if="addingServicePlanId === plan.id" class="cnv-proc-form">
<!-- Quick-fill dos serviços -->
<div v-if="services.filter(s => s.active).length" class="mb-3">
<div class="cnv-label mb-1.5">Clique num serviço para pré-preencher:</div>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-1.5">
<button
v-for="svc in services.filter(s => s.active)"
:key="svc.id"
class="svc-quick-card"
@click="fillFromService(svc)"
>
<span class="svc-quick-name">{{ svc.name }}</span>
<span class="svc-quick-price">{{ fmtBRL(svc.price) }}</span>
</button>
</div>
</div>
<!-- Campos nome + valor -->
<div class="grid grid-cols-12 gap-2">
<div class="col-span-12 sm:col-span-7">
<label class="cnv-label">Nome do procedimento *</label>
<InputText v-model="newServiceForm.name" placeholder="Ex: Consulta" class="w-full" size="small" />
</div>
<div class="col-span-12 sm:col-span-5">
<label class="cnv-label">Valor (R$) *</label>
<InputNumber v-model="newServiceForm.value" mode="currency" currency="BRL" locale="pt-BR" :min="0" :minFractionDigits="2" class="w-full" size="small" />
</div>
</div>
<!-- Botões em linha separada -->
<div class="flex gap-2 justify-end mt-2">
<Button label="Cancelar" severity="secondary" outlined size="small" class="rounded-full" @click="cancelAddService" />
<Button label="Adicionar" icon="pi pi-check" size="small" class="rounded-full" :loading="savingService" @click="saveService(plan.id)" />
</div>
</div>
<Button
v-if="addingServicePlanId !== plan.id"
label="Adicionar procedimento"
icon="pi pi-plus"
severity="secondary" outlined size="small" class="mt-2 rounded-full"
@click="startAddService(plan.id)"
/>
</div>
</template>
</Card>
</div>
<Message severity="info" :closable="false">
<span class="text-sm">
@@ -509,44 +463,129 @@ onMounted(async () => {
</template>
<style scoped>
.cfg-icon-box {
display: grid;
place-items: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 0.875rem;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
flex-shrink: 0;
/* ── Subheader degradê ────────────────────────────── */
.cfg-subheader {
display: flex; align-items: center; gap: 0.65rem;
padding: 0.875rem 1rem; border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--primary-color,#6366f1) 30%, transparent);
background: linear-gradient(135deg,
color-mix(in srgb, var(--primary-color,#6366f1) 12%, var(--surface-card)) 0%,
color-mix(in srgb, var(--primary-color,#6366f1) 4%, var(--surface-card)) 60%,
var(--surface-card) 100%);
position: relative; overflow: hidden;
}
.cfg-subheader::before {
content: ''; position: absolute; top: -20px; right: -20px;
width: 80px; height: 80px; border-radius: 50%;
background: color-mix(in srgb, var(--primary-color,#6366f1) 15%, transparent);
filter: blur(20px); pointer-events: none;
}
.cfg-subheader__icon {
display: grid; place-items: center;
width: 2rem; height: 2rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 20%, transparent);
color: var(--primary-color,#6366f1); font-size: 0.85rem;
}
.cfg-subheader__title { font-size: 0.95rem; font-weight: 700; letter-spacing: -0.01em; color: var(--primary-color,#6366f1); }
.cfg-subheader__sub { font-size: 0.75rem; color: var(--text-color-secondary); opacity: 0.85; }
.cfg-subheader__actions { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; flex-shrink: 0; position: relative; z-index: 1; }
/* ── Card wrap ────────────────────────────────────── */
.cfg-wrap {
border: 1px solid var(--surface-border);
border-radius: 6px; background: var(--surface-card);
overflow: hidden;
}
.cfg-wrap__head {
display: flex; align-items: center; gap: 0.625rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-ground);
}
.cfg-wrap__icon {
display: grid; place-items: center;
width: 1.75rem; height: 1.75rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 12%, transparent);
color: var(--primary-color,#6366f1); font-size: 0.8rem;
}
.cfg-wrap__title { font-size: 0.88rem; font-weight: 700; color: var(--text-color); }
.cfg-wrap__body { padding: 1rem; display: flex; flex-direction: column; gap: 0.75rem; }
/* ── Empty state ──────────────────────────────────── */
.cfg-empty {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 0.75rem; padding: 2.5rem 1rem; text-align: center;
color: var(--text-color-secondary);
border: 1px dashed var(--surface-border);
border-radius: 6px; background: var(--surface-ground);
}
.cfg-icon-box-sm {
display: grid;
place-items: center;
width: 2rem;
height: 2rem;
border-radius: 0.625rem;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 10%, transparent);
color: var(--p-primary-500, #6366f1);
flex-shrink: 0;
/* ── Cabeçalho do plano ───────────────────────────── */
.cnv-plan-head {
display: flex; align-items: center; justify-content: space-between;
gap: 0.75rem; padding: 0.75rem 1rem; flex-wrap: wrap;
}
/* ── Painel procedimentos ─────────────────────────── */
.cnv-procedures {
border-top: 1px solid var(--surface-border);
padding: 0.75rem 1rem;
display: flex; flex-direction: column; gap: 0.25rem;
background: var(--surface-ground);
}
.cnv-proc-list {
display: flex; flex-direction: column;
border: 1px solid var(--surface-border);
border-radius: 6px; overflow: hidden;
background: var(--surface-card);
margin-bottom: 0.5rem;
}
.cnv-proc-row {
display: flex; align-items: center; justify-content: space-between;
gap: 0.5rem; padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--surface-border);
transition: background 0.1s;
}
.cnv-proc-row:last-child { border-bottom: none; }
.cnv-proc-row:hover { background: var(--surface-hover); }
.cnv-proc-edit {
padding: 0.75rem;
border-bottom: 1px solid var(--surface-border);
background: color-mix(in srgb, var(--primary-color,#6366f1) 3%, var(--surface-card));
}
.cnv-proc-edit:last-child { border-bottom: none; }
/* ── Form adicionar procedimento ──────────────────── */
.cnv-proc-form {
border: 1px solid var(--surface-border);
border-radius: 6px;
background: var(--surface-card);
padding: 0.75rem;
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
/* ── Labels ───────────────────────────────────────── */
.cnv-label {
display: block;
font-size: 0.72rem; font-weight: 500;
color: var(--text-color-secondary); margin-bottom: 0.25rem;
}
/* ── Quick-fill serviços ──────────────────────────── */
.svc-quick-card {
display: flex;
flex-direction: column;
gap: 0.125rem;
padding: 0.375rem 0.625rem;
border-radius: 0.5rem;
border: 1px solid var(--p-surface-200, #e5e7eb);
background: var(--p-surface-50, #f9fafb);
text-align: left;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
display: flex; flex-direction: column; gap: 0.1rem;
padding: 0.375rem 0.625rem; border-radius: 6px;
border: 1px solid var(--surface-border);
background: var(--surface-ground);
text-align: left; cursor: pointer;
transition: border-color 0.12s, background 0.12s;
}
.svc-quick-card:hover {
border-color: var(--p-primary-400, #818cf8);
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 6%, transparent);
border-color: var(--primary-color,#6366f1);
background: color-mix(in srgb, var(--primary-color,#6366f1) 5%, transparent);
}
.svc-quick-name { font-size: 0.75rem; font-weight: 600; color: var(--p-text-color); }
.svc-quick-price { font-size: 0.7rem; color: var(--p-text-muted-color); }
</style>
.svc-quick-name { font-size: 0.72rem; font-weight: 600; color: var(--text-color); }
.svc-quick-price { font-size: 0.68rem; color: var(--text-color-secondary); }
</style>
@@ -184,32 +184,19 @@ onMounted(async () => {
<template>
<Toast />
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3">
<!-- Header -->
<Card>
<template #content>
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-3">
<div class="cfg-icon-box">
<i class="pi pi-percentage text-lg" />
</div>
<div>
<div class="text-900 font-semibold text-lg">Descontos por Paciente</div>
<div class="text-600 text-sm">
Configure descontos recorrentes aplicados automaticamente por paciente.
</div>
</div>
</div>
<Button
label="Novo desconto"
icon="pi pi-plus"
:disabled="pageLoading || addingNew"
@click="addingNew = true; cancelEdit()"
/>
</div>
</template>
</Card>
<!-- Subheader -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-percentage" /></div>
<div class="min-w-0">
<div class="cfg-subheader__title">Descontos por Paciente</div>
<div class="cfg-subheader__sub">Descontos recorrentes aplicados automaticamente por paciente</div>
</div>
<div class="cfg-subheader__actions">
<Button label="Novo desconto" icon="pi pi-plus" size="small" :disabled="pageLoading || addingNew" class="rounded-full" @click="addingNew = true; cancelEdit()" />
</div>
</div>
<!-- Loading -->
<div v-if="pageLoading || loading" class="flex justify-center py-10">
@@ -218,309 +205,144 @@ onMounted(async () => {
<template v-else>
<!-- Lista de descontos -->
<Card v-if="discounts.length || addingNew">
<template #content>
<div class="flex flex-col gap-3">
<!-- Lista + form -->
<div v-if="discounts.length || addingNew" class="cfg-wrap">
<div class="cfg-wrap__head">
<div class="cfg-wrap__icon"><i class="pi pi-percentage" /></div>
<span class="cfg-wrap__title">Descontos cadastrados</span>
<span class="cfg-wrap__count">{{ discounts.length }}</span>
</div>
<template v-for="disc in discounts" :key="disc.id">
<div class="dsc-list">
<!-- Modo edição inline -->
<div v-if="editingId === disc.id" class="discount-row editing">
<div class="grid grid-cols-12 gap-3 flex-1">
<template v-for="disc in discounts" :key="disc.id">
<!-- Paciente (desabilitado na edição) -->
<div class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
<Select
v-model="editForm.patient_id"
inputId="edit-patient"
:options="patients"
optionLabel="nome_completo"
optionValue="id"
disabled
class="w-full"
/>
<label for="edit-patient">Paciente</label>
</FloatLabel>
</div>
<!-- Desconto % -->
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<InputNumber
v-model="editForm.discount_pct"
inputId="edit-pct"
:min="0"
:max="100"
:minFractionDigits="0"
:maxFractionDigits="2"
suffix="%"
fluid
/>
<label for="edit-pct">Desconto %</label>
</FloatLabel>
</div>
<!-- Desconto R$ -->
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<InputNumber
v-model="editForm.discount_flat"
inputId="edit-flat"
mode="currency"
currency="BRL"
locale="pt-BR"
:min="0"
fluid
/>
<label for="edit-flat">Desconto R$</label>
</FloatLabel>
</div>
<!-- Vigência: de -->
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<DatePicker
v-model="editForm.active_from"
inputId="edit-from"
dateFormat="dd/mm/yy"
showButtonBar
fluid
/>
<label for="edit-from">Vigência: de</label>
</FloatLabel>
</div>
<!-- Vigência: até -->
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<DatePicker
v-model="editForm.active_to"
inputId="edit-to"
dateFormat="dd/mm/yy"
showButtonBar
fluid
/>
<label for="edit-to">Vigência: até</label>
</FloatLabel>
</div>
<!-- Motivo -->
<div class="col-span-12">
<FloatLabel variant="on">
<InputText
v-model="editForm.reason"
inputId="edit-reason"
class="w-full"
/>
<label for="edit-reason">Motivo (opcional)</label>
</FloatLabel>
</div>
</div>
<div class="flex gap-2 mt-1">
<Button
icon="pi pi-check"
size="small"
:loading="savingEdit"
@click="saveEdit"
/>
<Button
icon="pi pi-times"
size="small"
severity="secondary"
outlined
@click="cancelEdit"
/>
</div>
</div>
<!-- Modo leitura -->
<div v-else class="discount-row">
<div class="discount-info">
<div class="font-medium text-900">{{ patientName(disc.patient_id) }}</div>
<div class="flex flex-wrap gap-2 mt-1">
<span v-if="fmtPct(disc.discount_pct)" class="discount-badge">
{{ fmtPct(disc.discount_pct) }}
</span>
<span v-if="fmtBRL(disc.discount_flat)" class="discount-badge">
{{ fmtBRL(disc.discount_flat) }}
</span>
</div>
<div class="text-sm text-600 mt-0.5">
<span v-if="disc.active_from || disc.active_to">
{{ fmtDate(disc.active_from) || 'Indefinido' }}
{{ fmtDate(disc.active_to) || 'Indefinido' }}
</span>
<span v-else>Vigência indefinida</span>
</div>
<div v-if="disc.reason" class="text-sm text-500 mt-0.5 italic">
{{ disc.reason }}
</div>
</div>
<div class="discount-meta">
<Tag
:value="disc.active ? 'Ativo' : 'Inativo'"
:severity="disc.active ? 'success' : 'secondary'"
/>
</div>
<div class="flex gap-2 ml-auto">
<Button
icon="pi pi-pencil"
size="small"
severity="secondary"
text
@click="startEdit(disc); addingNew = false"
/>
<Button
v-if="disc.active"
icon="pi pi-ban"
size="small"
severity="danger"
text
v-tooltip.top="'Desativar'"
@click="confirmRemove(disc.id)"
/>
</div>
</div>
</template>
<!-- Divisor antes do form novo -->
<Divider v-if="discounts.length && addingNew" />
<!-- Formulário novo desconto inline -->
<div v-if="addingNew" class="discount-row new-row">
<div class="grid grid-cols-12 gap-3 flex-1">
<!-- Paciente -->
<!-- Edição inline -->
<div v-if="editingId === disc.id" class="dsc-form-row dsc-form-row--editing">
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
<Select
v-model="newForm.patient_id"
inputId="new-patient"
:options="patients"
optionLabel="nome_completo"
optionValue="id"
filter
class="w-full"
/>
<label for="new-patient">Paciente *</label>
<Select v-model="editForm.patient_id" inputId="edit-patient" :options="patients" optionLabel="nome_completo" optionValue="id" disabled class="w-full" />
<label for="edit-patient">Paciente</label>
</FloatLabel>
</div>
<!-- Desconto % -->
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<InputNumber
v-model="newForm.discount_pct"
inputId="new-pct"
:min="0"
:max="100"
:minFractionDigits="0"
:maxFractionDigits="2"
suffix="%"
fluid
/>
<label for="new-pct">Desconto %</label>
<InputNumber v-model="editForm.discount_pct" inputId="edit-pct" :min="0" :max="100" :minFractionDigits="0" :maxFractionDigits="2" suffix="%" fluid />
<label for="edit-pct">Desconto %</label>
</FloatLabel>
</div>
<!-- Desconto R$ -->
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<InputNumber
v-model="newForm.discount_flat"
inputId="new-flat"
mode="currency"
currency="BRL"
locale="pt-BR"
:min="0"
fluid
/>
<label for="new-flat">Desconto R$</label>
<InputNumber v-model="editForm.discount_flat" inputId="edit-flat" mode="currency" currency="BRL" locale="pt-BR" :min="0" fluid />
<label for="edit-flat">Desconto R$</label>
</FloatLabel>
</div>
<!-- Vigência: de -->
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<DatePicker
v-model="newForm.active_from"
inputId="new-from"
dateFormat="dd/mm/yy"
showButtonBar
fluid
/>
<label for="new-from">Vigência: de</label>
<DatePicker v-model="editForm.active_from" inputId="edit-from" dateFormat="dd/mm/yy" showButtonBar fluid />
<label for="edit-from">Vigência: de</label>
</FloatLabel>
</div>
<!-- Vigência: até -->
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<DatePicker
v-model="newForm.active_to"
inputId="new-to"
dateFormat="dd/mm/yy"
showButtonBar
fluid
/>
<label for="new-to">Vigência: até</label>
<DatePicker v-model="editForm.active_to" inputId="edit-to" dateFormat="dd/mm/yy" showButtonBar fluid />
<label for="edit-to">Vigência: até</label>
</FloatLabel>
</div>
<!-- Motivo -->
<div class="col-span-12">
<FloatLabel variant="on">
<InputText
v-model="newForm.reason"
inputId="new-reason"
class="w-full"
/>
<label for="new-reason">Motivo (opcional)</label>
<InputText v-model="editForm.reason" inputId="edit-reason" class="w-full" />
<label for="edit-reason">Motivo (opcional)</label>
</FloatLabel>
</div>
</div>
<div class="flex gap-2 mt-1">
<Button
icon="pi pi-check"
label="Adicionar"
size="small"
:loading="savingNew"
@click="saveNew"
/>
<Button
icon="pi pi-times"
size="small"
severity="secondary"
outlined
@click="addingNew = false; newForm = emptyForm()"
/>
<div class="flex gap-2 justify-end mt-3">
<Button label="Cancelar" icon="pi pi-times" size="small" severity="secondary" outlined class="rounded-full" @click="cancelEdit" />
<Button label="Salvar" icon="pi pi-check" size="small" :loading="savingEdit" class="rounded-full" @click="saveEdit" />
</div>
</div>
<!-- Leitura -->
<div v-else class="dsc-row">
<div class="dsc-row__info">
<div class="font-semibold text-sm">{{ patientName(disc.patient_id) }}</div>
<div class="flex flex-wrap gap-1.5 mt-1">
<span v-if="fmtPct(disc.discount_pct)" class="dsc-badge">{{ fmtPct(disc.discount_pct) }}</span>
<span v-if="fmtBRL(disc.discount_flat)" class="dsc-badge">{{ fmtBRL(disc.discount_flat) }}</span>
</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5">
<span v-if="disc.active_from || disc.active_to">
{{ fmtDate(disc.active_from) || 'Indefinido' }} {{ fmtDate(disc.active_to) || 'Indefinido' }}
</span>
<span v-else>Vigência indefinida</span>
</div>
<div v-if="disc.reason" class="text-xs text-[var(--text-color-secondary)] italic mt-0.5">{{ disc.reason }}</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<Tag :value="disc.active ? 'Ativo' : 'Inativo'" :severity="disc.active ? 'success' : 'secondary'" />
<Button icon="pi pi-pencil" size="small" severity="secondary" text v-tooltip.top="'Editar'" @click="startEdit(disc); addingNew = false" />
<Button v-if="disc.active" icon="pi pi-ban" size="small" severity="danger" text v-tooltip.top="'Desativar'" @click="confirmRemove(disc.id)" />
</div>
</div>
</template>
<!-- Form novo desconto -->
<div v-if="addingNew" class="dsc-form-row dsc-form-row--new">
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
<Select v-model="newForm.patient_id" inputId="new-patient" :options="patients" optionLabel="nome_completo" optionValue="id" filter class="w-full" />
<label for="new-patient">Paciente *</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<InputNumber v-model="newForm.discount_pct" inputId="new-pct" :min="0" :max="100" :minFractionDigits="0" :maxFractionDigits="2" suffix="%" fluid />
<label for="new-pct">Desconto %</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<InputNumber v-model="newForm.discount_flat" inputId="new-flat" mode="currency" currency="BRL" locale="pt-BR" :min="0" fluid />
<label for="new-flat">Desconto R$</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<DatePicker v-model="newForm.active_from" inputId="new-from" dateFormat="dd/mm/yy" showButtonBar fluid />
<label for="new-from">Vigência: de</label>
</FloatLabel>
</div>
<div class="col-span-6 sm:col-span-2">
<FloatLabel variant="on">
<DatePicker v-model="newForm.active_to" inputId="new-to" dateFormat="dd/mm/yy" showButtonBar fluid />
<label for="new-to">Vigência: até</label>
</FloatLabel>
</div>
<div class="col-span-12">
<FloatLabel variant="on">
<InputText v-model="newForm.reason" inputId="new-reason" class="w-full" />
<label for="new-reason">Motivo (opcional)</label>
</FloatLabel>
</div>
</div>
<div class="flex gap-2 justify-end mt-3">
<Button label="Cancelar" icon="pi pi-times" size="small" severity="secondary" outlined class="rounded-full" @click="addingNew = false; newForm = emptyForm()" />
<Button label="Adicionar" icon="pi pi-check" size="small" :loading="savingNew" class="rounded-full" @click="saveNew" />
</div>
</div>
</template>
</Card>
</div>
</div>
<!-- Estado vazio -->
<Card v-else>
<template #content>
<div class="flex flex-col items-center gap-3 py-6 text-center">
<i class="pi pi-percentage text-4xl text-400" />
<div class="text-600">Nenhum desconto cadastrado ainda.</div>
<Button
label="Adicionar primeiro desconto"
icon="pi pi-plus"
outlined
@click="addingNew = true"
/>
</div>
</template>
</Card>
<div v-else class="cfg-empty">
<i class="pi pi-percentage text-3xl opacity-25" />
<div class="text-sm font-medium">Nenhum desconto cadastrado ainda.</div>
<Button label="Adicionar primeiro desconto" icon="pi pi-plus" outlined size="small" class="rounded-full" @click="addingNew = true" />
</div>
<!-- Dica -->
<Message severity="info" :closable="false">
@@ -535,56 +357,103 @@ onMounted(async () => {
</template>
<style scoped>
.cfg-icon-box {
display: grid;
place-items: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 0.875rem;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
flex-shrink: 0;
/* ── Subheader degradê ────────────────────────────── */
.cfg-subheader {
display: flex; align-items: center; gap: 0.65rem;
padding: 0.875rem 1rem; border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--primary-color,#6366f1) 30%, transparent);
background: linear-gradient(135deg,
color-mix(in srgb, var(--primary-color,#6366f1) 12%, var(--surface-card)) 0%,
color-mix(in srgb, var(--primary-color,#6366f1) 4%, var(--surface-card)) 60%,
var(--surface-card) 100%);
position: relative; overflow: hidden;
}
.cfg-subheader::before {
content: ''; position: absolute; top: -20px; right: -20px;
width: 80px; height: 80px; border-radius: 50%;
background: color-mix(in srgb, var(--primary-color,#6366f1) 15%, transparent);
filter: blur(20px); pointer-events: none;
}
.cfg-subheader__icon {
display: grid; place-items: center;
width: 2rem; height: 2rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 20%, transparent);
color: var(--primary-color,#6366f1); font-size: 0.85rem;
}
.cfg-subheader__title { font-size: 0.95rem; font-weight: 700; letter-spacing: -0.01em; color: var(--primary-color,#6366f1); }
.cfg-subheader__sub { font-size: 0.75rem; color: var(--text-color-secondary); opacity: 0.85; }
.cfg-subheader__actions { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; flex-shrink: 0; position: relative; z-index: 1; }
.discount-row {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.75rem;
border-radius: 0.75rem;
/* ── Card wrap ────────────────────────────────────── */
.cfg-wrap {
border: 1px solid var(--surface-border);
border-radius: 6px; background: var(--surface-card);
overflow: hidden;
}
.cfg-wrap__head {
display: flex; align-items: center; gap: 0.625rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-ground);
flex-wrap: wrap;
}
.cfg-wrap__icon {
display: grid; place-items: center;
width: 1.75rem; height: 1.75rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 12%, transparent);
color: var(--primary-color,#6366f1); font-size: 0.8rem;
}
.cfg-wrap__title { font-size: 0.88rem; font-weight: 700; color: var(--text-color); flex: 1; }
.cfg-wrap__count {
font-size: 0.7rem; font-weight: 700;
background: var(--primary-color,#6366f1); color: #fff;
padding: 1px 8px; border-radius: 999px; flex-shrink: 0;
}
.discount-row.editing {
border-color: var(--p-primary-300, #a5b4fc);
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 4%, var(--surface-ground));
}
/* ── Lista de descontos ───────────────────────────── */
.dsc-list { display: flex; flex-direction: column; }
.discount-row.new-row {
border-style: dashed;
/* Linha de leitura */
.dsc-row {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 0.75rem; padding: 0.75rem 1rem;
border-bottom: 1px solid var(--surface-border);
transition: background 0.1s; flex-wrap: wrap;
}
.dsc-row:last-child { border-bottom: none; }
.dsc-row:hover { background: var(--surface-hover); }
.dsc-row__info { flex: 1; min-width: 0; }
.discount-info {
flex: 1;
min-width: 8rem;
}
.discount-meta {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
.discount-badge {
font-weight: 600;
color: var(--p-primary-600, #4f46e5);
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 10%, transparent);
padding: 0.2rem 0.6rem;
border-radius: 1rem;
font-size: 0.875rem;
/* Badge de valor */
.dsc-badge {
font-size: 0.75rem; font-weight: 600;
color: var(--primary-color,#6366f1);
background: color-mix(in srgb, var(--primary-color,#6366f1) 10%, transparent);
padding: 0.15rem 0.5rem; border-radius: 6px;
white-space: nowrap;
}
</style>
/* Form de adição/edição */
.dsc-form-row {
padding: 1rem;
border-bottom: 1px solid var(--surface-border);
}
.dsc-form-row:last-child { border-bottom: none; }
.dsc-form-row--editing {
background: color-mix(in srgb, var(--primary-color,#6366f1) 3%, var(--surface-card));
border-left: 3px solid color-mix(in srgb, var(--primary-color,#6366f1) 50%, transparent);
}
.dsc-form-row--new {
background: var(--surface-ground);
border-top: 1px dashed var(--surface-border);
border-bottom: none;
}
/* ── Empty state ──────────────────────────────────── */
.cfg-empty {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 0.75rem; padding: 2.5rem 1rem; text-align: center;
color: var(--text-color-secondary);
border: 1px dashed var(--surface-border);
border-radius: 6px; background: var(--surface-ground);
}
</style>
@@ -145,24 +145,16 @@ onMounted(async () => {
<template>
<Toast />
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3">
<!-- Header -->
<Card>
<template #content>
<div class="flex items-center gap-3">
<div class="cfg-icon-box">
<i class="pi pi-exclamation-triangle text-lg" />
</div>
<div>
<div class="text-900 font-semibold text-lg">Exceções Financeiras</div>
<div class="text-600 text-sm">
Defina o que cobrar em situações excepcionais de cancelamento ou falta.
</div>
</div>
</div>
</template>
</Card>
<!-- Subheader -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-exclamation-triangle" /></div>
<div class="min-w-0">
<div class="cfg-subheader__title">Exceções Financeiras</div>
<div class="cfg-subheader__sub">Defina o que cobrar em cancelamentos, faltas e situações excepcionais</div>
</div>
</div>
<!-- Loading -->
<div v-if="pageLoading || loading" class="flex justify-center py-10">
@@ -172,154 +164,99 @@ onMounted(async () => {
<template v-else>
<!-- Um card por tipo de exceção -->
<Card v-for="et in exceptionTypes" :key="et.value">
<template #content>
<div v-for="et in exceptionTypes" :key="et.value" class="cfg-wrap">
<!-- Modo leitura -->
<template v-if="editingType !== et.value">
<div class="exception-row">
<div class="exception-info">
<div class="font-semibold text-900 text-base">{{ et.label }}</div>
<template v-if="recordFor(et.value)">
<div class="text-sm text-600 mt-1">
{{ summaryFor(recordFor(et.value)) }}
<span
v-if="et.value === 'patient_cancellation' && recordFor(et.value)?.min_hours_notice"
class="text-500"
>
cobrar apenas se cancelado com menos de
{{ recordFor(et.value).min_hours_notice }}h de antecedência
</span>
</div>
<div class="flex gap-2 mt-2 flex-wrap">
<Tag
:value="chargeModeLabel(recordFor(et.value)?.charge_mode)"
:severity="chargeModeSeverity[recordFor(et.value)?.charge_mode] ?? 'secondary'"
/>
<Tag
v-if="isGlobalRecord(recordFor(et.value))"
value="Regra da clínica"
severity="secondary"
/>
</div>
</template>
<div v-else class="text-sm text-500 mt-1 italic">
Não configurado comportamento padrão: não cobrar.
</div>
</div>
<Button
v-if="!isGlobalRecord(recordFor(et.value))"
label="Configurar"
icon="pi pi-cog"
size="small"
severity="secondary"
outlined
class="ml-auto flex-shrink-0"
@click="startEdit(et.value)"
<!-- Cabeçalho do card -->
<div class="cfg-wrap__head">
<div class="cfg-wrap__icon"><i class="pi pi-exclamation-triangle" /></div>
<span class="cfg-wrap__title">{{ et.label }}</span>
<div class="ml-auto flex items-center gap-2 shrink-0">
<template v-if="recordFor(et.value)">
<Tag
:value="chargeModeLabel(recordFor(et.value)?.charge_mode)"
:severity="chargeModeSeverity[recordFor(et.value)?.charge_mode] ?? 'secondary'"
/>
<Tag v-if="isGlobalRecord(recordFor(et.value))" value="Regra da clínica" severity="secondary" />
</template>
<Button
v-if="editingType !== et.value && !isGlobalRecord(recordFor(et.value))"
label="Configurar"
icon="pi pi-cog"
size="small"
severity="secondary"
outlined
class="rounded-full"
@click="startEdit(et.value)"
/>
</div>
</div>
<!-- Modo leitura -->
<div v-if="editingType !== et.value" class="exc-read">
<template v-if="recordFor(et.value)">
<div class="text-sm text-[var(--text-color-secondary)]">
{{ summaryFor(recordFor(et.value)) }}
<span v-if="et.value === 'patient_cancellation' && recordFor(et.value)?.min_hours_notice">
cobrar apenas se cancelado com menos de {{ recordFor(et.value).min_hours_notice }}h de antecedência
</span>
</div>
</template>
<div v-else class="text-sm text-[var(--text-color-secondary)] italic">
Não configurado comportamento padrão: não cobrar.
</div>
</div>
<!-- Modo edição inline -->
<template v-else>
<div class="exception-row editing">
<div class="flex flex-col gap-4 flex-1">
<!-- Modo edição -->
<div v-else class="exc-edit">
<!-- Modo de cobrança -->
<div>
<label class="exc-label">Modo de cobrança</label>
<SelectButton
v-model="editForm.charge_mode"
:options="chargeModeOptions"
optionLabel="label"
optionValue="value"
class="flex-wrap mt-1"
/>
</div>
<div class="font-semibold text-900">{{ et.label }}</div>
<!-- Modo de cobrança -->
<div>
<label class="text-sm text-600 block mb-2">Modo de cobrança</label>
<SelectButton
v-model="editForm.charge_mode"
:options="chargeModeOptions"
optionLabel="label"
optionValue="value"
class="flex-wrap"
/>
</div>
<div class="grid grid-cols-12 gap-3">
<!-- Taxa fixa (R$) -->
<div v-if="showChargeValue" class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
<InputNumber
v-model="editForm.charge_value"
inputId="edit-charge-value"
mode="currency"
currency="BRL"
locale="pt-BR"
:min="0"
fluid
/>
<label for="edit-charge-value">Taxa fixa (R$)</label>
</FloatLabel>
</div>
<!-- Percentual (%) -->
<div v-if="showChargePct" class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
<InputNumber
v-model="editForm.charge_pct"
inputId="edit-charge-pct"
:min="0"
:max="100"
:minFractionDigits="0"
:maxFractionDigits="2"
suffix="%"
fluid
/>
<label for="edit-charge-pct">Percentual da sessão (%)</label>
</FloatLabel>
</div>
<!-- Antecedência mínima (apenas patient_cancellation) -->
<div v-if="showMinHours" class="col-span-12 sm:col-span-5">
<FloatLabel variant="on">
<InputNumber
v-model="editForm.min_hours_notice"
inputId="edit-min-hours"
:min="0"
:max="720"
suffix=" h"
fluid
showButtons
/>
<label for="edit-min-hours">Cobrar se cancelado com menos de X horas (vazio = sempre)</label>
</FloatLabel>
<small class="text-500 mt-1 block">
Deixe em branco para cobrar independentemente da antecedência.
</small>
</div>
</div>
<div class="flex gap-2">
<Button
icon="pi pi-check"
label="Salvar"
size="small"
:loading="savingEdit"
@click="saveEdit"
/>
<Button
icon="pi pi-times"
size="small"
severity="secondary"
outlined
@click="cancelEdit"
/>
</div>
</div>
<div class="grid grid-cols-12 gap-3">
<!-- Taxa fixa -->
<div v-if="showChargeValue" class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
<InputNumber v-model="editForm.charge_value" inputId="edit-charge-value" mode="currency" currency="BRL" locale="pt-BR" :min="0" fluid />
<label for="edit-charge-value">Taxa fixa (R$)</label>
</FloatLabel>
</div>
</template>
</template>
</Card>
<!-- Percentual -->
<div v-if="showChargePct" class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
<InputNumber v-model="editForm.charge_pct" inputId="edit-charge-pct" :min="0" :max="100" :minFractionDigits="0" :maxFractionDigits="2" suffix="%" fluid />
<label for="edit-charge-pct">Percentual da sessão (%)</label>
</FloatLabel>
</div>
<!-- Antecedência mínima -->
<div v-if="showMinHours" class="col-span-12 sm:col-span-6">
<FloatLabel variant="on">
<InputNumber v-model="editForm.min_hours_notice" inputId="edit-min-hours" :min="0" :max="720" suffix=" h" fluid showButtons />
<label for="edit-min-hours">Cobrar se cancelado com menos de X horas (vazio = sempre)</label>
</FloatLabel>
<small class="text-[var(--text-color-secondary)] opacity-70 mt-1 block text-xs">
Deixe em branco para cobrar independentemente da antecedência.
</small>
</div>
</div>
<!-- Botões na linha separada -->
<div class="flex gap-2 justify-end mt-1">
<Button label="Cancelar" icon="pi pi-times" size="small" severity="secondary" outlined class="rounded-full" @click="cancelEdit" />
<Button label="Salvar" icon="pi pi-check" size="small" :loading="savingEdit" class="rounded-full" @click="saveEdit" />
</div>
</div>
</div>
<!-- Dica -->
<Message severity="info" :closable="false">
@@ -334,33 +271,69 @@ onMounted(async () => {
</template>
<style scoped>
.cfg-icon-box {
display: grid;
place-items: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 0.875rem;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
flex-shrink: 0;
/* ── Subheader degradê ────────────────────────────── */
.cfg-subheader {
display: flex; align-items: center; gap: 0.65rem;
padding: 0.875rem 1rem; border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--primary-color,#6366f1) 30%, transparent);
background: linear-gradient(135deg,
color-mix(in srgb, var(--primary-color,#6366f1) 12%, var(--surface-card)) 0%,
color-mix(in srgb, var(--primary-color,#6366f1) 4%, var(--surface-card)) 60%,
var(--surface-card) 100%);
position: relative; overflow: hidden;
}
.cfg-subheader::before {
content: ''; position: absolute; top: -20px; right: -20px;
width: 80px; height: 80px; border-radius: 50%;
background: color-mix(in srgb, var(--primary-color,#6366f1) 15%, transparent);
filter: blur(20px); pointer-events: none;
}
.cfg-subheader__icon {
display: grid; place-items: center;
width: 2rem; height: 2rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 20%, transparent);
color: var(--primary-color,#6366f1); font-size: 0.85rem;
}
.cfg-subheader__title { font-size: 0.95rem; font-weight: 700; letter-spacing: -0.01em; color: var(--primary-color,#6366f1); }
.cfg-subheader__sub { font-size: 0.75rem; color: var(--text-color-secondary); opacity: 0.85; }
.cfg-subheader__actions { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; flex-shrink: 0; position: relative; z-index: 1; }
/* ── Card wrap ────────────────────────────────────── */
.cfg-wrap {
border: 1px solid var(--surface-border);
border-radius: 6px; background: var(--surface-card);
overflow: hidden;
}
.cfg-wrap__head {
display: flex; align-items: center; gap: 0.625rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-ground); flex-wrap: wrap;
}
.cfg-wrap__icon {
display: grid; place-items: center;
width: 1.75rem; height: 1.75rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 12%, transparent);
color: var(--primary-color,#6366f1); font-size: 0.8rem;
}
.cfg-wrap__title { font-size: 0.88rem; font-weight: 700; color: var(--text-color); }
/* ── Leitura ──────────────────────────────────────── */
.exc-read {
padding: 0.75rem 1rem;
}
.exception-row {
display: flex;
align-items: flex-start;
gap: 1rem;
flex-wrap: wrap;
}
.exception-row.editing {
border: 1px solid var(--p-primary-300, #a5b4fc);
border-radius: 0.75rem;
/* ── Edição ───────────────────────────────────────── */
.exc-edit {
padding: 1rem;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 4%, var(--surface-ground));
display: flex; flex-direction: column; gap: 1rem;
background: color-mix(in srgb, var(--primary-color,#6366f1) 3%, var(--surface-card));
border-top: 2px solid color-mix(in srgb, var(--primary-color,#6366f1) 40%, transparent);
}
.exception-info {
flex: 1;
min-width: 10rem;
/* ── Label ────────────────────────────────────────── */
.exc-label {
display: block; font-size: 0.75rem; font-weight: 600;
color: var(--text-color-secondary); margin-bottom: 0.375rem;
}
</style>
</style>
@@ -10,7 +10,8 @@ const tenantStore = useTenantStore()
const loading = ref(true)
const ownerId = ref(null)
const expandedCard = ref(null)
const CARDS = ['pix', 'deposito', 'dinheiro', 'cartao', 'convenio', 'observacoes']
const expandedCard = ref(new Set())
const savingCard = ref(null)
// ── Defaults ────────────────────────────────────────────────────
@@ -84,8 +85,13 @@ const bancos = [
]
// ── Toggle cards ─────────────────────────────────────────────────
function expandAll () { expandedCard.value = new Set(CARDS) }
function collapseAll () { expandedCard.value = new Set() }
function toggleCard (key) {
expandedCard.value = expandedCard.value === key ? null : key
const s = new Set(expandedCard.value)
if (s.has(key)) s.delete(key)
else s.add(key)
expandedCard.value = s
}
// ── Load ─────────────────────────────────────────────────────────
@@ -173,30 +179,40 @@ onMounted(load)
<template>
<Toast />
<div v-if="loading" class="flex items-center gap-2 p-6 text-slate-500">
<div v-if="loading" class="flex items-center gap-2 p-6 text-[var(--text-color-secondary)]">
<i class="pi pi-spin pi-spinner" /> Carregando
</div>
<div v-else class="flex flex-col gap-4">
<!-- Subheader -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-wallet" /></div>
<div class="min-w-0">
<div class="cfg-subheader__title">Pagamento</div>
<div class="cfg-subheader__sub">Formas de pagamento aceitas: Pix, depósito, dinheiro, cartão e convênio</div>
</div>
<div class="cfg-subheader__actions">
<Button size="small" icon="pi pi-arrows-v" label="Expandir" severity="secondary" outlined class="rounded-full" @click="expandAll" />
<Button size="small" icon="pi pi-minus" label="Contrair" severity="secondary" outlined class="rounded-full" @click="collapseAll" />
</div>
</div>
<!-- Pix -->
<div class="rounded-2xl border bg-[var(--surface-card)] overflow-hidden"
<div class="rounded-[6px] border bg-[var(--surface-card)] overflow-hidden"
:class="cfg.pix_ativo ? 'border-green-300' : 'border-[var(--surface-border)]'">
<!-- Header -->
<button
type="button"
class="w-full flex items-center justify-between gap-4 px-5 py-4 hover:bg-[var(--surface-hover)] transition text-left"
@click="toggleCard('pix')"
<button type="button" class="pag-accordion__header" @click="toggleCard('pix')"
>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl flex items-center justify-center shrink-0"
:class="cfg.pix_ativo ? 'bg-green-100 text-green-700' : 'bg-slate-100 text-slate-400'">
<div class="w-10 h-10 rounded-[6px] flex items-center justify-center shrink-0"
:class="cfg.pix_ativo ? 'bg-green-100 text-green-700' : 'bg-[var(--surface-ground)] text-[var(--text-color-secondary)]'">
<i class="pi pi-qrcode text-lg" />
</div>
<div>
<div class="font-semibold text-slate-800">Pix</div>
<div class="text-sm text-slate-500">
<div class="font-semibold text-[var(--text-color)]">Pix</div>
<div class="text-sm text-[var(--text-color-secondary)]">
{{ cfg.pix_ativo && cfg.pix_chave ? `Chave: ${cfg.pix_chave}` : 'Pagamento instantâneo via chave Pix' }}
</div>
</div>
@@ -204,21 +220,21 @@ onMounted(load)
<div class="flex items-center gap-3 shrink-0">
<Tag v-if="cfg.pix_ativo" value="Ativo" severity="success" />
<Tag v-else value="Inativo" severity="secondary" />
<i class="pi text-slate-400" :class="expandedCard === 'pix' ? 'pi-angle-up' : 'pi-angle-down'" />
<i class="pi text-[var(--text-color-secondary)]" :class="expandedCard.has('pix') ? 'pi-angle-up' : 'pi-angle-down'" />
</div>
</button>
<!-- Body -->
<div v-if="expandedCard === 'pix'" class="border-t border-[var(--surface-border)] px-5 py-5 flex flex-col gap-4">
<div v-if="expandedCard.has('pix')" class="pag-accordion__body">
<div class="flex items-center justify-between">
<span class="font-medium text-slate-700">Habilitar Pix</span>
<span class="font-medium text-[var(--text-color)]">Habilitar Pix</span>
<ToggleSwitch v-model="cfg.pix_ativo" />
</div>
<template v-if="cfg.pix_ativo">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-600 mb-1">Tipo de chave</label>
<label class="block text-sm font-medium text-[var(--text-color)] mb-1">Tipo de chave</label>
<Select
v-model="cfg.pix_tipo"
:options="pixTipoOptions"
@@ -228,13 +244,13 @@ onMounted(load)
/>
</div>
<div>
<label class="block text-sm font-medium text-slate-600 mb-1">
<label class="block text-sm font-medium text-[var(--text-color)] mb-1">
{{ pixTipoLabel[cfg.pix_tipo] || 'Chave' }}
</label>
<InputText v-model="cfg.pix_chave" class="w-full" :placeholder="pixTipoLabel[cfg.pix_tipo]" />
</div>
<div class="md:col-span-2">
<label class="block text-sm font-medium text-slate-600 mb-1">Nome do titular</label>
<label class="block text-sm font-medium text-[var(--text-color)] mb-1">Nome do titular</label>
<InputText v-model="cfg.pix_nome_titular" class="w-full" placeholder="Nome que aparece na chave" />
</div>
</div>
@@ -252,22 +268,19 @@ onMounted(load)
</div>
<!-- Depósito bancário -->
<div class="rounded-2xl border bg-[var(--surface-card)] overflow-hidden"
<div class="rounded-[6px] border bg-[var(--surface-card)] overflow-hidden"
:class="cfg.deposito_ativo ? 'border-blue-300' : 'border-[var(--surface-border)]'">
<button
type="button"
class="w-full flex items-center justify-between gap-4 px-5 py-4 hover:bg-[var(--surface-hover)] transition text-left"
@click="toggleCard('deposito')"
<button type="button" class="pag-accordion__header" @click="toggleCard('deposito')"
>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl flex items-center justify-center shrink-0"
:class="cfg.deposito_ativo ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-400'">
<div class="w-10 h-10 rounded-[6px] flex items-center justify-center shrink-0"
:class="cfg.deposito_ativo ? 'bg-blue-100 text-blue-700' : 'bg-[var(--surface-ground)] text-[var(--text-color-secondary)]'">
<i class="pi pi-building-columns text-lg" />
</div>
<div>
<div class="font-semibold text-slate-800">Depósito / TED</div>
<div class="text-sm text-slate-500">
<div class="font-semibold text-[var(--text-color)]">Depósito / TED</div>
<div class="text-sm text-[var(--text-color-secondary)]">
{{ cfg.deposito_ativo && cfg.deposito_banco ? `${cfg.deposito_banco} · Ag. ${cfg.deposito_agencia || '—'} · Conta ${cfg.deposito_conta || '—'}` : 'Transferência bancária ou depósito' }}
</div>
</div>
@@ -275,20 +288,20 @@ onMounted(load)
<div class="flex items-center gap-3 shrink-0">
<Tag v-if="cfg.deposito_ativo" value="Ativo" severity="success" />
<Tag v-else value="Inativo" severity="secondary" />
<i class="pi text-slate-400" :class="expandedCard === 'deposito' ? 'pi-angle-up' : 'pi-angle-down'" />
<i class="pi text-[var(--text-color-secondary)]" :class="expandedCard.has('deposito') ? 'pi-angle-up' : 'pi-angle-down'" />
</div>
</button>
<div v-if="expandedCard === 'deposito'" class="border-t border-[var(--surface-border)] px-5 py-5 flex flex-col gap-4">
<div v-if="expandedCard.has('deposito')" class="pag-accordion__body">
<div class="flex items-center justify-between">
<span class="font-medium text-slate-700">Habilitar Depósito / TED</span>
<span class="font-medium text-[var(--text-color)]">Habilitar Depósito / TED</span>
<ToggleSwitch v-model="cfg.deposito_ativo" />
</div>
<template v-if="cfg.deposito_ativo">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-slate-600 mb-1">Banco</label>
<label class="block text-sm font-medium text-[var(--text-color)] mb-1">Banco</label>
<Select
v-model="cfg.deposito_banco"
:options="bancos"
@@ -300,7 +313,7 @@ onMounted(load)
/>
</div>
<div>
<label class="block text-sm font-medium text-slate-600 mb-1">Tipo de conta</label>
<label class="block text-sm font-medium text-[var(--text-color)] mb-1">Tipo de conta</label>
<Select
v-model="cfg.deposito_tipo_conta"
:options="tipoConta"
@@ -310,19 +323,19 @@ onMounted(load)
/>
</div>
<div>
<label class="block text-sm font-medium text-slate-600 mb-1">Agência</label>
<label class="block text-sm font-medium text-[var(--text-color)] mb-1">Agência</label>
<InputText v-model="cfg.deposito_agencia" class="w-full" placeholder="0000" />
</div>
<div>
<label class="block text-sm font-medium text-slate-600 mb-1">Conta</label>
<label class="block text-sm font-medium text-[var(--text-color)] mb-1">Conta</label>
<InputText v-model="cfg.deposito_conta" class="w-full" placeholder="00000-0" />
</div>
<div>
<label class="block text-sm font-medium text-slate-600 mb-1">Titular</label>
<label class="block text-sm font-medium text-[var(--text-color)] mb-1">Titular</label>
<InputText v-model="cfg.deposito_titular" class="w-full" placeholder="Nome completo ou razão social" />
</div>
<div>
<label class="block text-sm font-medium text-slate-600 mb-1">CPF / CNPJ do titular</label>
<label class="block text-sm font-medium text-[var(--text-color)] mb-1">CPF / CNPJ do titular</label>
<InputText v-model="cfg.deposito_cpf_cnpj" class="w-full" placeholder="000.000.000-00" />
</div>
</div>
@@ -340,36 +353,33 @@ onMounted(load)
</div>
<!-- Dinheiro -->
<div class="rounded-2xl border bg-[var(--surface-card)] overflow-hidden"
<div class="rounded-[6px] border bg-[var(--surface-card)] overflow-hidden"
:class="cfg.dinheiro_ativo ? 'border-yellow-300' : 'border-[var(--surface-border)]'">
<button
type="button"
class="w-full flex items-center justify-between gap-4 px-5 py-4 hover:bg-[var(--surface-hover)] transition text-left"
@click="toggleCard('dinheiro')"
<button type="button" class="pag-accordion__header" @click="toggleCard('dinheiro')"
>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl flex items-center justify-center shrink-0"
:class="cfg.dinheiro_ativo ? 'bg-yellow-100 text-yellow-700' : 'bg-slate-100 text-slate-400'">
<div class="w-10 h-10 rounded-[6px] flex items-center justify-center shrink-0"
:class="cfg.dinheiro_ativo ? 'bg-yellow-100 text-yellow-700' : 'bg-[var(--surface-ground)] text-[var(--text-color-secondary)]'">
<i class="pi pi-wallet text-lg" />
</div>
<div>
<div class="font-semibold text-slate-800">Dinheiro (espécie)</div>
<div class="text-sm text-slate-500">Pagamento presencial em dinheiro</div>
<div class="font-semibold text-[var(--text-color)]">Dinheiro (espécie)</div>
<div class="text-sm text-[var(--text-color-secondary)]">Pagamento presencial em dinheiro</div>
</div>
</div>
<div class="flex items-center gap-3 shrink-0">
<Tag v-if="cfg.dinheiro_ativo" value="Ativo" severity="success" />
<Tag v-else value="Inativo" severity="secondary" />
<i class="pi text-slate-400" :class="expandedCard === 'dinheiro' ? 'pi-angle-up' : 'pi-angle-down'" />
<i class="pi text-[var(--text-color-secondary)]" :class="expandedCard.has('dinheiro') ? 'pi-angle-up' : 'pi-angle-down'" />
</div>
</button>
<div v-if="expandedCard === 'dinheiro'" class="border-t border-[var(--surface-border)] px-5 py-5 flex flex-col gap-4">
<div v-if="expandedCard.has('dinheiro')" class="pag-accordion__body">
<div class="flex items-center justify-between">
<div>
<span class="font-medium text-slate-700">Habilitar pagamento em dinheiro</span>
<p class="text-sm text-slate-500 mt-1">
<span class="font-medium text-[var(--text-color)]">Habilitar pagamento em dinheiro</span>
<p class="text-sm text-[var(--text-color-secondary)] mt-1">
Aceitar pagamento em espécie nas sessões presenciais.
</p>
</div>
@@ -388,36 +398,33 @@ onMounted(load)
</div>
<!-- Cartão -->
<div class="rounded-2xl border bg-[var(--surface-card)] overflow-hidden"
<div class="rounded-[6px] border bg-[var(--surface-card)] overflow-hidden"
:class="cfg.cartao_ativo ? 'border-purple-300' : 'border-[var(--surface-border)]'">
<button
type="button"
class="w-full flex items-center justify-between gap-4 px-5 py-4 hover:bg-[var(--surface-hover)] transition text-left"
@click="toggleCard('cartao')"
<button type="button" class="pag-accordion__header" @click="toggleCard('cartao')"
>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl flex items-center justify-center shrink-0"
:class="cfg.cartao_ativo ? 'bg-purple-100 text-purple-700' : 'bg-slate-100 text-slate-400'">
<div class="w-10 h-10 rounded-[6px] flex items-center justify-center shrink-0"
:class="cfg.cartao_ativo ? 'bg-purple-100 text-purple-700' : 'bg-[var(--surface-ground)] text-[var(--text-color-secondary)]'">
<i class="pi pi-credit-card text-lg" />
</div>
<div>
<div class="font-semibold text-slate-800">Cartão (maquininha)</div>
<div class="text-sm text-slate-500">Crédito e débito presencial</div>
<div class="font-semibold text-[var(--text-color)]">Cartão (maquininha)</div>
<div class="text-sm text-[var(--text-color-secondary)]">Crédito e débito presencial</div>
</div>
</div>
<div class="flex items-center gap-3 shrink-0">
<Tag v-if="cfg.cartao_ativo" value="Ativo" severity="success" />
<Tag v-else value="Inativo" severity="secondary" />
<i class="pi text-slate-400" :class="expandedCard === 'cartao' ? 'pi-angle-up' : 'pi-angle-down'" />
<i class="pi text-[var(--text-color-secondary)]" :class="expandedCard.has('cartao') ? 'pi-angle-up' : 'pi-angle-down'" />
</div>
</button>
<div v-if="expandedCard === 'cartao'" class="border-t border-[var(--surface-border)] px-5 py-5 flex flex-col gap-4">
<div v-if="expandedCard.has('cartao')" class="pag-accordion__body">
<div class="flex items-center justify-between">
<div>
<span class="font-medium text-slate-700">Habilitar pagamento por cartão</span>
<p class="text-sm text-slate-500 mt-1">
<span class="font-medium text-[var(--text-color)]">Habilitar pagamento por cartão</span>
<p class="text-sm text-[var(--text-color-secondary)] mt-1">
Aceitar cartão de crédito e débito via maquininha nas sessões presenciais.
</p>
</div>
@@ -426,7 +433,7 @@ onMounted(load)
<template v-if="cfg.cartao_ativo">
<div>
<label class="block text-sm font-medium text-slate-600 mb-1">Instrução ao paciente <span class="text-slate-400">(opcional)</span></label>
<label class="block text-sm font-medium text-[var(--text-color)] mb-1">Instrução ao paciente <span class="text-[var(--text-color-secondary)]">(opcional)</span></label>
<InputText
v-model="cfg.cartao_instrucao"
class="w-full"
@@ -447,22 +454,19 @@ onMounted(load)
</div>
<!-- Plano de Saúde / Convênio -->
<div class="rounded-2xl border bg-[var(--surface-card)] overflow-hidden"
<div class="rounded-[6px] border bg-[var(--surface-card)] overflow-hidden"
:class="cfg.convenio_ativo ? 'border-teal-300' : 'border-[var(--surface-border)]'">
<button
type="button"
class="w-full flex items-center justify-between gap-4 px-5 py-4 hover:bg-[var(--surface-hover)] transition text-left"
@click="toggleCard('convenio')"
<button type="button" class="pag-accordion__header" @click="toggleCard('convenio')"
>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl flex items-center justify-center shrink-0"
:class="cfg.convenio_ativo ? 'bg-teal-100 text-teal-700' : 'bg-slate-100 text-slate-400'">
<div class="w-10 h-10 rounded-[6px] flex items-center justify-center shrink-0"
:class="cfg.convenio_ativo ? 'bg-teal-100 text-teal-700' : 'bg-[var(--surface-ground)] text-[var(--text-color-secondary)]'">
<i class="pi pi-heart text-lg" />
</div>
<div>
<div class="font-semibold text-slate-800">Plano de saúde / Convênio</div>
<div class="text-sm text-slate-500">
<div class="font-semibold text-[var(--text-color)]">Plano de saúde / Convênio</div>
<div class="text-sm text-[var(--text-color-secondary)]">
{{ cfg.convenio_ativo && cfg.convenio_lista ? cfg.convenio_lista.slice(0, 60) + (cfg.convenio_lista.length > 60 ? '…' : '') : 'Atendimento por convênio' }}
</div>
</div>
@@ -470,15 +474,15 @@ onMounted(load)
<div class="flex items-center gap-3 shrink-0">
<Tag v-if="cfg.convenio_ativo" value="Ativo" severity="success" />
<Tag v-else value="Inativo" severity="secondary" />
<i class="pi text-slate-400" :class="expandedCard === 'convenio' ? 'pi-angle-up' : 'pi-angle-down'" />
<i class="pi text-[var(--text-color-secondary)]" :class="expandedCard.has('convenio') ? 'pi-angle-up' : 'pi-angle-down'" />
</div>
</button>
<div v-if="expandedCard === 'convenio'" class="border-t border-[var(--surface-border)] px-5 py-5 flex flex-col gap-4">
<div v-if="expandedCard.has('convenio')" class="pag-accordion__body">
<div class="flex items-center justify-between">
<div>
<span class="font-medium text-slate-700">Aceitar plano de saúde / convênio</span>
<p class="text-sm text-slate-500 mt-1">
<span class="font-medium text-[var(--text-color)]">Aceitar plano de saúde / convênio</span>
<p class="text-sm text-[var(--text-color-secondary)] mt-1">
Habilite para informar quais convênios são aceitos.
</p>
</div>
@@ -487,7 +491,7 @@ onMounted(load)
<template v-if="cfg.convenio_ativo">
<div>
<label class="block text-sm font-medium text-slate-600 mb-1">Convênios aceitos</label>
<label class="block text-sm font-medium text-[var(--text-color)] mb-1">Convênios aceitos</label>
<Textarea
v-model="cfg.convenio_lista"
rows="3"
@@ -495,7 +499,7 @@ onMounted(load)
placeholder="Ex: Unimed, Bradesco Saúde, Amil, SulAmérica..."
autoResize
/>
<small class="text-slate-400">Liste os convênios separados por vírgula ou um por linha.</small>
<small class="text-[var(--text-color-secondary)]">Liste os convênios separados por vírgula ou um por linha.</small>
</div>
</template>
@@ -511,26 +515,23 @@ onMounted(load)
</div>
<!-- Observações gerais -->
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<div class="pag-accordion">
<button
type="button"
class="w-full flex items-center justify-between gap-4 px-5 py-4 hover:bg-[var(--surface-hover)] transition text-left"
@click="toggleCard('observacoes')"
<button type="button" class="pag-accordion__header" @click="toggleCard('observacoes')"
>
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-slate-100 text-slate-400 flex items-center justify-center shrink-0">
<div class="pag-accordion__icon bg-[var(--surface-ground)] text-[var(--text-color-secondary)]">
<i class="pi pi-comment text-lg" />
</div>
<div>
<div class="font-semibold text-slate-800">Observações ao paciente</div>
<div class="text-sm text-slate-500">Texto exibido junto às formas de pagamento</div>
<div class="font-semibold text-[var(--text-color)]">Observações ao paciente</div>
<div class="text-sm text-[var(--text-color-secondary)]">Texto exibido junto às formas de pagamento</div>
</div>
</div>
<i class="pi text-slate-400" :class="expandedCard === 'observacoes' ? 'pi-angle-up' : 'pi-angle-down'" />
<i class="pi text-[var(--text-color-secondary)]" :class="expandedCard.has('observacoes') ? 'pi-angle-up' : 'pi-angle-down'" />
</button>
<div v-if="expandedCard === 'observacoes'" class="border-t border-[var(--surface-border)] px-5 py-5 flex flex-col gap-4">
<div v-if="expandedCard.has('observacoes')" class="pag-accordion__body">
<Textarea
v-model="cfg.observacoes_pagamento"
rows="4"
@@ -551,3 +552,87 @@ onMounted(load)
</div>
</template>
<style scoped>
/* ── Subheader degradê ────────────────────────────── */
.cfg-subheader {
display: flex; align-items: center; gap: 0.65rem;
padding: 0.875rem 1rem; border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--primary-color,#6366f1) 30%, transparent);
background: linear-gradient(135deg,
color-mix(in srgb, var(--primary-color,#6366f1) 12%, var(--surface-card)) 0%,
color-mix(in srgb, var(--primary-color,#6366f1) 4%, var(--surface-card)) 60%,
var(--surface-card) 100%);
position: relative; overflow: hidden;
}
.cfg-subheader::before {
content: ''; position: absolute; top: -20px; right: -20px;
width: 80px; height: 80px; border-radius: 50%;
background: color-mix(in srgb, var(--primary-color,#6366f1) 15%, transparent);
filter: blur(20px); pointer-events: none;
}
.cfg-subheader__icon {
display: grid; place-items: center;
width: 2rem; height: 2rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 20%, transparent);
color: var(--primary-color,#6366f1); font-size: 0.85rem;
}
.cfg-subheader__title { font-size: 0.95rem; font-weight: 700; letter-spacing: -0.01em; color: var(--primary-color,#6366f1); }
.cfg-subheader__sub { font-size: 0.75rem; color: var(--text-color-secondary); opacity: 0.85; }
.cfg-subheader__actions { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; flex-shrink: 0; position: relative; z-index: 1; }
/* ── Card wrap ────────────────────────────────────── */
.cfg-wrap {
border: 1px solid var(--surface-border);
border-radius: 6px; background: var(--surface-card);
overflow: hidden;
}
.cfg-wrap__head {
display: flex; align-items: center; justify-content: space-between;
gap: 0.75rem; padding: 0.75rem 1rem;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-ground);
}
.cfg-wrap__icon {
display: grid; place-items: center;
width: 1.75rem; height: 1.75rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 12%, transparent);
color: var(--primary-color,#6366f1); font-size: 0.8rem;
}
.cfg-wrap__title { font-size: 0.88rem; font-weight: 700; color: var(--text-color); }
.cfg-wrap__body { padding: 1rem; display: flex; flex-direction: column; gap: 0.75rem; }
/* ── Empty state ──────────────────────────────────── */
.cfg-empty {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 0.75rem; padding: 2.5rem 1rem; text-align: center;
color: var(--text-color-secondary);
border: 1px dashed var(--surface-border);
border-radius: 6px; background: var(--surface-ground);
}
/* ── Pagamento accordions ─────────────────────────── */
.pag-accordion {
border: 1px solid var(--surface-border);
border-radius: 6px; background: var(--surface-card);
overflow: hidden; transition: border-color 0.15s;
}
.pag-accordion--on {
border-color: color-mix(in srgb, #22c55e 40%, transparent);
}
.pag-accordion__header {
display: flex; align-items: center; justify-content: space-between;
gap: 1rem; width: 100%; padding: 0.875rem 1rem;
background: transparent; border: none; cursor: pointer;
transition: background 0.12s; text-align: left;
}
.pag-accordion__header:hover { background: var(--surface-hover); }
.pag-accordion__icon {
display: flex; align-items: center; justify-content: center;
width: 2.25rem; height: 2.25rem; border-radius: 6px; flex-shrink: 0;
}
.pag-accordion__body {
border-top: 1px solid var(--surface-border);
padding: 1rem; display: flex; flex-direction: column; gap: 1rem;
}
</style>
@@ -148,32 +148,19 @@ onMounted(async () => {
<template>
<Toast />
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3">
<!-- Header -->
<Card>
<template #content>
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-3">
<div class="cfg-icon-box">
<i class="pi pi-tag text-lg" />
</div>
<div>
<div class="text-900 font-semibold text-lg">Serviços e Precificação</div>
<div class="text-600 text-sm">
Gerencie os serviços que você oferece e seus respectivos preços.
</div>
</div>
</div>
<Button
label="Novo serviço"
icon="pi pi-plus"
:disabled="pageLoading || addingNew"
@click="addingNew = true; cancelEdit()"
/>
</div>
</template>
</Card>
<!-- Subheader -->
<div class="cfg-subheader">
<div class="cfg-subheader__icon"><i class="pi pi-tag" /></div>
<div class="min-w-0">
<div class="cfg-subheader__title">Precificação</div>
<div class="cfg-subheader__sub">Valor padrão da sessão e preços por tipo de compromisso</div>
</div>
<div class="cfg-subheader__actions">
<Button label="Novo serviço" icon="pi pi-plus" size="small" :disabled="pageLoading || addingNew" class="rounded-full" @click="addingNew = true; cancelEdit()" />
</div>
</div>
<!-- Loading -->
<div v-if="pageLoading || loading" class="flex justify-center py-10">
@@ -183,20 +170,16 @@ onMounted(async () => {
<template v-else>
<Message v-if="isDynamic" severity="info" :closable="false">
<span class="text-sm">
Modo <b>dinâmico</b> ativo a duração da sessão é definida pelo serviço selecionado.
</span>
<span class="text-sm">Modo <b>dinâmico</b> ativo a duração da sessão é definida pelo serviço selecionado.</span>
</Message>
<!-- Formulário novo serviço -->
<Card v-if="addingNew">
<template #title>
<div class="flex items-center gap-2">
<i class="pi pi-plus-circle text-primary-500" />
<span>Novo serviço</span>
</div>
</template>
<template #content>
<!-- Form novo serviço -->
<div v-if="addingNew" class="cfg-wrap">
<div class="cfg-wrap__head">
<div class="cfg-wrap__icon"><i class="pi pi-plus" /></div>
<span class="cfg-wrap__title">Novo serviço</span>
</div>
<div class="svc-form">
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
@@ -206,16 +189,7 @@ onMounted(async () => {
</div>
<div class="col-span-12 sm:col-span-3">
<FloatLabel variant="on">
<InputNumber
v-model="newForm.price"
inputId="new-price"
mode="currency"
currency="BRL"
locale="pt-BR"
:min="0"
:minFractionDigits="2"
fluid
/>
<InputNumber v-model="newForm.price" inputId="new-price" mode="currency" currency="BRL" locale="pt-BR" :min="0" :minFractionDigits="2" fluid />
<label for="new-price">Preço (R$) *</label>
</FloatLabel>
</div>
@@ -232,30 +206,59 @@ onMounted(async () => {
</FloatLabel>
</div>
</div>
<div class="flex gap-2 justify-end mt-4">
<Button label="Cancelar" severity="secondary" outlined @click="addingNew = false; newForm = emptyForm()" />
<Button label="Salvar" icon="pi pi-check" :loading="savingNew" @click="saveNew" />
<div class="flex gap-2 justify-end mt-3">
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" @click="addingNew = false; newForm = emptyForm()" />
<Button label="Salvar" icon="pi pi-check" class="rounded-full" :loading="savingNew" @click="saveNew" />
</div>
</template>
</Card>
</div>
</div>
<!-- Lista vazia -->
<Card v-if="!services.length && !addingNew">
<template #content>
<div class="text-center py-6 text-color-secondary">
<i class="pi pi-tag text-4xl opacity-30 mb-3 block" />
<div class="font-medium mb-1">Nenhum serviço cadastrado</div>
<div class="text-sm">Clique em "Novo serviço" para começar.</div>
</div>
</template>
</Card>
<div v-if="!services.length && !addingNew" class="cfg-empty">
<i class="pi pi-tag text-3xl opacity-25" />
<div class="text-sm font-medium">Nenhum serviço cadastrado</div>
<div class="text-xs opacity-70">Clique em "Novo serviço" para começar.</div>
</div>
<!-- Lista de serviços -->
<Card v-for="svc in services" :key="svc.id" :class="{ 'opacity-60': !svc.active }">
<template #content>
<div v-for="svc in services" :key="svc.id" class="cfg-wrap" :class="{ 'opacity-60': !svc.active }">
<!-- Modo edição -->
<template v-if="editingId === svc.id">
<!-- Modo leitura: head clicável -->
<template v-if="editingId !== svc.id">
<div class="svc-row">
<div class="svc-row__icon">
<i class="pi pi-tag" />
</div>
<div class="svc-row__info">
<div class="font-semibold text-sm">{{ svc.name }}</div>
<div class="flex flex-wrap gap-x-3 gap-y-0.5 mt-0.5 text-xs text-[var(--text-color-secondary)]">
<span class="font-semibold text-[var(--primary-color)]">{{ fmtBRL(svc.price) }}</span>
<span v-if="svc.duration_min">{{ svc.duration_min }} min</span>
<span v-if="svc.description" class="italic">{{ svc.description }}</span>
</div>
</div>
<div class="flex items-center gap-1.5 shrink-0">
<Tag :value="svc.active ? 'Ativo' : 'Inativo'" :severity="svc.active ? 'success' : 'secondary'" />
<Button
:icon="svc.active ? 'pi pi-eye-slash' : 'pi pi-eye'"
:severity="svc.active ? 'secondary' : 'success'"
outlined size="small"
v-tooltip.top="svc.active ? 'Desativar' : 'Ativar'"
@click="toggleService(svc)"
/>
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" v-tooltip.top="'Editar'" @click="startEdit(svc)" />
<Button icon="pi pi-trash" severity="danger" outlined size="small" v-tooltip.top="'Remover'" @click="confirmRemove(svc.id)" />
</div>
</div>
</template>
<!-- Modo edição -->
<template v-else>
<div class="cfg-wrap__head">
<div class="cfg-wrap__icon"><i class="pi pi-pencil" /></div>
<span class="cfg-wrap__title">Editar {{ svc.name }}</span>
</div>
<div class="svc-form svc-form--editing">
<div class="grid grid-cols-12 gap-3">
<div class="col-span-12 sm:col-span-4">
<FloatLabel variant="on">
@@ -265,16 +268,7 @@ onMounted(async () => {
</div>
<div class="col-span-12 sm:col-span-3">
<FloatLabel variant="on">
<InputNumber
v-model="editForm.price"
:inputId="`edit-price-${svc.id}`"
mode="currency"
currency="BRL"
locale="pt-BR"
:min="0"
:minFractionDigits="2"
fluid
/>
<InputNumber v-model="editForm.price" :inputId="`edit-price-${svc.id}`" mode="currency" currency="BRL" locale="pt-BR" :min="0" :minFractionDigits="2" fluid />
<label :for="`edit-price-${svc.id}`">Preço (R$) *</label>
</FloatLabel>
</div>
@@ -291,51 +285,17 @@ onMounted(async () => {
</FloatLabel>
</div>
</div>
<div class="flex gap-2 justify-end mt-4">
<Button label="Cancelar" severity="secondary" outlined @click="cancelEdit" />
<Button label="Salvar" icon="pi pi-check" :loading="savingEdit" @click="saveEdit" />
<div class="flex gap-2 justify-end mt-3">
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" @click="cancelEdit" />
<Button label="Salvar" icon="pi pi-check" class="rounded-full" :loading="savingEdit" @click="saveEdit" />
</div>
</template>
<!-- Modo leitura -->
<template v-else>
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="flex items-center gap-3">
<div class="cfg-icon-box-sm">
<i class="pi pi-tag" />
</div>
<div>
<div class="font-semibold text-900">{{ svc.name }}</div>
<div class="text-sm text-color-secondary flex flex-wrap gap-x-3 gap-y-0.5 mt-0.5">
<span><b class="text-primary-500">{{ fmtBRL(svc.price) }}</b></span>
<span v-if="svc.duration_min">{{ svc.duration_min }}min</span>
<span v-if="svc.description" class="italic">{{ svc.description }}</span>
</div>
</div>
</div>
<div class="flex items-center gap-2">
<Tag :value="svc.active ? 'Ativo' : 'Inativo'" :severity="svc.active ? 'success' : 'secondary'" />
<Button
:icon="svc.active ? 'pi pi-eye-slash' : 'pi pi-eye'"
:severity="svc.active ? 'secondary' : 'success'"
outlined
size="small"
v-tooltip.top="svc.active ? 'Desativar' : 'Ativar'"
@click="toggleService(svc)"
/>
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" v-tooltip.top="'Editar'" @click="startEdit(svc)" />
<Button icon="pi pi-trash" severity="danger" outlined size="small" v-tooltip.top="'Remover'" @click="confirmRemove(svc.id)" />
</div>
</div>
</template>
</div>
</template>
</Card>
</div>
<Message severity="info" :closable="false">
<span class="text-sm">
Os serviços cadastrados aqui aparecem para seleção ao criar ou editar um evento na agenda.
</span>
<span class="text-sm">Os serviços cadastrados aqui aparecem para seleção ao criar ou editar um evento na agenda.</span>
</Message>
</template>
@@ -343,25 +303,84 @@ onMounted(async () => {
</template>
<style scoped>
.cfg-icon-box {
display: grid;
place-items: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 0.875rem;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
flex-shrink: 0;
/* ── Subheader degradê ────────────────────────────── */
.cfg-subheader {
display: flex; align-items: center; gap: 0.65rem;
padding: 0.875rem 1rem; border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--primary-color,#6366f1) 30%, transparent);
background: linear-gradient(135deg,
color-mix(in srgb, var(--primary-color,#6366f1) 12%, var(--surface-card)) 0%,
color-mix(in srgb, var(--primary-color,#6366f1) 4%, var(--surface-card)) 60%,
var(--surface-card) 100%);
position: relative; overflow: hidden;
}
.cfg-subheader::before {
content: ''; position: absolute; top: -20px; right: -20px;
width: 80px; height: 80px; border-radius: 50%;
background: color-mix(in srgb, var(--primary-color,#6366f1) 15%, transparent);
filter: blur(20px); pointer-events: none;
}
.cfg-subheader__icon {
display: grid; place-items: center;
width: 2rem; height: 2rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 20%, transparent);
color: var(--primary-color,#6366f1); font-size: 0.85rem;
}
.cfg-subheader__title { font-size: 0.95rem; font-weight: 700; letter-spacing: -0.01em; color: var(--primary-color,#6366f1); }
.cfg-subheader__sub { font-size: 0.75rem; color: var(--text-color-secondary); opacity: 0.85; }
.cfg-subheader__actions { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; flex-shrink: 0; position: relative; z-index: 1; }
/* ── Card wrap ────────────────────────────────────── */
.cfg-wrap {
border: 1px solid var(--surface-border);
border-radius: 6px; background: var(--surface-card);
overflow: hidden;
}
.cfg-wrap__head {
display: flex; align-items: center; gap: 0.625rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-ground);
}
.cfg-wrap__icon {
display: grid; place-items: center;
width: 1.75rem; height: 1.75rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 12%, transparent);
color: var(--primary-color,#6366f1); font-size: 0.8rem;
}
.cfg-wrap__title { font-size: 0.88rem; font-weight: 700; color: var(--text-color); }
/* ── Linha de leitura do serviço ──────────────────── */
.svc-row {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.75rem 1rem; flex-wrap: wrap;
transition: background 0.1s;
}
.svc-row:hover { background: var(--surface-hover); }
.svc-row__icon {
display: grid; place-items: center;
width: 1.75rem; height: 1.75rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color,#6366f1) 10%, transparent);
color: var(--primary-color,#6366f1); font-size: 0.78rem;
}
.svc-row__info { flex: 1; min-width: 0; }
/* ── Form (novo + edição) ─────────────────────────── */
.svc-form {
padding: 1rem;
display: flex; flex-direction: column; gap: 0.75rem;
}
.svc-form--editing {
background: color-mix(in srgb, var(--primary-color,#6366f1) 3%, var(--surface-card));
border-top: 2px solid color-mix(in srgb, var(--primary-color,#6366f1) 40%, transparent);
}
.cfg-icon-box-sm {
display: grid;
place-items: center;
width: 2rem;
height: 2rem;
border-radius: 0.625rem;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 10%, transparent);
color: var(--p-primary-500, #6366f1);
flex-shrink: 0;
/* ── Empty state ──────────────────────────────────── */
.cfg-empty {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 0.75rem; padding: 2.5rem 1rem; text-align: center;
color: var(--text-color-secondary);
border: 1px dashed var(--surface-border);
border-radius: 6px; background: var(--surface-ground);
}
</style>