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