Agenda, Agendador, Configurações
This commit is contained in:
12
src/App.vue
12
src/App.vue
@@ -4,6 +4,8 @@ import { useRoute } from 'vue-router'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
import { useEntitlementsStore } from '@/stores/entitlementsStore'
|
||||
import AjudaDrawer from '@/components/AjudaDrawer.vue'
|
||||
import { fetchDocsForPath } from '@/composables/useAjuda'
|
||||
|
||||
const route = useRoute()
|
||||
const tenantStore = useTenantStore()
|
||||
@@ -114,6 +116,9 @@ onMounted(async () => {
|
||||
|
||||
// snapshot inicial
|
||||
await debugSnapshot('mounted')
|
||||
|
||||
// Carrega docs de ajuda para a rota inicial
|
||||
fetchDocsForPath(route.path)
|
||||
})
|
||||
|
||||
// snapshot a cada navegação (isso é o que vai te salvar)
|
||||
@@ -121,10 +126,17 @@ watch(
|
||||
() => route.fullPath,
|
||||
async (to, from) => {
|
||||
await debugSnapshot(`route change: ${from} -> ${to}`)
|
||||
// Atualiza docs de ajuda ao navegar
|
||||
fetchDocsForPath(route.path)
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
|
||||
<!-- Drawer de ajuda global — fora de qualquer layout, sempre disponível -->
|
||||
<Teleport to="body">
|
||||
<AjudaDrawer />
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -1,17 +0,0 @@
|
||||
pre.app-code {
|
||||
background-color: var(--code-background);
|
||||
margin: 0 0 1rem 0;
|
||||
padding: 0;
|
||||
border-radius: var(--content-border-radius);
|
||||
overflow: auto;
|
||||
|
||||
code {
|
||||
color: var(--code-color);
|
||||
padding: 1rem;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
display: block;
|
||||
font-weight: semibold;
|
||||
font-family: monaco, Consolas, monospace;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
@use './code.scss';
|
||||
@use './flags/flags.css';
|
||||
File diff suppressed because one or more lines are too long
@@ -1,4 +1,3 @@
|
||||
/* You can add global styles to this file, and also import other style files */
|
||||
@use 'primeicons/primeicons.css';
|
||||
@use '@/assets/layout/layout.scss';
|
||||
@use '@/assets/demo/demo.scss';
|
||||
|
||||
716
src/components/AjudaDrawer.vue
Normal file
716
src/components/AjudaDrawer.vue
Normal file
@@ -0,0 +1,716 @@
|
||||
<!-- src/components/AjudaDrawer.vue -->
|
||||
<!-- Painel de ajuda lateral — home com sessão/docs/faq + navegação interna + votação -->
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAjuda } from '@/composables/useAjuda'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const {
|
||||
sessionDocs, sessionFaq,
|
||||
allDocs, allDocsLoading, allDocsHasMore,
|
||||
globalFaq, globalFaqLoading,
|
||||
currentDoc, isHome, isNavigating, navStack,
|
||||
drawerOpen, loading,
|
||||
meusVotos,
|
||||
closeDrawer, navigateToDoc, navBack,
|
||||
votar, loadMoreDocs,
|
||||
} = useAjuda()
|
||||
|
||||
// ── FAQ accordion state ───────────────────────────────────────
|
||||
const faqAbertos = ref({})
|
||||
function toggleFaq (id) { faqAbertos.value[id] = !faqAbertos.value[id] }
|
||||
|
||||
// ── Docs do conteúdo atual (quando navegando) ─────────────────
|
||||
const docAtual = computed(() => currentDoc.value?.docs?.[0] || null)
|
||||
const faqDoDocAtual = computed(() => currentDoc.value?.faqItens || [])
|
||||
const relacionados = computed(() => currentDoc.value?.relatedDocs || [])
|
||||
|
||||
// ── Label do caminho atual para o cabeçalho da sessão ────────
|
||||
const sessionLabel = computed(() => {
|
||||
// Usa o último segmento da rota como label legível
|
||||
const segs = route.path.split('/').filter(Boolean)
|
||||
if (!segs.length) return 'esta página'
|
||||
const last = segs[segs.length - 1]
|
||||
return last.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
|
||||
})
|
||||
|
||||
// ── Votação ───────────────────────────────────────────────────
|
||||
const votandoId = ref(null)
|
||||
|
||||
async function handleVotar (docId, util) {
|
||||
votandoId.value = docId
|
||||
await votar(docId, util)
|
||||
votandoId.value = null
|
||||
}
|
||||
|
||||
function meuVoto (docId) {
|
||||
return meusVotos.value[docId] ?? null
|
||||
}
|
||||
|
||||
// ── Navegação ─────────────────────────────────────────────────
|
||||
function abrirDoc (doc) {
|
||||
faqAbertos.value = {}
|
||||
navigateToDoc(doc.id, doc.titulo)
|
||||
}
|
||||
|
||||
function voltar () {
|
||||
faqAbertos.value = {}
|
||||
navBack()
|
||||
}
|
||||
|
||||
function fechar () {
|
||||
faqAbertos.value = {}
|
||||
closeDrawer()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="ajuda-slide">
|
||||
<div v-if="drawerOpen" class="ajuda-panel">
|
||||
|
||||
<!-- ── Cabeçalho ─────────────────────────────────────── -->
|
||||
<div class="ajuda-header">
|
||||
<div class="flex items-center gap-2 min-w-0 flex-1">
|
||||
<button v-if="isNavigating" class="nav-btn" @click="voltar" title="Voltar">
|
||||
<i class="pi pi-arrow-left text-xs" />
|
||||
</button>
|
||||
<i v-else class="pi pi-question-circle text-blue-500 text-lg shrink-0" />
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span class="font-semibold text-base leading-tight truncate">
|
||||
{{ isNavigating ? (docAtual?.titulo || 'Ajuda') : 'Central de Ajuda' }}
|
||||
</span>
|
||||
<span v-if="isNavigating && navStack.length > 0" class="breadcrumb">
|
||||
<i class="pi pi-chevron-left text-[9px] mr-0.5" />
|
||||
{{ navStack[navStack.length - 1].label || 'Voltar' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="close-btn" @click="fechar">
|
||||
<i class="pi pi-times text-sm" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Corpo ─────────────────────────────────────────── -->
|
||||
<div class="ajuda-body">
|
||||
|
||||
<!-- Loading global -->
|
||||
<div v-if="loading" class="flex justify-center py-10">
|
||||
<i class="pi pi-spinner pi-spin text-xl opacity-40" />
|
||||
</div>
|
||||
|
||||
<!-- ════ VISUALIZAÇÃO DE DOC (navegação interna) ════ -->
|
||||
<template v-else-if="!isHome">
|
||||
|
||||
<div class="doc-view">
|
||||
|
||||
<!-- Conteúdo -->
|
||||
<div v-if="docAtual?.conteudo" class="doc-conteudo ql-content" v-html="docAtual.conteudo" />
|
||||
|
||||
<!-- Mídias -->
|
||||
<template v-if="docAtual?.medias?.length">
|
||||
<div v-for="(m, idx) in docAtual.medias.filter(m => m.url)" :key="idx" class="mt-3">
|
||||
<img v-if="m.tipo === 'imagem'" :src="m.url" :alt="docAtual.titulo" class="doc-img" />
|
||||
<div v-else-if="m.tipo === 'video'" class="doc-video-wrap">
|
||||
<iframe :src="m.url" frameborder="0" allowfullscreen class="doc-iframe" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- FAQ do doc -->
|
||||
<div v-if="faqDoDocAtual.length" class="faq-section">
|
||||
<div class="section-label">
|
||||
<i class="pi pi-comments text-[10px] mr-1" />Perguntas frequentes
|
||||
</div>
|
||||
<div class="faq-list">
|
||||
<div
|
||||
v-for="item in faqDoDocAtual" :key="item.id"
|
||||
class="faq-item" :class="{ 'faq-item--open': faqAbertos[item.id] }"
|
||||
>
|
||||
<button class="faq-pergunta" @click="toggleFaq(item.id)">
|
||||
<span>{{ item.pergunta }}</span>
|
||||
<i class="pi shrink-0 text-xs opacity-50 transition-transform duration-200"
|
||||
:class="faqAbertos[item.id] ? 'pi-chevron-up' : 'pi-chevron-down'" />
|
||||
</button>
|
||||
<Transition name="expand">
|
||||
<div v-if="faqAbertos[item.id] && item.resposta"
|
||||
class="faq-resposta ql-content" v-html="item.resposta" />
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Docs relacionados -->
|
||||
<div v-if="relacionados.length" class="rel-section">
|
||||
<div class="section-label"><i class="pi pi-link text-[10px] mr-1" />Veja também</div>
|
||||
<div class="flex flex-col gap-1 mt-2">
|
||||
<button v-for="rel in relacionados" :key="rel.id" class="rel-btn" @click="abrirDoc(rel)">
|
||||
<i class="pi pi-arrow-right text-xs shrink-0" />
|
||||
<span>{{ rel.titulo }}</span>
|
||||
<i class="pi pi-chevron-right text-[10px] opacity-30 ml-auto shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Votação -->
|
||||
<div v-if="docAtual" class="voto-section">
|
||||
<div class="voto-label">Este documento foi útil?</div>
|
||||
<div class="voto-btns">
|
||||
<button
|
||||
class="voto-btn"
|
||||
:class="{
|
||||
'voto-btn--ativo-sim': meuVoto(docAtual.id) === true,
|
||||
'voto-btn--loading': votandoId === docAtual.id
|
||||
}"
|
||||
:disabled="votandoId === docAtual.id"
|
||||
@click="handleVotar(docAtual.id, true)"
|
||||
>
|
||||
<i class="pi pi-thumbs-up" />
|
||||
<span>Sim</span>
|
||||
<span v-if="docAtual.votos_util" class="voto-count">{{ docAtual.votos_util }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="voto-btn"
|
||||
:class="{
|
||||
'voto-btn--ativo-nao': meuVoto(docAtual.id) === false,
|
||||
'voto-btn--loading': votandoId === docAtual.id
|
||||
}"
|
||||
:disabled="votandoId === docAtual.id"
|
||||
@click="handleVotar(docAtual.id, false)"
|
||||
>
|
||||
<i class="pi pi-thumbs-down" />
|
||||
<span>Não</span>
|
||||
<span v-if="docAtual.votos_nao_util" class="voto-count">{{ docAtual.votos_nao_util }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ════ HOME ════ -->
|
||||
<template v-else>
|
||||
|
||||
<!-- Ícone de boas-vindas -->
|
||||
<div class="home-welcome">
|
||||
<div class="home-icon">
|
||||
<i class="pi pi-book text-2xl" />
|
||||
</div>
|
||||
<p class="home-welcome-text">Como podemos ajudar?</p>
|
||||
</div>
|
||||
|
||||
<!-- Seção: Documentos desta sessão -->
|
||||
<section class="home-section">
|
||||
<div class="section-title">
|
||||
<i class="pi pi-map-marker text-[11px] mr-1.5 opacity-60" />
|
||||
Documentos desta página
|
||||
</div>
|
||||
|
||||
<div v-if="!sessionDocs.length" class="empty-msg">
|
||||
<i class="pi pi-info-circle mr-1 opacity-40" />
|
||||
Ainda não há documentos para esta página.
|
||||
</div>
|
||||
|
||||
<div v-else class="doc-list">
|
||||
<button
|
||||
v-for="doc in sessionDocs" :key="doc.id"
|
||||
class="doc-card"
|
||||
@click="abrirDoc(doc)"
|
||||
>
|
||||
<div class="doc-card-icon">
|
||||
<i class="pi pi-file-edit text-xs" />
|
||||
</div>
|
||||
<div class="doc-card-body">
|
||||
<span class="doc-card-titulo">{{ doc.titulo }}</span>
|
||||
<span v-if="doc.categoria" class="doc-card-cat">{{ doc.categoria }}</span>
|
||||
</div>
|
||||
<i class="pi pi-chevron-right text-[11px] opacity-30 shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Seção: Outros documentos -->
|
||||
<section class="home-section">
|
||||
<div class="section-title">
|
||||
<i class="pi pi-book text-[11px] mr-1.5 opacity-60" />
|
||||
Outros documentos
|
||||
</div>
|
||||
|
||||
<div v-if="allDocsLoading && !allDocs.length" class="flex justify-center py-4">
|
||||
<i class="pi pi-spinner pi-spin opacity-30" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="!allDocs.length" class="empty-msg">
|
||||
<i class="pi pi-info-circle mr-1 opacity-40" />
|
||||
Nenhum outro documento disponível.
|
||||
</div>
|
||||
|
||||
<div v-else class="doc-list">
|
||||
<button
|
||||
v-for="doc in allDocs" :key="doc.id"
|
||||
class="doc-card"
|
||||
@click="abrirDoc(doc)"
|
||||
>
|
||||
<div class="doc-card-icon">
|
||||
<i class="pi pi-file text-xs" />
|
||||
</div>
|
||||
<div class="doc-card-body">
|
||||
<span class="doc-card-titulo">{{ doc.titulo }}</span>
|
||||
<span v-if="doc.categoria" class="doc-card-cat">{{ doc.categoria }}</span>
|
||||
</div>
|
||||
<i class="pi pi-chevron-right text-[11px] opacity-30 shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Carregar mais -->
|
||||
<button
|
||||
v-if="allDocsHasMore"
|
||||
class="load-more-btn"
|
||||
:disabled="allDocsLoading"
|
||||
@click="loadMoreDocs"
|
||||
>
|
||||
<i v-if="allDocsLoading" class="pi pi-spinner pi-spin mr-1" />
|
||||
<i v-else class="pi pi-chevron-down mr-1" />
|
||||
Carregar mais
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<!-- Seção: Perguntas frequentes -->
|
||||
<section class="home-section">
|
||||
<div class="section-title">
|
||||
<i class="pi pi-comments text-[11px] mr-1.5 opacity-60" />
|
||||
Perguntas frequentes
|
||||
</div>
|
||||
|
||||
<!-- FAQ da sessão atual -->
|
||||
<template v-if="sessionFaq.length">
|
||||
<div class="faq-subsection-label">Desta página</div>
|
||||
<div class="faq-list">
|
||||
<div
|
||||
v-for="item in sessionFaq" :key="item.id"
|
||||
class="faq-item" :class="{ 'faq-item--open': faqAbertos[item.id] }"
|
||||
>
|
||||
<button class="faq-pergunta" @click="toggleFaq(item.id)">
|
||||
<span>{{ item.pergunta }}</span>
|
||||
<i class="pi shrink-0 text-xs opacity-50 transition-transform duration-200"
|
||||
:class="faqAbertos[item.id] ? 'pi-chevron-up' : 'pi-chevron-down'" />
|
||||
</button>
|
||||
<Transition name="expand">
|
||||
<div v-if="faqAbertos[item.id] && item.resposta"
|
||||
class="faq-resposta ql-content" v-html="item.resposta" />
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- FAQ global (outros docs) -->
|
||||
<div v-if="globalFaqLoading && !globalFaq.length" class="flex justify-center py-3">
|
||||
<i class="pi pi-spinner pi-spin opacity-30 text-sm" />
|
||||
</div>
|
||||
|
||||
<template v-if="globalFaq.length">
|
||||
<div v-if="sessionFaq.length" class="faq-subsection-label mt-3">Outros tópicos</div>
|
||||
<div class="faq-list">
|
||||
<div
|
||||
v-for="item in globalFaq" :key="item.id"
|
||||
class="faq-item" :class="{ 'faq-item--open': faqAbertos[item.id] }"
|
||||
>
|
||||
<button class="faq-pergunta" @click="toggleFaq(item.id)">
|
||||
<div class="flex flex-col gap-0.5 min-w-0 text-left">
|
||||
<span>{{ item.pergunta }}</span>
|
||||
<span v-if="item._docTitulo" class="faq-doc-label">{{ item._docTitulo }}</span>
|
||||
</div>
|
||||
<i class="pi shrink-0 text-xs opacity-50 transition-transform duration-200"
|
||||
:class="faqAbertos[item.id] ? 'pi-chevron-up' : 'pi-chevron-down'" />
|
||||
</button>
|
||||
<Transition name="expand">
|
||||
<div v-if="faqAbertos[item.id] && item.resposta"
|
||||
class="faq-resposta ql-content" v-html="item.resposta" />
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="!sessionFaq.length && !globalFaq.length && !globalFaqLoading" class="empty-msg">
|
||||
<i class="pi pi-info-circle mr-1 opacity-40" />
|
||||
Ainda não há perguntas cadastradas.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── Painel ──────────────────────────────────────────────────── */
|
||||
.ajuda-panel {
|
||||
position: fixed;
|
||||
top: 0; right: 0;
|
||||
width: 400px;
|
||||
height: 100vh;
|
||||
z-index: 998;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface-card);
|
||||
border-left: 1px solid var(--surface-border);
|
||||
box-shadow: -4px 0 16px rgba(0,0,0,0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ajuda-slide-enter-active,
|
||||
.ajuda-slide-leave-active { transition: transform 0.3s ease; }
|
||||
.ajuda-slide-enter-from,
|
||||
.ajuda-slide-leave-to { transform: translateX(100%); }
|
||||
|
||||
/* ── Cabeçalho ──────────────────────────────────────────────── */
|
||||
.ajuda-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
flex-shrink: 0;
|
||||
background: var(--surface-card);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-btn, .close-btn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-ground);
|
||||
color: var(--text-color-secondary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.close-btn { border: none; background: transparent; }
|
||||
.nav-btn:hover, .close-btn:hover { background: var(--surface-hover); }
|
||||
|
||||
.breadcrumb {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.65;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* ── Corpo ───────────────────────────────────────────────────── */
|
||||
.ajuda-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── Home ────────────────────────────────────────────────────── */
|
||||
.home-welcome {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 1.75rem 1.25rem 1.25rem;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
.home-icon {
|
||||
width: 56px; height: 56px;
|
||||
border-radius: 16px;
|
||||
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
|
||||
color: var(--primary-color);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.home-welcome-text {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Seções ──────────────────────────────────────────────────── */
|
||||
.home-section {
|
||||
padding: 0 1.25rem 1.25rem;
|
||||
border-top: 1px solid var(--surface-border);
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.7;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0.625rem;
|
||||
}
|
||||
|
||||
.empty-msg {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.375rem 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── Cards de documento ──────────────────────────────────────── */
|
||||
.doc-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.doc-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border-radius: 0.625rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.doc-card:hover { background: var(--surface-hover); }
|
||||
|
||||
.doc-card-icon {
|
||||
width: 28px; height: 28px;
|
||||
border-radius: 7px;
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
color: var(--primary-color);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.doc-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.doc-card-titulo {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.doc-card-cat {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Carregar mais */
|
||||
.load-more-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--primary-color);
|
||||
background: transparent;
|
||||
border: 1px dashed color-mix(in srgb, var(--primary-color) 30%, transparent);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.load-more-btn:hover:not(:disabled) { background: color-mix(in srgb, var(--primary-color) 6%, transparent); }
|
||||
.load-more-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* ── Visualização de doc ─────────────────────────────────────── */
|
||||
.doc-view {
|
||||
padding: 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.doc-conteudo {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color-secondary);
|
||||
line-height: 1.65;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Quill styles compartilhados */
|
||||
.doc-conteudo.ql-content :deep(p),
|
||||
.faq-resposta.ql-content :deep(p) { margin: 0 0 0.5rem; }
|
||||
.doc-conteudo.ql-content :deep(p:last-child),
|
||||
.faq-resposta.ql-content :deep(p:last-child) { margin-bottom: 0; }
|
||||
.doc-conteudo.ql-content :deep(strong),
|
||||
.faq-resposta.ql-content :deep(strong) { font-weight: 600; color: var(--text-color); }
|
||||
.doc-conteudo.ql-content :deep(em),
|
||||
.faq-resposta.ql-content :deep(em) { font-style: italic; }
|
||||
.doc-conteudo.ql-content :deep(h1),
|
||||
.faq-resposta.ql-content :deep(h1) { font-size: 1.1rem; font-weight: 700; color: var(--text-color); margin: 0.75rem 0 0.35rem; }
|
||||
.doc-conteudo.ql-content :deep(h2),
|
||||
.faq-resposta.ql-content :deep(h2) { font-size: 1rem; font-weight: 700; color: var(--text-color); margin: 0.65rem 0 0.3rem; }
|
||||
.doc-conteudo.ql-content :deep(h3),
|
||||
.faq-resposta.ql-content :deep(h3) { font-size: 0.9rem; font-weight: 600; color: var(--text-color); margin: 0.5rem 0 0.25rem; }
|
||||
.doc-conteudo.ql-content :deep(ul),
|
||||
.faq-resposta.ql-content :deep(ul),
|
||||
.doc-conteudo.ql-content :deep(ol),
|
||||
.faq-resposta.ql-content :deep(ol) { padding-left: 1.25rem; margin: 0.4rem 0; }
|
||||
.doc-conteudo.ql-content :deep(li),
|
||||
.faq-resposta.ql-content :deep(li) { margin-bottom: 0.2rem; }
|
||||
.doc-conteudo.ql-content :deep(a),
|
||||
.faq-resposta.ql-content :deep(a) { color: var(--primary-color); text-decoration: underline; }
|
||||
.doc-conteudo.ql-content :deep(blockquote),
|
||||
.faq-resposta.ql-content :deep(blockquote) {
|
||||
border-left: 3px solid var(--surface-border);
|
||||
margin: 0.5rem 0; padding: 0.25rem 0.75rem;
|
||||
color: var(--text-color-secondary); font-style: italic;
|
||||
}
|
||||
.doc-conteudo.ql-content :deep(pre),
|
||||
.faq-resposta.ql-content :deep(pre) {
|
||||
background: var(--surface-ground);
|
||||
border-radius: 0.5rem; padding: 0.5rem 0.75rem;
|
||||
font-size: 0.8rem; overflow-x: auto; white-space: pre-wrap; word-break: break-all;
|
||||
}
|
||||
|
||||
/* ── Mídias ──────────────────────────────────────────────────── */
|
||||
.doc-img {
|
||||
width: 100%; max-width: 100%; height: auto;
|
||||
border-radius: 0.75rem; border: 1px solid var(--surface-border); display: block;
|
||||
}
|
||||
.doc-video-wrap {
|
||||
position: relative; width: 100%; padding-top: 56.25%;
|
||||
border-radius: 0.75rem; overflow: hidden; border: 1px solid var(--surface-border);
|
||||
}
|
||||
.doc-iframe { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
|
||||
/* ── FAQ accordion ───────────────────────────────────────────── */
|
||||
.faq-section {
|
||||
border-top: 1px solid var(--surface-border);
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
.section-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.65;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.faq-subsection-label {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.5;
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
.faq-list { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
.faq-item {
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
background: var(--surface-ground);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.faq-item--open { border-color: color-mix(in srgb, var(--primary-color) 30%, transparent); }
|
||||
.faq-pergunta {
|
||||
width: 100%;
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
gap: 0.5rem; padding: 0.5rem 0.625rem;
|
||||
font-size: 0.8rem; font-weight: 500; color: var(--text-color);
|
||||
background: transparent; border: none; cursor: pointer; text-align: left;
|
||||
transition: background 0.15s; line-height: 1.4;
|
||||
}
|
||||
.faq-pergunta:hover { background: var(--surface-hover); }
|
||||
.faq-resposta {
|
||||
padding: 0 0.625rem 0.5rem;
|
||||
font-size: 0.8rem; color: var(--text-color-secondary);
|
||||
line-height: 1.6; word-break: break-word;
|
||||
}
|
||||
.faq-doc-label {
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.55;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ── Relacionados ────────────────────────────────────────────── */
|
||||
.rel-section { border-top: 1px solid var(--surface-border); padding-top: 0.75rem; }
|
||||
.rel-btn {
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
padding: 0.5rem 0.625rem; border-radius: 0.5rem;
|
||||
font-size: 0.875rem; text-align: left;
|
||||
color: var(--primary-color); background: transparent;
|
||||
border: none; cursor: pointer; transition: background 0.15s; width: 100%;
|
||||
}
|
||||
.rel-btn span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
||||
.rel-btn:hover { background: var(--surface-hover); }
|
||||
|
||||
/* ── Votação ─────────────────────────────────────────────────── */
|
||||
.voto-section {
|
||||
border-top: 1px solid var(--surface-border);
|
||||
padding-top: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
.voto-label {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.75;
|
||||
}
|
||||
.voto-btns { display: flex; gap: 0.5rem; }
|
||||
.voto-btn {
|
||||
display: flex; align-items: center; gap: 0.375rem;
|
||||
padding: 0.375rem 0.875rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.8rem; font-weight: 500;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-ground);
|
||||
color: var(--text-color-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.voto-btn:hover:not(:disabled) {
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.voto-btn--ativo-sim {
|
||||
background: color-mix(in srgb, #22c55e 12%, transparent);
|
||||
border-color: #22c55e;
|
||||
color: #16a34a;
|
||||
}
|
||||
.voto-btn--ativo-nao {
|
||||
background: color-mix(in srgb, #ef4444 10%, transparent);
|
||||
border-color: #ef4444;
|
||||
color: #dc2626;
|
||||
}
|
||||
.voto-btn--loading { opacity: 0.5; cursor: not-allowed; }
|
||||
.voto-count {
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.65;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ── Expand transition ───────────────────────────────────────── */
|
||||
.expand-enter-active,
|
||||
.expand-leave-active {
|
||||
transition: opacity 0.2s ease, max-height 0.25s ease;
|
||||
max-height: 600px; overflow: hidden;
|
||||
}
|
||||
.expand-enter-from,
|
||||
.expand-leave-to { opacity: 0; max-height: 0; }
|
||||
</style>
|
||||
@@ -1,156 +0,0 @@
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
header: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
code: null,
|
||||
recent: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
free: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
containerClass: null,
|
||||
previewStyle: null
|
||||
});
|
||||
|
||||
const BlockView = reactive({
|
||||
PREVIEW: 0,
|
||||
CODE: 1
|
||||
});
|
||||
const blockView = ref(0);
|
||||
const codeCopied = ref(false);
|
||||
const codeCopyLoading = ref(false);
|
||||
|
||||
function activateView(event, blockViewValue) {
|
||||
blockView.value = blockViewValue;
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
async function copyCode(event) {
|
||||
if (codeCopied.value || codeCopyLoading.value) return;
|
||||
|
||||
codeCopyLoading.value = true;
|
||||
event.preventDefault();
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.code);
|
||||
codeCopyLoading.value = false;
|
||||
codeCopied.value = true;
|
||||
setTimeout(() => {
|
||||
codeCopied.value = false;
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('Clipboard write failed:', err);
|
||||
codeCopyLoading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mb-16 overflow-hidden">
|
||||
<div class="flex flex-col lg:flex-row justify-between py-4 gap-4 lg:gap-2 px-0!">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-xl">{{ header }}</span>
|
||||
<span v-if="free" class="flex items-center justify-center px-1.5 py-1 w-fit bg-emerald-500 text-emerald-100 dark:bg-emerald-400 dark:text-emerald-800 rounded-md leading-none! text-xs md:text-sm">Free</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Preview/Code Toggle -->
|
||||
<div class="inline-flex border border-surface-200 dark:border-surface-700 rounded-2xl overflow-hidden min-h-10">
|
||||
<button
|
||||
:class="[
|
||||
'min-w-28 flex items-center gap-1 justify-center px-4 py-2 rounded-2xl transition-all duration-200 font-medium cursor-pointer ',
|
||||
blockView === BlockView.CODE ? 'bg-primary text-primary-contrast ' : 'text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-0'
|
||||
]"
|
||||
@click="activateView($event, BlockView.CODE)"
|
||||
>
|
||||
<span>Code</span>
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
'min-w-28 flex items-center gap-1 justify-center px-4 py-2 rounded-2xl transition-all duration-200 font-medium cursor-pointer',
|
||||
blockView === BlockView.PREVIEW ? 'bg-primary text-primary-contrast ' : 'text-surface-600 dark:text-surface-400 hover:text-surface-900 dark:hover:text-surface-0'
|
||||
]"
|
||||
@click="activateView($event, BlockView.PREVIEW)"
|
||||
>
|
||||
<span>Preview</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Separator -->
|
||||
<div class="h-6 w-px bg-surface-200 dark:bg-surface-700 hidden lg:block"></div>
|
||||
|
||||
<!-- Animated Copy Button -->
|
||||
<div class="flex items-center gap-2 grow lg:grow-0 justify-end lg:justify-start">
|
||||
<button
|
||||
@click="copyCode($event)"
|
||||
:disabled="codeCopyLoading"
|
||||
class="relative w-10 h-10 border border-surface-200 dark:border-surface-700 rounded-full hover:bg-surface-100 dark:hover:bg-surface-800 transition-all focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-surface-0 dark:focus-visible:ring-offset-surface-900 cursor-pointer disabled:cursor-wait"
|
||||
>
|
||||
<!-- Loading Spinner -->
|
||||
<span :class="['absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transition-all duration-300 leading-none', codeCopyLoading ? 'opacity-100 scale-100 z-10' : 'opacity-0 scale-50 -z-[2]']">
|
||||
<i class="pi pi-spinner animate-spin text-surface-700 dark:text-surface-300" style="font-size: 1.25rem"></i>
|
||||
</span>
|
||||
|
||||
<!-- Checkmark Icon -->
|
||||
<span :class="['absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transition-all duration-300 leading-none', codeCopied && !codeCopyLoading ? 'opacity-100 scale-100 z-10' : 'opacity-0 scale-50 -z-[2]']">
|
||||
<svg class="w-5 h-5 fill-green-600 dark:fill-green-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<g id="check">
|
||||
<path d="M9,18.25A.74.74,0,0,1,8.47,18l-5-5A.75.75,0,1,1,4.53,12L9,16.44,19.47,6A.75.75,0,0,1,20.53,7l-11,11A.74.74,0,0,1,9,18.25Z"></path>
|
||||
</g>
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
<!-- Copy Icon -->
|
||||
<span :class="['absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transition-all duration-300 leading-none', !codeCopied && !codeCopyLoading ? 'opacity-100 scale-100 z-10' : 'opacity-0 scale-50 -z-[2]']">
|
||||
<svg class="w-5 h-5 fill-surface-700 dark:fill-surface-300" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<g id="clone">
|
||||
<path
|
||||
d="M14,16.75H6A2.75,2.75,0,0,1,3.25,14V6A2.75,2.75,0,0,1,6,3.25h8A2.75,2.75,0,0,1,16.75,6v8A2.75,2.75,0,0,1,14,16.75Zm-8-12A1.25,1.25,0,0,0,4.75,6v8A1.25,1.25,0,0,0,6,15.25h8A1.25,1.25,0,0,0,15.25,14V6A1.25,1.25,0,0,0,14,4.75Z"
|
||||
></path>
|
||||
<path d="M18,20.75H10A2.75,2.75,0,0,1,7.25,18V16h1.5v2A1.25,1.25,0,0,0,10,19.25h8A1.25,1.25,0,0,0,19.25,18V10A1.25,1.25,0,0,0,18,8.75H16V7.25h2A2.75,2.75,0,0,1,20.75,10v8A2.75,2.75,0,0,1,18,20.75Z"></path>
|
||||
</g>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-0 border border-surface-200 dark:border-surface-700 rounded-xl overflow-hidden">
|
||||
<div :class="containerClass" :style="previewStyle" v-if="blockView == BlockView.PREVIEW">
|
||||
<slot />
|
||||
</div>
|
||||
<div v-if="blockView === BlockView.CODE">
|
||||
<pre class="app-code"><code>{{code}}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
pre {
|
||||
border: 0 none !important;
|
||||
border-radius: 0 !important;
|
||||
.app-code {
|
||||
background: var(--p-surface-900) !important;
|
||||
margin: 0 !important;
|
||||
border: 0 none !important;
|
||||
&:before,
|
||||
&:after {
|
||||
display: none !important;
|
||||
}
|
||||
code {
|
||||
color: var(--p-surface-50);
|
||||
padding: 1rem;
|
||||
line-height: 1.5;
|
||||
display: block;
|
||||
font-family: monaco, Consolas, monospace;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,21 +0,0 @@
|
||||
<script setup>
|
||||
import AppConfigurator from '@/layout/AppConfigurator.vue';
|
||||
import { useLayout } from '@/layout/composables/layout';
|
||||
|
||||
const { toggleDarkMode, isDarkTheme } = useLayout();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed flex gap-4 top-8 right-8">
|
||||
<Button type="button" @click="toggleDarkMode" rounded :icon="isDarkTheme ? 'pi pi-moon' : 'pi pi-sun'" severity="secondary" />
|
||||
<div class="relative">
|
||||
<Button
|
||||
icon="pi pi-palette"
|
||||
v-styleclass="{ selector: '@next', enterFromClass: 'hidden', enterActiveClass: 'animate-scalein', leaveToClass: 'hidden', leaveActiveClass: 'animate-fadeout', hideOnOutsideClick: true }"
|
||||
type="button"
|
||||
rounded
|
||||
/>
|
||||
<AppConfigurator />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -221,7 +221,7 @@ function saveCustom() {
|
||||
<div class="flex gap-3">
|
||||
<div class="flex-1 flex flex-col gap-1">
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">Início</label>
|
||||
<DatePicker v-model="form.inicio" showIcon fluid iconDisplay="input" timeOnly hourFormat="24">
|
||||
<DatePicker v-model="form.inicio" showIcon fluid iconDisplay="input" timeOnly hourFormat="24" :stepMinute="15" :manualInput="false">
|
||||
<template #inputicon="slotProps">
|
||||
<i class="pi pi-clock" @click="slotProps.clickCallback" />
|
||||
</template>
|
||||
@@ -229,7 +229,7 @@ function saveCustom() {
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col gap-1">
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">Fim</label>
|
||||
<DatePicker v-model="form.fim" showIcon fluid iconDisplay="input" timeOnly hourFormat="24">
|
||||
<DatePicker v-model="form.fim" showIcon fluid iconDisplay="input" timeOnly hourFormat="24" :stepMinute="15" :manualInput="false">
|
||||
<template #inputicon="slotProps">
|
||||
<i class="pi pi-clock" @click="slotProps.clickCallback" />
|
||||
</template>
|
||||
|
||||
94
src/composables/Usedocshealth.js
Normal file
94
src/composables/Usedocshealth.js
Normal file
@@ -0,0 +1,94 @@
|
||||
// src/composables/useDocsHealth.js
|
||||
// Singleton que computa métricas de saúde dos documentos.
|
||||
// Critério de "atenção": mais de 30% dos votos são negativos
|
||||
// (mínimo 3 votos totais para evitar falso-positivo com 1 voto negativo).
|
||||
//
|
||||
// O `countAtencao` é exportado como singleton para uso no menu SaaS:
|
||||
//
|
||||
// import { countAtencao } from '@/composables/useDocsHealth'
|
||||
// // No lugar onde saasMenu() é chamado:
|
||||
// saasMenu(sessionCtx, { mismatchCount, docsAtencaoCount: countAtencao.value })
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const THRESHOLD_NEGATIVO = 0.30 // 30%
|
||||
const MIN_VOTOS = 3 // mínimo de votos para considerar
|
||||
|
||||
// Estado singleton — alimentado pelo SaasDocsPage após load()
|
||||
const _docs = ref([])
|
||||
|
||||
// ── Classificação individual (exportada standalone p/ uso externo) ─
|
||||
export function saudeDocItem (doc) {
|
||||
const total = (doc.votos_util || 0) + (doc.votos_nao_util || 0)
|
||||
if (total < MIN_VOTOS) return 'sem_dados'
|
||||
const pctNeg = (doc.votos_nao_util || 0) / total
|
||||
return pctNeg > THRESHOLD_NEGATIVO ? 'atencao' : 'ok'
|
||||
}
|
||||
|
||||
// ── Singleton reativo — use no menu ou em qualquer lugar ──────
|
||||
export const countAtencao = computed(() =>
|
||||
_docs.value.filter(d => saudeDocItem(d) === 'atencao').length
|
||||
)
|
||||
|
||||
export function useDocsHealth () {
|
||||
|
||||
function setDocs (docs) {
|
||||
_docs.value = docs
|
||||
}
|
||||
|
||||
function saudeDoc (doc) {
|
||||
return saudeDocItem(doc)
|
||||
}
|
||||
|
||||
function pctNegativo (doc) {
|
||||
const total = (doc.votos_util || 0) + (doc.votos_nao_util || 0)
|
||||
if (!total) return 0
|
||||
return Math.round(((doc.votos_nao_util || 0) / total) * 100)
|
||||
}
|
||||
|
||||
// ── Métricas globais ───────────────────────────────────────
|
||||
const totalDocs = computed(() => _docs.value.length)
|
||||
const docsAtencao = computed(() => _docs.value.filter(d => saudeDoc(d) === 'atencao'))
|
||||
const docsOk = computed(() => _docs.value.filter(d => saudeDoc(d) === 'ok'))
|
||||
const docsSemDados = computed(() => _docs.value.filter(d => saudeDoc(d) === 'sem_dados'))
|
||||
|
||||
// Doc mais útil (maior % positivo com mínimo de votos)
|
||||
const docMaisUtil = computed(() => {
|
||||
const comVotos = _docs.value.filter(d =>
|
||||
(d.votos_util || 0) + (d.votos_nao_util || 0) >= MIN_VOTOS
|
||||
)
|
||||
if (!comVotos.length) return null
|
||||
return comVotos.reduce((best, d) => {
|
||||
const pct = (d.votos_util || 0) / ((d.votos_util || 0) + (d.votos_nao_util || 0))
|
||||
const bestPct = (best.votos_util || 0) / ((best.votos_util || 0) + (best.votos_nao_util || 0))
|
||||
return pct > bestPct ? d : best
|
||||
})
|
||||
})
|
||||
|
||||
// ── Ordenação por saúde ────────────────────────────────────
|
||||
// Problemáticas primeiro → ok → sem dados
|
||||
function sortBySaude (lista) {
|
||||
const ordem = { atencao: 0, ok: 1, sem_dados: 2 }
|
||||
return [...lista].sort((a, b) => {
|
||||
const sa = saudeDoc(a)
|
||||
const sb = saudeDoc(b)
|
||||
if (ordem[sa] !== ordem[sb]) return ordem[sa] - ordem[sb]
|
||||
return pctNegativo(b) - pctNegativo(a)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
setDocs,
|
||||
saudeDoc,
|
||||
pctNegativo,
|
||||
totalDocs,
|
||||
docsAtencao,
|
||||
docsOk,
|
||||
docsSemDados,
|
||||
countAtencao,
|
||||
docMaisUtil,
|
||||
sortBySaude,
|
||||
THRESHOLD_NEGATIVO,
|
||||
MIN_VOTOS,
|
||||
}
|
||||
}
|
||||
354
src/composables/useAjuda.js
Normal file
354
src/composables/useAjuda.js
Normal file
@@ -0,0 +1,354 @@
|
||||
// src/composables/useAjuda.js
|
||||
// Composable singleton para o drawer de ajuda.
|
||||
// - Home: docs da sessão atual + outros docs paginados + FAQ
|
||||
// - Navegação interna com stack (voltar)
|
||||
// - Votação por usuário (útil / não útil)
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { sessionRole, sessionIsSaasAdmin } from '@/app/session'
|
||||
|
||||
const ADMIN_ROLES = ['clinic_admin', 'tenant_admin']
|
||||
|
||||
function isCurrentUserAdmin () {
|
||||
return sessionIsSaasAdmin.value || ADMIN_ROLES.includes(sessionRole.value)
|
||||
}
|
||||
|
||||
// ── Singleton state ────────────────────────────────────────────
|
||||
const cache = new Map() // path → { docs, relatedDocs, faqItens }
|
||||
const docCache = new Map() // id → { docs, relatedDocs, faqItens }
|
||||
|
||||
// Estado da sessão atual
|
||||
const sessionDocs = ref([])
|
||||
const sessionFaq = ref([])
|
||||
const sessionPath = ref('')
|
||||
|
||||
// Estado da home (todos os docs paginados + FAQ global)
|
||||
const allDocs = ref([])
|
||||
const allDocsTotal = ref(0)
|
||||
const allDocsPage = ref(0)
|
||||
const allDocsLoading = ref(false)
|
||||
const ALL_DOCS_PAGE_SIZE = 8
|
||||
|
||||
const globalFaq = ref([])
|
||||
const globalFaqLoading = ref(false)
|
||||
|
||||
// Drawer
|
||||
const drawerOpen = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
// Stack de navegação — cada entrada: { currentDoc, label }
|
||||
const navStack = ref([])
|
||||
// null = home, objeto = doc aberto
|
||||
const currentDoc = ref(null)
|
||||
|
||||
const isHome = computed(() => currentDoc.value === null)
|
||||
const isNavigating = computed(() => navStack.value.length > 0)
|
||||
|
||||
// Votos do usuário { [docId]: true | false | null }
|
||||
const meusVotos = ref({})
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────
|
||||
function normalizePath (path) {
|
||||
return String(path || '').split('?')[0].split('#')[0].replace(/\/$/, '') || '/'
|
||||
}
|
||||
|
||||
async function fetchFaqItensForDocs (docIds) {
|
||||
if (!docIds.length) return []
|
||||
const { data } = await supabase
|
||||
.from('saas_faq_itens')
|
||||
.select('id, doc_id, pergunta, resposta, ordem')
|
||||
.in('doc_id', docIds)
|
||||
.eq('ativo', true)
|
||||
.order('doc_id')
|
||||
.order('ordem')
|
||||
return data || []
|
||||
}
|
||||
|
||||
// ── Fetch por rota (chamado pelo AppLayout ao mudar de rota) ──
|
||||
export async function fetchDocsForPath (rawPath) {
|
||||
const path = normalizePath(rawPath)
|
||||
sessionPath.value = path
|
||||
currentDoc.value = null
|
||||
navStack.value = []
|
||||
// Reseta paginação de outros docs ao mudar de rota
|
||||
allDocs.value = []
|
||||
allDocsPage.value = 0
|
||||
|
||||
if (cache.has(path)) {
|
||||
const cached = cache.get(path)
|
||||
sessionDocs.value = cached.docs
|
||||
sessionFaq.value = cached.faqItens
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('saas_docs')
|
||||
.select('id, titulo, conteudo, medias, tipo_acesso, docs_relacionados, ordem, categoria, exibir_no_faq, votos_util, votos_nao_util')
|
||||
.eq('pagina_path', path)
|
||||
.eq('ativo', true)
|
||||
.order('ordem')
|
||||
|
||||
if (error) throw error
|
||||
|
||||
const userIsAdmin = isCurrentUserAdmin()
|
||||
const mainDocs = (data || []).filter(d =>
|
||||
d.tipo_acesso === 'usuario' || userIsAdmin
|
||||
)
|
||||
|
||||
const allIds = [...new Set(mainDocs.flatMap(d => d.docs_relacionados || []))]
|
||||
let related = []
|
||||
if (allIds.length) {
|
||||
const { data: relData } = await supabase
|
||||
.from('saas_docs')
|
||||
.select('id, titulo, pagina_path')
|
||||
.in('id', allIds)
|
||||
.eq('ativo', true)
|
||||
related = relData || []
|
||||
}
|
||||
|
||||
const itens = await fetchFaqItensForDocs(mainDocs.map(d => d.id))
|
||||
|
||||
cache.set(path, { docs: mainDocs, relatedDocs: related, faqItens: itens })
|
||||
sessionDocs.value = mainDocs
|
||||
sessionFaq.value = itens
|
||||
} catch (e) {
|
||||
console.warn('[useAjuda] erro ao buscar docs:', e?.message || e)
|
||||
sessionDocs.value = []
|
||||
sessionFaq.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Todos os docs paginados (para a home do drawer) ───────────
|
||||
export async function loadAllDocs (page = 0) {
|
||||
allDocsLoading.value = true
|
||||
try {
|
||||
const from = page * ALL_DOCS_PAGE_SIZE
|
||||
const to = from + ALL_DOCS_PAGE_SIZE - 1
|
||||
|
||||
const userIsAdmin = isCurrentUserAdmin()
|
||||
let query = supabase
|
||||
.from('saas_docs')
|
||||
.select('id, titulo, pagina_path, categoria, votos_util, votos_nao_util', { count: 'exact' })
|
||||
.eq('ativo', true)
|
||||
.order('titulo')
|
||||
.range(from, to)
|
||||
|
||||
if (!userIsAdmin) query = query.eq('tipo_acesso', 'usuario')
|
||||
|
||||
const { data, count, error } = await query
|
||||
if (error) throw error
|
||||
|
||||
// Exclui docs da sessão atual
|
||||
const sessionIds = new Set(sessionDocs.value.map(d => d.id))
|
||||
const filtered = (data || []).filter(d => !sessionIds.has(d.id))
|
||||
|
||||
allDocs.value = page === 0 ? filtered : [...allDocs.value, ...filtered]
|
||||
allDocsTotal.value = count || 0
|
||||
allDocsPage.value = page
|
||||
} catch (e) {
|
||||
console.warn('[useAjuda] erro ao carregar todos os docs:', e?.message || e)
|
||||
} finally {
|
||||
allDocsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── FAQ global (itens fora da sessão atual) ───────────────────
|
||||
export async function loadGlobalFaq () {
|
||||
globalFaqLoading.value = true
|
||||
try {
|
||||
const userIsAdmin = isCurrentUserAdmin()
|
||||
let q = supabase
|
||||
.from('saas_docs')
|
||||
.select('id, titulo')
|
||||
.eq('ativo', true)
|
||||
.eq('exibir_no_faq', true)
|
||||
|
||||
if (!userIsAdmin) q = q.eq('tipo_acesso', 'usuario')
|
||||
|
||||
const { data: faqDocs } = await q
|
||||
if (!faqDocs?.length) { globalFaq.value = []; return }
|
||||
|
||||
const sessionIds = new Set(sessionDocs.value.map(d => d.id))
|
||||
const outrosIds = faqDocs.filter(d => !sessionIds.has(d.id)).map(d => d.id)
|
||||
|
||||
if (!outrosIds.length) { globalFaq.value = []; return }
|
||||
|
||||
const itens = await fetchFaqItensForDocs(outrosIds)
|
||||
globalFaq.value = itens.map(item => ({
|
||||
...item,
|
||||
_docTitulo: faqDocs.find(d => d.id === item.doc_id)?.titulo || ''
|
||||
}))
|
||||
} catch (e) {
|
||||
console.warn('[useAjuda] erro ao carregar FAQ global:', e?.message || e)
|
||||
globalFaq.value = []
|
||||
} finally {
|
||||
globalFaqLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Votos do usuário ──────────────────────────────────────────
|
||||
export async function loadMeusVotos () {
|
||||
try {
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
if (!user) return
|
||||
const { data } = await supabase
|
||||
.from('saas_doc_votos')
|
||||
.select('doc_id, util')
|
||||
.eq('user_id', user.id)
|
||||
meusVotos.value = {}
|
||||
for (const v of (data || [])) meusVotos.value[v.doc_id] = v.util
|
||||
} catch (e) {
|
||||
console.warn('[useAjuda] erro ao carregar votos:', e?.message || e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function votar (docId, util) {
|
||||
try {
|
||||
const { data, error } = await supabase.rpc('saas_votar_doc', {
|
||||
p_doc_id: docId,
|
||||
p_util: util
|
||||
})
|
||||
if (error) throw error
|
||||
|
||||
const acao = data?.acao
|
||||
meusVotos.value = {
|
||||
...meusVotos.value,
|
||||
[docId]: acao === 'removido' ? null : util
|
||||
}
|
||||
_atualizarContadorLocal(docId, util, acao)
|
||||
} catch (e) {
|
||||
console.warn('[useAjuda] erro ao votar:', e?.message || e)
|
||||
}
|
||||
}
|
||||
|
||||
function _atualizarContadorLocal (docId, util, acao) {
|
||||
const ajustar = (doc) => {
|
||||
if (!doc || doc.id !== docId) return doc
|
||||
let { votos_util = 0, votos_nao_util = 0 } = doc
|
||||
if (acao === 'registrado') {
|
||||
if (util) votos_util++; else votos_nao_util++
|
||||
} else if (acao === 'removido') {
|
||||
if (util) votos_util = Math.max(0, votos_util - 1)
|
||||
else votos_nao_util = Math.max(0, votos_nao_util - 1)
|
||||
} else if (acao === 'atualizado') {
|
||||
if (util) { votos_util++; votos_nao_util = Math.max(0, votos_nao_util - 1) }
|
||||
else { votos_nao_util++; votos_util = Math.max(0, votos_util - 1) }
|
||||
}
|
||||
return { ...doc, votos_util, votos_nao_util }
|
||||
}
|
||||
sessionDocs.value = sessionDocs.value.map(ajustar)
|
||||
allDocs.value = allDocs.value.map(ajustar)
|
||||
if (currentDoc.value) {
|
||||
currentDoc.value = { ...currentDoc.value, docs: currentDoc.value.docs.map(ajustar) }
|
||||
}
|
||||
cache.forEach((val, key) => {
|
||||
if (val.docs.some(d => d.id === docId)) cache.delete(key)
|
||||
})
|
||||
docCache.delete(docId)
|
||||
}
|
||||
|
||||
// ── Navegação interna ─────────────────────────────────────────
|
||||
export async function navigateToDoc (docId, label = '') {
|
||||
if (!docId) return
|
||||
|
||||
navStack.value.push({ currentDoc: currentDoc.value, label: label || 'Voltar' })
|
||||
loading.value = true
|
||||
try {
|
||||
if (docCache.has(docId)) {
|
||||
currentDoc.value = docCache.get(docId)
|
||||
return
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('saas_docs')
|
||||
.select('id, titulo, conteudo, medias, tipo_acesso, docs_relacionados, ordem, categoria, exibir_no_faq, votos_util, votos_nao_util')
|
||||
.eq('id', docId)
|
||||
.eq('ativo', true)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) throw error
|
||||
if (!data) { navStack.value.pop(); return }
|
||||
|
||||
const userIsAdmin = isCurrentUserAdmin()
|
||||
if (data.tipo_acesso !== 'usuario' && !userIsAdmin) {
|
||||
navStack.value.pop(); return
|
||||
}
|
||||
|
||||
const relIds = data.docs_relacionados || []
|
||||
let related = []
|
||||
if (relIds.length) {
|
||||
const { data: relData } = await supabase
|
||||
.from('saas_docs')
|
||||
.select('id, titulo, pagina_path')
|
||||
.in('id', relIds)
|
||||
.eq('ativo', true)
|
||||
related = relData || []
|
||||
}
|
||||
|
||||
const itens = await fetchFaqItensForDocs([data.id])
|
||||
const entry = { docs: [data], relatedDocs: related, faqItens: itens }
|
||||
docCache.set(docId, entry)
|
||||
currentDoc.value = entry
|
||||
} catch (e) {
|
||||
console.warn('[useAjuda] erro ao navegar para doc:', e?.message || e)
|
||||
navStack.value.pop()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
export function navBack () {
|
||||
const prev = navStack.value.pop()
|
||||
if (prev) currentDoc.value = prev.currentDoc
|
||||
}
|
||||
|
||||
export function invalidateAjudaCache (path) {
|
||||
if (path) cache.delete(normalizePath(path))
|
||||
else cache.clear()
|
||||
docCache.clear()
|
||||
}
|
||||
|
||||
// ── Composable público ────────────────────────────────────────
|
||||
export function useAjuda () {
|
||||
const hasAjuda = computed(() => true) // sempre habilitado
|
||||
|
||||
const allDocsHasMore = computed(() =>
|
||||
allDocs.value.length < allDocsTotal.value - sessionDocs.value.length
|
||||
)
|
||||
|
||||
function openDrawer () {
|
||||
drawerOpen.value = true
|
||||
currentDoc.value = null
|
||||
if (!allDocs.value.length) loadAllDocs(0)
|
||||
if (!globalFaq.value.length) loadGlobalFaq()
|
||||
if (!Object.keys(meusVotos.value).length) loadMeusVotos()
|
||||
}
|
||||
|
||||
function closeDrawer () {
|
||||
drawerOpen.value = false
|
||||
navStack.value = []
|
||||
currentDoc.value = null
|
||||
}
|
||||
|
||||
function loadMoreDocs () {
|
||||
loadAllDocs(allDocsPage.value + 1)
|
||||
}
|
||||
|
||||
return {
|
||||
sessionDocs, sessionFaq, sessionPath,
|
||||
allDocs, allDocsTotal, allDocsLoading, allDocsHasMore,
|
||||
globalFaq, globalFaqLoading,
|
||||
currentDoc, isHome, isNavigating, navStack,
|
||||
drawerOpen, loading,
|
||||
hasAjuda,
|
||||
meusVotos,
|
||||
openDrawer, closeDrawer,
|
||||
navigateToDoc, navBack,
|
||||
votar, loadMoreDocs,
|
||||
}
|
||||
}
|
||||
21
src/composables/useDocsAdmin.js
Normal file
21
src/composables/useDocsAdmin.js
Normal file
@@ -0,0 +1,21 @@
|
||||
// src/composables/useDocsAdmin.js
|
||||
// Estado compartilhado para abrir o dialog de edição de um doc
|
||||
// a partir de outra página (ex: SaasFaqPage → SaasDocsPage).
|
||||
|
||||
import { ref } from 'vue'
|
||||
|
||||
const pendingEditDocId = ref(null)
|
||||
|
||||
export function useDocsAdmin () {
|
||||
function requestEditDoc (docId) {
|
||||
pendingEditDocId.value = docId
|
||||
}
|
||||
|
||||
function consumePendingEdit () {
|
||||
const id = pendingEditDocId.value
|
||||
pendingEditDocId.value = null
|
||||
return id
|
||||
}
|
||||
|
||||
return { pendingEditDocId, requestEditDoc, consumePendingEdit }
|
||||
}
|
||||
119
src/composables/useFeriados.js
Normal file
119
src/composables/useFeriados.js
Normal file
@@ -0,0 +1,119 @@
|
||||
// src/composables/useFeriados.js
|
||||
// Fonte única de verdade para feriados: nacionais (algoritmo) + municipais (Supabase).
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { getFeriadosNacionais } from '@/utils/feriadosBR'
|
||||
|
||||
export function useFeriados () {
|
||||
const ano = ref(new Date().getFullYear())
|
||||
const loading = ref(false)
|
||||
const municipais = ref([]) // linhas da tabela `feriados`
|
||||
|
||||
// ── Nacionais (algoritmo, sem DB) ─────────────────────────
|
||||
const nacionais = computed(() =>
|
||||
getFeriadosNacionais(ano.value).map(f => ({ ...f, tipo: 'nacional' }))
|
||||
)
|
||||
|
||||
// ── Todos juntos, ordenados por data ─────────────────────
|
||||
const todos = computed(() => [
|
||||
...nacionais.value,
|
||||
...municipais.value.map(f => ({ ...f, tipo: f.tipo || 'municipal' }))
|
||||
].sort((a, b) => a.data.localeCompare(b.data)))
|
||||
|
||||
// ── Feriados de um mês (1–12) ─────────────────────────────
|
||||
function doMes (mes) {
|
||||
const m = String(mes).padStart(2, '0')
|
||||
const prefix = `${ano.value}-${m}`
|
||||
return todos.value.filter(f => f.data.startsWith(prefix))
|
||||
}
|
||||
|
||||
// ── Próximos N dias ───────────────────────────────────────
|
||||
function proximos (dias = 30) {
|
||||
const hoje = new Date()
|
||||
const limite = new Date(hoje)
|
||||
limite.setDate(limite.getDate() + dias)
|
||||
const hojeISO = toISO(hoje)
|
||||
const limiteISO = toISO(limite)
|
||||
return todos.value.filter(f => f.data >= hojeISO && f.data <= limiteISO)
|
||||
}
|
||||
|
||||
function toISO (d) {
|
||||
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
|
||||
}
|
||||
|
||||
// ── Load municipais do Supabase ───────────────────────────
|
||||
async function load (tenantId, year) {
|
||||
if (year) ano.value = year
|
||||
if (!tenantId) return
|
||||
loading.value = true
|
||||
try {
|
||||
// Busca feriados do tenant + feriados globais (tenant_id null, cadastrados pelo SAAS)
|
||||
const { data, error } = await supabase
|
||||
.from('feriados')
|
||||
.select('*')
|
||||
.or(`tenant_id.eq.${tenantId},tenant_id.is.null`)
|
||||
.gte('data', `${ano.value}-01-01`)
|
||||
.lte('data', `${ano.value}-12-31`)
|
||||
.order('data')
|
||||
if (error) throw error
|
||||
municipais.value = data || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Criar feriado municipal ───────────────────────────────
|
||||
async function criar (payload) {
|
||||
const { data, error } = await supabase
|
||||
.from('feriados')
|
||||
.insert(payload)
|
||||
.select()
|
||||
.single()
|
||||
if (error) throw error
|
||||
municipais.value = [...municipais.value, data].sort((a, b) => a.data.localeCompare(b.data))
|
||||
return data
|
||||
}
|
||||
|
||||
// ── Remover feriado municipal ─────────────────────────────
|
||||
async function remover (id) {
|
||||
const { error } = await supabase.from('feriados').delete().eq('id', id)
|
||||
if (error) throw error
|
||||
municipais.value = municipais.value.filter(f => f.id !== id)
|
||||
}
|
||||
|
||||
// ── Verificar duplicata ───────────────────────────────────
|
||||
function isDuplicata (data, nome) {
|
||||
return todos.value.some(f => f.data === data && f.nome.trim().toLowerCase() === nome.trim().toLowerCase())
|
||||
}
|
||||
|
||||
// ── Converter para eventos do FullCalendar (background) ──
|
||||
function toFcEvents (lista) {
|
||||
return lista.map(f => ({
|
||||
id: `feriado_${f.id || f.data}_${f.nome}`,
|
||||
title: f.nome,
|
||||
start: f.data,
|
||||
allDay: true,
|
||||
display: 'background',
|
||||
color: 'rgba(251, 191, 36, 0.25)',
|
||||
extendedProps: { _feriado: true, tipo: f.tipo }
|
||||
}))
|
||||
}
|
||||
|
||||
const fcEvents = computed(() => toFcEvents(todos.value))
|
||||
|
||||
return {
|
||||
ano,
|
||||
loading,
|
||||
nacionais,
|
||||
municipais,
|
||||
todos,
|
||||
fcEvents,
|
||||
load,
|
||||
criar,
|
||||
remover,
|
||||
doMes,
|
||||
proximos,
|
||||
isDuplicata
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,9 @@ import ptBrLocale from '@fullcalendar/core/locales/pt-br'
|
||||
const props = defineProps({
|
||||
view: { type: String, default: 'day' }, // 'day' | 'week' | 'month'
|
||||
mode: { type: String, default: 'work_hours' }, // 'full_24h' | 'work_hours'
|
||||
timezone: { type: String, default: 'America/Sao_Paulo' },
|
||||
timezone: { type: String, default: 'local' },
|
||||
|
||||
slotDuration: { type: String, default: '00:30:00' },
|
||||
slotDuration: { type: String, default: '00:15:00' },
|
||||
slotMinTime: { type: String, default: '06:00:00' },
|
||||
slotMaxTime: { type: String, default: '22:00:00' },
|
||||
|
||||
@@ -35,7 +35,14 @@ const props = defineProps({
|
||||
clinicSubtitle: { type: String, default: 'Agenda da clínica' },
|
||||
|
||||
// subtitle terapeutas
|
||||
staffSubtitle: { type: String, default: 'Visão diária operacional' }
|
||||
staffSubtitle: { type: String, default: 'Visão diária operacional' },
|
||||
|
||||
// jornada por dia: [{ daysOfWeek:[n], startTime:'HH:MM', endTime:'HH:MM' }]
|
||||
businessHours: { type: Array, default: () => [] },
|
||||
|
||||
// Array de ISO strings (yyyy-mm-dd) de dias totalmente bloqueados.
|
||||
// Exibe banner vermelho no topo de cada coluna (view "day") e fundo no calendário.
|
||||
blockedDates: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -44,7 +51,6 @@ const emit = defineEmits([
|
||||
'eventClick',
|
||||
'eventDrop',
|
||||
'eventResize',
|
||||
// ✅ debug
|
||||
'debugColumn'
|
||||
])
|
||||
|
||||
@@ -89,13 +95,18 @@ function forEachApi (fn) {
|
||||
}
|
||||
}
|
||||
|
||||
function goToday () { forEachApi(api => api.today()) }
|
||||
function prev () { forEachApi(api => api.prev()) }
|
||||
function next () { forEachApi(api => api.next()) }
|
||||
function goToday () {
|
||||
const d = new Date(); d.setHours(12, 0, 0, 0)
|
||||
trackedDate = d
|
||||
forEachApi(api => api.today())
|
||||
}
|
||||
function prev () { trackedDate = null; forEachApi(api => api.prev()) }
|
||||
function next () { trackedDate = null; forEachApi(api => api.next()) }
|
||||
function gotoDate (date) {
|
||||
if (!date) return
|
||||
const dt = (date instanceof Date) ? new Date(date) : new Date(date)
|
||||
dt.setHours(12, 0, 0, 0) // anti “voltar dia”
|
||||
dt.setHours(12, 0, 0, 0)
|
||||
trackedDate = new Date(dt)
|
||||
forEachApi(api => api.gotoDate(dt))
|
||||
}
|
||||
|
||||
@@ -107,16 +118,70 @@ function setMode () {}
|
||||
|
||||
defineExpose({ goToday, prev, next, gotoDate, setView, setMode })
|
||||
|
||||
// ── Dias bloqueados ────────────────────────────────────────────
|
||||
const blockedSet = computed(() => new Set(props.blockedDates || []))
|
||||
|
||||
// Eventos de background que colorem o dia inteiro de vermelho suave no FullCalendar
|
||||
const blockedBgEvents = computed(() =>
|
||||
(props.blockedDates || []).map(iso => ({
|
||||
id: `_blocked_bg_${iso}`,
|
||||
start: iso,
|
||||
allDay: true,
|
||||
display: 'background',
|
||||
color: 'rgba(239,68,68,0.13)',
|
||||
classNames: ['fc-blocked-day']
|
||||
}))
|
||||
)
|
||||
|
||||
// ISO do dia sendo exibido atualmente — atualizado pelo datesSet
|
||||
const currentViewISO = ref('')
|
||||
|
||||
// Retorna true se o dia exibido está bloqueado (banner só aparece na view "day")
|
||||
function isCurrentDayBlocked () {
|
||||
return props.view === 'day' && !!currentViewISO.value && blockedSet.value.has(currentViewISO.value)
|
||||
}
|
||||
|
||||
function eventsFor (ownerId) {
|
||||
const list = props.events || []
|
||||
return list.filter(e => String(e?.extendedProps?.owner_id || '') === String(ownerId || ''))
|
||||
const list = (props.events || []).filter(
|
||||
e => String(e?.extendedProps?.owner_id || '') === String(ownerId || '')
|
||||
)
|
||||
return [...list, ...blockedBgEvents.value]
|
||||
}
|
||||
|
||||
// ---- range sync ----
|
||||
let lastRangeKey = ''
|
||||
let suppressSync = false
|
||||
let trackedDate = null // data-alvo atual (definida via gotoDate/goToday; limpa em prev/next)
|
||||
|
||||
function sameDay (a, b) {
|
||||
if (!a || !b) return false
|
||||
return a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
}
|
||||
|
||||
function onDatesSet (arg) {
|
||||
const cd = arg.view?.currentStart || arg.start
|
||||
if (cd) {
|
||||
currentViewISO.value = `${cd.getFullYear()}-${String(cd.getMonth()+1).padStart(2,'0')}-${String(cd.getDate()).padStart(2,'0')}`
|
||||
}
|
||||
|
||||
// Calendário recém-montado disparou datesSet com data antiga (ex: hoje) enquanto
|
||||
// a agenda já estava em outra data (trackedDate). Navega silenciosamente para a
|
||||
// data correta sem emitir rangeChange nem atualizar lastRangeKey.
|
||||
if (trackedDate && cd && !sameDay(cd, trackedDate)) {
|
||||
if (!suppressSync) {
|
||||
suppressSync = true
|
||||
forEachApi((api) => {
|
||||
const cur = api.view?.currentStart
|
||||
if (!cur || sameDay(cur, trackedDate)) return
|
||||
api.gotoDate(trackedDate)
|
||||
})
|
||||
Promise.resolve().then(() => { suppressSync = false })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const key = `${arg.startStr}__${arg.endStr}__${arg.view?.type || ''}`
|
||||
if (key === lastRangeKey) return
|
||||
lastRangeKey = key
|
||||
@@ -127,13 +192,13 @@ function onDatesSet (arg) {
|
||||
startStr: arg.startStr,
|
||||
endStr: arg.endStr,
|
||||
viewType: arg.view.type,
|
||||
currentDate: arg.view?.currentStart || arg.start
|
||||
currentDate: cd
|
||||
})
|
||||
|
||||
if (suppressSync) return
|
||||
suppressSync = true
|
||||
|
||||
const masterDate = arg.view?.currentStart || arg.start
|
||||
const masterDate = cd
|
||||
forEachApi((api) => {
|
||||
const cur = api.view?.currentStart
|
||||
if (!cur || !masterDate) return
|
||||
@@ -148,6 +213,16 @@ watch(() => props.view, async () => {
|
||||
setView(props.view)
|
||||
})
|
||||
|
||||
// ✅ Fix: watch combinado — evita render intermediário que colapsava labels de hora cheia
|
||||
watch([computedSlotMinTime, computedSlotMaxTime], async ([minT, maxT]) => {
|
||||
await nextTick()
|
||||
forEachApi(api => {
|
||||
api.setOption?.('slotMinTime', minT)
|
||||
api.setOption?.('slotMaxTime', maxT)
|
||||
api.updateSize?.()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------- helpers UI ----------
|
||||
function colSubtitle (p) {
|
||||
return p?.__kind === 'clinic' ? props.clinicSubtitle : props.staffSubtitle
|
||||
@@ -179,8 +254,20 @@ function buildFcOptions (ownerId) {
|
||||
selectMirror: true,
|
||||
|
||||
slotDuration: props.slotDuration,
|
||||
snapDuration: '00:15:00',
|
||||
slotMinTime: computedSlotMinTime.value,
|
||||
slotMaxTime: computedSlotMaxTime.value,
|
||||
slotLabelInterval: '00:30',
|
||||
slotLabelContent: (arg) => {
|
||||
const min = arg.date.getMinutes()
|
||||
if (min === 0) {
|
||||
const h = String(arg.date.getHours()).padStart(2, '0')
|
||||
return { html: `<span class="fc-slot-label-hour">${h}:00</span>` }
|
||||
}
|
||||
return { html: `<span class="fc-slot-label-half">:${String(min).padStart(2, '0')}</span>` }
|
||||
},
|
||||
|
||||
businessHours: props.businessHours,
|
||||
|
||||
height: 'auto',
|
||||
expandRows: true,
|
||||
@@ -191,7 +278,47 @@ function buildFcOptions (ownerId) {
|
||||
|
||||
eventClick: (info) => emit('eventClick', info),
|
||||
eventDrop: (info) => emit('eventDrop', info),
|
||||
eventResize: (info) => emit('eventResize', info)
|
||||
eventResize: (info) => emit('eventResize', info),
|
||||
|
||||
eventContent: (arg) => {
|
||||
const ext = arg.event.extendedProps || {}
|
||||
const avatarUrl = ext.paciente_avatar || ''
|
||||
const nome = ext.paciente_nome || ''
|
||||
const obs = ext.observacoes || ''
|
||||
const title = arg.event.title || ''
|
||||
const timeText = arg.timeText || ''
|
||||
|
||||
const esc = (s) => String(s ?? '')
|
||||
.replace(/&/g, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"')
|
||||
|
||||
const initials = (n) => {
|
||||
const p = String(n).trim().split(/\s+/).filter(Boolean)
|
||||
if (!p.length) return '?'
|
||||
if (p.length === 1) return p[0].slice(0, 2).toUpperCase()
|
||||
return (p[0][0] + p[p.length - 1][0]).toUpperCase()
|
||||
}
|
||||
|
||||
const avatarHtml = avatarUrl
|
||||
? `<img src="${esc(avatarUrl)}" class="ev-avatar ev-avatar-img" />`
|
||||
: nome
|
||||
? `<div class="ev-avatar ev-avatar-initials">${esc(initials(nome))}</div>`
|
||||
: ''
|
||||
|
||||
const obsHtml = obs ? `<div class="ev-obs">${esc(obs)}</div>` : ''
|
||||
const timeHtml = timeText ? `<div class="ev-time">${esc(timeText)}</div>` : ''
|
||||
|
||||
return {
|
||||
html: `<div class="ev-custom">
|
||||
${avatarHtml}
|
||||
<div class="ev-body">
|
||||
${timeHtml}
|
||||
<div class="ev-title">${esc(title)}</div>
|
||||
${obsHtml}
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
base.select = (selection) => {
|
||||
@@ -230,6 +357,12 @@ function buildFcOptions (ownerId) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Banner: dia bloqueado — aparece apenas na view "day" -->
|
||||
<div v-if="isCurrentDayBlocked()" class="mosaic-blocked-banner">
|
||||
<i class="pi pi-lock text-xs" />
|
||||
<span>Dia bloqueado — apenas compromissos de sessão não são permitidos</span>
|
||||
</div>
|
||||
|
||||
<div class="p-2">
|
||||
<FullCalendar
|
||||
:ref="(el) => setCalendarRef(el, 0)"
|
||||
@@ -260,6 +393,12 @@ function buildFcOptions (ownerId) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Banner: dia bloqueado — aparece apenas na view "day" -->
|
||||
<div v-if="isCurrentDayBlocked()" class="mosaic-blocked-banner">
|
||||
<i class="pi pi-lock text-xs" />
|
||||
<span>Dia bloqueado — apenas compromissos de sessão não são permitidos</span>
|
||||
</div>
|
||||
|
||||
<div class="p-2">
|
||||
<FullCalendar
|
||||
:ref="(el) => setCalendarRef(el, (clinicColumn ? (sIdx + 1) : sIdx))"
|
||||
@@ -273,48 +412,120 @@ function buildFcOptions (ownerId) {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* Evento customizado — unscoped pois é HTML injetado pelo FullCalendar */
|
||||
.ev-custom {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 5px;
|
||||
overflow: hidden;
|
||||
padding: 1px 2px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
.ev-avatar {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.ev-avatar-img { object-fit: cover; }
|
||||
.ev-avatar-initials {
|
||||
background: rgba(255,255,255,0.25);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .5px;
|
||||
}
|
||||
.ev-body { min-width: 0; flex: 1; overflow: hidden; }
|
||||
.ev-time { font-size: 10px; opacity: 0.8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.ev-title { font-size: 11px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.3; }
|
||||
.ev-obs { font-size: 10px; opacity: 0.75; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; }
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.mosaic-shell{
|
||||
display:flex;
|
||||
gap:12px;
|
||||
.mosaic-shell {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 8px;
|
||||
}
|
||||
@media (min-width: 768px){
|
||||
.mosaic-shell{ padding: 12px; }
|
||||
@media (min-width: 768px) {
|
||||
.mosaic-shell { padding: 12px; }
|
||||
}
|
||||
|
||||
.mosaic-fixed{
|
||||
.mosaic-fixed {
|
||||
flex: 0 0 auto;
|
||||
width: 420px;
|
||||
min-width: 320px;
|
||||
max-width: 460px;
|
||||
}
|
||||
|
||||
.mosaic-scroll{
|
||||
.mosaic-scroll {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.mosaic-grid{
|
||||
display:grid;
|
||||
.mosaic-grid {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
gap:12px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mosaic-col{
|
||||
.mosaic-col {
|
||||
border-radius: 1.25rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: color-mix(in_srgb, var(--surface-card), transparent 12%);
|
||||
overflow:hidden;
|
||||
background: color-mix(in srgb, var(--surface-card), transparent 12%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mosaic-col-head{
|
||||
.mosaic-col-head {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
display:flex;
|
||||
align-items:center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Banner vermelho de dia bloqueado no topo de cada coluna */
|
||||
.mosaic-blocked-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 14px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--red-700, #b91c1c);
|
||||
background: color-mix(in srgb, var(--red-400, #f87171) 15%, var(--surface-card));
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--red-400, #f87171) 30%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.fc-slot-label-hour {
|
||||
display: inline-block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-color);
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1;
|
||||
}
|
||||
.fc-slot-label-half {
|
||||
display: inline-block;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.5;
|
||||
line-height: 1;
|
||||
padding-left: 2px;
|
||||
}
|
||||
/* Garante opacidade total nos dias bloqueados (background event do FullCalendar) */
|
||||
.fc-blocked-day {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
603
src/features/agenda/components/BloqueioDialog.vue
Normal file
603
src/features/agenda/components/BloqueioDialog.vue
Normal file
@@ -0,0 +1,603 @@
|
||||
<!-- src/features/agenda/components/BloqueioDialog.vue -->
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useFeriados } from '@/composables/useFeriados'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import DatePicker from 'primevue/datepicker'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: Boolean,
|
||||
mode: { type: String, default: 'horario' }, // 'horario' | 'periodo' | 'dia' | 'feriados'
|
||||
workRules: { type: Array, default: () => [] },
|
||||
settings: { type: Object, default: null },
|
||||
ownerId: { type: String, default: '' },
|
||||
tenantId: { type: [String, null], default: null }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'saved'])
|
||||
const toast = useToast()
|
||||
const saving = ref(false)
|
||||
|
||||
// ── Feriados ──────────────────────────────────────────────────────────────
|
||||
const { proximos, load: loadFeriados, criar: criarFeriado } = useFeriados()
|
||||
|
||||
// ── Mode: horario ─────────────────────────────────────────────────────────
|
||||
const todayDow = new Date().getDay()
|
||||
|
||||
const timeSlots = computed(() => {
|
||||
const rule = props.workRules.find(r => Number(r.dia_semana) === todayDow)
|
||||
if (!rule) return []
|
||||
const dur = props.settings?.session_duration_min ?? props.settings?.duracao_padrao_minutos ?? 50
|
||||
const [sh, sm] = String(rule.hora_inicio || '08:00').slice(0, 5).split(':').map(Number)
|
||||
const [eh, em] = String(rule.hora_fim || '18:00').slice(0, 5).split(':').map(Number)
|
||||
const startMin = sh * 60 + sm
|
||||
const endMin = eh * 60 + em
|
||||
const slots = []
|
||||
for (let t = startMin; t + dur <= endMin; t += dur) {
|
||||
const h1 = Math.floor(t / 60), m1 = t % 60
|
||||
const t2 = t + dur, h2 = Math.floor(t2 / 60), m2 = t2 % 60
|
||||
const hi = `${String(h1).padStart(2, '0')}:${String(m1).padStart(2, '0')}`
|
||||
const hf = `${String(h2).padStart(2, '0')}:${String(m2).padStart(2, '0')}`
|
||||
slots.push({ label: `${hi} – ${hf}`, hora_inicio: hi, hora_fim: hf })
|
||||
}
|
||||
return slots
|
||||
})
|
||||
|
||||
const selectedSlotIndices = ref(new Set())
|
||||
|
||||
function toggleSlot (idx) {
|
||||
const s = new Set(selectedSlotIndices.value)
|
||||
if (s.has(idx)) s.delete(idx)
|
||||
else s.add(idx)
|
||||
selectedSlotIndices.value = s
|
||||
}
|
||||
|
||||
// ── Mode: periodo ─────────────────────────────────────────────────────────
|
||||
const periodos = ref([
|
||||
{ label: 'Manhã', sub: '06:00 – 12:00', icon: 'pi pi-sun', hora_inicio: '06:00', hora_fim: '12:00', selected: false },
|
||||
{ label: 'Tarde', sub: '12:00 – 18:00', icon: 'pi pi-cloud-sun', hora_inicio: '12:00', hora_fim: '18:00', selected: false },
|
||||
{ label: 'Noite', sub: '18:00 – 23:00', icon: 'pi pi-moon', hora_inicio: '18:00', hora_fim: '23:00', selected: false }
|
||||
])
|
||||
const periodoDate = ref(new Date())
|
||||
|
||||
// ── Mode: dia ─────────────────────────────────────────────────────────────
|
||||
const selectedDays = ref([])
|
||||
|
||||
// ── Mode: feriados ────────────────────────────────────────────────────────
|
||||
const upcomingFeriados = computed(() => proximos(90))
|
||||
const feriadosDecisao = ref({}) // { [iso]: true (trabalha) | false (não trabalha) }
|
||||
|
||||
// Dialog feriado municipal
|
||||
const fdlgOpen = ref(false)
|
||||
const fsaving = ref(false)
|
||||
const fform = ref({ nome: '', data: null, observacao: '' })
|
||||
|
||||
// ── Reset ao abrir ────────────────────────────────────────────────────────
|
||||
watch(() => props.modelValue, (v) => {
|
||||
if (!v) return
|
||||
selectedSlotIndices.value = new Set()
|
||||
periodos.value.forEach(p => { p.selected = false })
|
||||
periodoDate.value = new Date()
|
||||
selectedDays.value = []
|
||||
feriadosDecisao.value = {}
|
||||
if (props.mode === 'feriados' && props.tenantId) {
|
||||
loadFeriados(props.tenantId)
|
||||
}
|
||||
})
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
function toISO (d) {
|
||||
if (!d) return null
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function fmtDateLong (iso) {
|
||||
if (!iso) return ''
|
||||
const [y, m, d] = iso.split('-').map(Number)
|
||||
return new Date(y, m - 1, d).toLocaleDateString('pt-BR', { weekday: 'long', day: '2-digit', month: 'long' })
|
||||
}
|
||||
|
||||
function setFeriadoDecisao (data, rawVal) {
|
||||
const val = rawVal === 'sim' ? true : rawVal === 'nao' ? false : undefined
|
||||
const copy = { ...feriadosDecisao.value }
|
||||
if (val === undefined) delete copy[data]
|
||||
else copy[data] = val
|
||||
feriadosDecisao.value = copy
|
||||
}
|
||||
|
||||
// ── UI ────────────────────────────────────────────────────────────────────
|
||||
const dialogTitle = computed(() => ({
|
||||
horario: 'Bloquear por Horário',
|
||||
periodo: 'Bloquear por Período',
|
||||
dia: 'Bloquear por Dia',
|
||||
feriados: 'Bloqueio por Feriados'
|
||||
}[props.mode] || 'Bloquear'))
|
||||
|
||||
const canConfirm = computed(() => {
|
||||
if (props.mode === 'horario') return selectedSlotIndices.value.size > 0
|
||||
if (props.mode === 'periodo') return periodos.value.some(p => p.selected)
|
||||
if (props.mode === 'dia') return selectedDays.value.length > 0
|
||||
if (props.mode === 'feriados') return Object.values(feriadosDecisao.value).some(v => v === false)
|
||||
return false
|
||||
})
|
||||
|
||||
function close () { emit('update:modelValue', false) }
|
||||
|
||||
// ── Confirmar bloqueio ────────────────────────────────────────────────────
|
||||
async function confirmar () {
|
||||
if (!props.ownerId || !props.tenantId) {
|
||||
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Configurações da agenda não carregadas.', life: 3000 })
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const base = {
|
||||
owner_id: props.ownerId,
|
||||
tenant_id: props.tenantId,
|
||||
tipo: 'bloqueio',
|
||||
recorrente: false
|
||||
}
|
||||
const rows = []
|
||||
|
||||
if (props.mode === 'horario') {
|
||||
const iso = toISO(new Date())
|
||||
timeSlots.value.forEach((slot, idx) => {
|
||||
if (!selectedSlotIndices.value.has(idx)) return
|
||||
rows.push({ ...base,
|
||||
titulo: `Bloqueio ${slot.hora_inicio}–${slot.hora_fim}`,
|
||||
data_inicio: iso,
|
||||
data_fim: iso,
|
||||
hora_inicio: slot.hora_inicio,
|
||||
hora_fim: slot.hora_fim,
|
||||
origem: 'agenda_horario'
|
||||
})
|
||||
})
|
||||
} else if (props.mode === 'periodo') {
|
||||
const iso = toISO(periodoDate.value)
|
||||
periodos.value.filter(p => p.selected).forEach(p => {
|
||||
rows.push({ ...base,
|
||||
titulo: `Bloqueio ${p.label}`,
|
||||
data_inicio: iso,
|
||||
data_fim: iso,
|
||||
hora_inicio: p.hora_inicio,
|
||||
hora_fim: p.hora_fim,
|
||||
origem: 'agenda_periodo'
|
||||
})
|
||||
})
|
||||
} else if (props.mode === 'dia') {
|
||||
selectedDays.value.forEach(d => {
|
||||
rows.push({ ...base,
|
||||
titulo: 'Dia bloqueado',
|
||||
data_inicio: toISO(d),
|
||||
data_fim: toISO(d),
|
||||
hora_inicio: null,
|
||||
hora_fim: null,
|
||||
origem: 'agenda_dia'
|
||||
})
|
||||
})
|
||||
} else if (props.mode === 'feriados') {
|
||||
for (const [data, trabalha] of Object.entries(feriadosDecisao.value)) {
|
||||
if (trabalha !== false) continue
|
||||
const f = upcomingFeriados.value.find(f => f.data === data)
|
||||
rows.push({ ...base,
|
||||
titulo: f ? `Feriado: ${f.nome}` : 'Feriado bloqueado',
|
||||
data_inicio: data,
|
||||
data_fim: data,
|
||||
hora_inicio: null,
|
||||
hora_fim: null,
|
||||
origem: 'agenda_feriado'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!rows.length) {
|
||||
toast.add({ severity: 'warn', summary: 'Seleção vazia', detail: 'Selecione ao menos um item para bloquear.', life: 2500 })
|
||||
return
|
||||
}
|
||||
|
||||
const { error } = await supabase.from('agenda_bloqueios').insert(rows)
|
||||
if (error) throw error
|
||||
|
||||
// Marcar sessões existentes como "remarcar"
|
||||
await marcarSessoesParaRemarcar(rows)
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Bloqueio criado',
|
||||
detail: `${rows.length} bloqueio(s) registrado(s). Sessões existentes marcadas para reagendamento.`,
|
||||
life: 4500
|
||||
})
|
||||
emit('saved')
|
||||
close()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao criar bloqueio.', life: 4000 })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function marcarSessoesParaRemarcar (bloqueios) {
|
||||
// Para cada bloqueio, tenta marcar sessões existentes como 'remarcar'
|
||||
for (const b of bloqueios) {
|
||||
try {
|
||||
let query = supabase
|
||||
.from('agenda_eventos')
|
||||
.update({ status: 'remarcar' })
|
||||
.eq('owner_id', props.ownerId)
|
||||
.eq('tipo', 'sessao')
|
||||
.gte('inicio_em', `${b.data_inicio}T00:00:00`)
|
||||
.lte('inicio_em', `${b.data_fim}T23:59:59`)
|
||||
|
||||
if (b.hora_inicio && b.hora_fim) {
|
||||
// filtra pela hora aproximada — comparação UTC simplificada
|
||||
query = query
|
||||
.gte('inicio_em', `${b.data_inicio}T${b.hora_inicio}:00`)
|
||||
.lte('inicio_em', `${b.data_inicio}T${b.hora_fim}:00`)
|
||||
}
|
||||
|
||||
await query
|
||||
} catch { /* ignora erros parciais — o bloqueio já foi criado */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Feriado municipal ─────────────────────────────────────────────────────
|
||||
async function salvarFeriadoMunicipal () {
|
||||
if (!fform.value.nome || !fform.value.data) return
|
||||
fsaving.value = true
|
||||
const iso = toISO(fform.value.data)
|
||||
try {
|
||||
await criarFeriado({
|
||||
tenant_id: props.tenantId,
|
||||
owner_id: props.ownerId,
|
||||
tipo: 'municipal',
|
||||
nome: fform.value.nome.trim(),
|
||||
data: iso,
|
||||
observacao: fform.value.observacao || null,
|
||||
bloqueia_sessoes: true
|
||||
})
|
||||
toast.add({ severity: 'success', summary: 'Feriado cadastrado', life: 1800 })
|
||||
// Auto-marca como "não trabalha" para facilitar
|
||||
feriadosDecisao.value = { ...feriadosDecisao.value, [iso]: false }
|
||||
fdlgOpen.value = false
|
||||
fform.value = { nome: '', data: null, observacao: '' }
|
||||
// Recarrega feriados
|
||||
if (props.tenantId) loadFeriados(props.tenantId)
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3500 })
|
||||
} finally {
|
||||
fsaving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Dialog principal -->
|
||||
<Dialog
|
||||
:visible="modelValue"
|
||||
modal
|
||||
:draggable="false"
|
||||
:header="dialogTitle"
|
||||
:style="{ width: '540px', maxWidth: '96vw' }"
|
||||
@update:visible="emit('update:modelValue', $event)"
|
||||
>
|
||||
<!-- ── Banner de aviso ────────────────────────────────── -->
|
||||
<div class="blq-warning mb-4">
|
||||
<i class="pi pi-exclamation-triangle blq-warning__icon" />
|
||||
<div class="text-sm leading-relaxed">
|
||||
<b>Atenção:</b> sessões existentes nos períodos bloqueados serão marcadas como
|
||||
<b>Remarcar</b> e os pacientes receberão aviso por e-mail/SMS para reagendamento.<br />
|
||||
<span class="opacity-70 text-xs">O bloqueio prevalece sobre qualquer compromisso já agendado.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ Modo: Horário ════════════════════════════════════ -->
|
||||
<div v-if="mode === 'horario'" class="flex flex-col gap-3">
|
||||
<p class="text-sm text-[var(--text-color-secondary)]">
|
||||
Selecione os horários de <b>hoje</b> que deseja bloquear (baseados na sua jornada).
|
||||
Presencial e online serão bloqueados simultaneamente.
|
||||
</p>
|
||||
|
||||
<div v-if="timeSlots.length === 0" class="blq-empty">
|
||||
<i class="pi pi-info-circle" />
|
||||
Hoje não é um dia de trabalho configurado na agenda.
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="(slot, idx) in timeSlots"
|
||||
:key="idx"
|
||||
class="blq-chip"
|
||||
:class="{ 'blq-chip--on': selectedSlotIndices.has(idx) }"
|
||||
type="button"
|
||||
@click="toggleSlot(idx)"
|
||||
>
|
||||
<i class="pi pi-clock text-xs" />
|
||||
{{ slot.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="selectedSlotIndices.size > 0" class="text-xs text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-lock mr-1" style="color:var(--red-500)" />
|
||||
{{ selectedSlotIndices.size }} horário(s) selecionado(s)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- ══ Modo: Período ════════════════════════════════════ -->
|
||||
<div v-else-if="mode === 'periodo'" class="flex flex-col gap-4">
|
||||
<p class="text-sm text-[var(--text-color-secondary)]">
|
||||
Selecione o dia e os períodos que deseja bloquear.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label class="blq-label">Data *</label>
|
||||
<DatePicker
|
||||
v-model="periodoDate"
|
||||
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="grid grid-cols-3 gap-3">
|
||||
<button
|
||||
v-for="p in periodos"
|
||||
:key="p.label"
|
||||
class="blq-period-card"
|
||||
:class="{ 'blq-period-card--on': p.selected }"
|
||||
type="button"
|
||||
@click="p.selected = !p.selected"
|
||||
>
|
||||
<i :class="p.icon" class="text-xl mb-1" />
|
||||
<span class="font-semibold text-sm">{{ p.label }}</span>
|
||||
<span class="text-xs opacity-60">{{ p.sub }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ Modo: Dia ════════════════════════════════════════ -->
|
||||
<div v-else-if="mode === 'dia'" class="flex flex-col gap-3">
|
||||
<p class="text-sm text-[var(--text-color-secondary)]">
|
||||
Clique nos dias que deseja bloquear. O dia inteiro ficará indisponível para agendamentos.
|
||||
</p>
|
||||
|
||||
<Calendar
|
||||
v-model="selectedDays"
|
||||
inline
|
||||
selectionMode="multiple"
|
||||
:minDate="new Date()"
|
||||
class="w-full"
|
||||
/>
|
||||
|
||||
<p v-if="selectedDays.length" class="text-xs text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-lock mr-1" style="color:var(--red-500)" />
|
||||
{{ selectedDays.length }} dia(s) selecionado(s)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- ══ Modo: Feriados ═══════════════════════════════════ -->
|
||||
<div v-else-if="mode === 'feriados'" class="flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between gap-2 flex-wrap">
|
||||
<p class="text-sm text-[var(--text-color-secondary)] m-0">
|
||||
Próximos feriados (90 dias). Indique se vai trabalhar em cada um.
|
||||
</p>
|
||||
<Button
|
||||
label="+ Feriado municipal"
|
||||
icon="pi pi-map-marker"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="shrink-0 rounded-full"
|
||||
@click="fdlgOpen = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="upcomingFeriados.length === 0" class="blq-empty">
|
||||
<i class="pi pi-calendar" />
|
||||
Nenhum feriado nos próximos 90 dias.
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-2 max-h-[320px] overflow-y-auto pr-1">
|
||||
<div
|
||||
v-for="f in upcomingFeriados"
|
||||
:key="f.data"
|
||||
class="blq-feriado-row"
|
||||
:class="{ 'blq-feriado-row--blocked': feriadosDecisao[f.data] === false }"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-medium text-sm truncate">{{ f.nome }}</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] capitalize">
|
||||
{{ fmtDateLong(f.data) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 shrink-0 flex-wrap justify-end">
|
||||
<span class="text-xs text-[var(--text-color-secondary)] whitespace-nowrap">Vai trabalhar?</span>
|
||||
<SelectButton
|
||||
:modelValue="feriadosDecisao[f.data] === true ? 'sim' : feriadosDecisao[f.data] === false ? 'nao' : null"
|
||||
:options="[{ label: 'Sim', value: 'sim' }, { label: 'Não', value: 'nao' }]"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
:allowEmpty="true"
|
||||
size="small"
|
||||
@update:modelValue="(v) => setFeriadoDecisao(f.data, v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Footer ─────────────────────────────────────────── -->
|
||||
<template #footer>
|
||||
<Button label="Cancelar" severity="secondary" outlined @click="close" />
|
||||
<Button
|
||||
label="Confirmar Bloqueio"
|
||||
icon="pi pi-lock"
|
||||
severity="danger"
|
||||
:loading="saving"
|
||||
:disabled="!canConfirm"
|
||||
@click="confirmar"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Dialog feriado municipal -->
|
||||
<Dialog
|
||||
v-model:visible="fdlgOpen"
|
||||
modal
|
||||
:draggable="false"
|
||||
header="Cadastrar feriado municipal"
|
||||
:style="{ width: '420px' }"
|
||||
>
|
||||
<div class="flex flex-col gap-4 pt-1">
|
||||
<div>
|
||||
<label class="blq-label">Nome *</label>
|
||||
<InputText
|
||||
v-model="fform.nome"
|
||||
class="w-full mt-1"
|
||||
placeholder="Ex.: Aniversário da cidade, Padroeiro…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="blq-label">Data *</label>
|
||||
<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="blq-label">Observação <span class="opacity-60">(opcional)</span></label>
|
||||
<Textarea v-model="fform.observacao" class="w-full mt-1" rows="2" autoResize />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Cancelar" severity="secondary" outlined @click="fdlgOpen = false" />
|
||||
<Button
|
||||
label="Cadastrar"
|
||||
icon="pi pi-check"
|
||||
:disabled="!fform.nome || !fform.data"
|
||||
:loading="fsaving"
|
||||
@click="salvarFeriadoMunicipal"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── Aviso ─────────────────────────────────────────────── */
|
||||
.blq-warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.625rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.875rem;
|
||||
background: color-mix(in srgb, var(--red-400, #f87171) 10%, var(--surface-card));
|
||||
border: 1px solid color-mix(in srgb, var(--red-400, #f87171) 30%, transparent);
|
||||
}
|
||||
.blq-warning__icon {
|
||||
color: var(--red-500, #ef4444);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ── Label ─────────────────────────────────────────────── */
|
||||
.blq-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
/* ── Empty ─────────────────────────────────────────────── */
|
||||
.blq-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1.5rem 1rem;
|
||||
border-radius: 0.875rem;
|
||||
border: 1px dashed var(--surface-border);
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color-secondary);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ── Chips de horário ──────────────────────────────────── */
|
||||
.blq-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.4rem 0.875rem;
|
||||
border-radius: 999px;
|
||||
border: 1.5px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.14s;
|
||||
color: var(--text-color);
|
||||
}
|
||||
.blq-chip:hover {
|
||||
border-color: var(--red-300, #fca5a5);
|
||||
background: color-mix(in srgb, var(--red-400, #f87171) 8%, var(--surface-card));
|
||||
}
|
||||
.blq-chip--on {
|
||||
border-color: var(--red-500, #ef4444) !important;
|
||||
background: color-mix(in srgb, var(--red-500, #ef4444) 15%, var(--surface-card)) !important;
|
||||
color: var(--red-700, #b91c1c);
|
||||
}
|
||||
|
||||
/* ── Cards de período ──────────────────────────────────── */
|
||||
.blq-period-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.2rem;
|
||||
padding: 1.25rem 0.5rem;
|
||||
border-radius: 1rem;
|
||||
border: 1.5px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
cursor: pointer;
|
||||
transition: all 0.14s;
|
||||
color: var(--text-color);
|
||||
}
|
||||
.blq-period-card:hover {
|
||||
border-color: var(--red-300, #fca5a5);
|
||||
background: color-mix(in srgb, var(--red-400, #f87171) 8%, var(--surface-card));
|
||||
}
|
||||
.blq-period-card--on {
|
||||
border-color: var(--red-500, #ef4444) !important;
|
||||
background: color-mix(in srgb, var(--red-500, #ef4444) 15%, var(--surface-card)) !important;
|
||||
color: var(--red-700, #b91c1c);
|
||||
}
|
||||
|
||||
/* ── Feriados ──────────────────────────────────────────── */
|
||||
.blq-feriado-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border-radius: 0.875rem;
|
||||
border: 1.5px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
transition: all 0.14s;
|
||||
}
|
||||
.blq-feriado-row--blocked {
|
||||
border-color: var(--red-500, #ef4444);
|
||||
background: color-mix(in srgb, var(--red-500, #ef4444) 10%, var(--surface-card));
|
||||
}
|
||||
</style>
|
||||
442
src/features/agenda/components/ProximosFeriadosCard.vue
Normal file
442
src/features/agenda/components/ProximosFeriadosCard.vue
Normal file
@@ -0,0 +1,442 @@
|
||||
<!-- src/features/agenda/components/ProximosFeriadosCard.vue -->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useFeriados } from '@/composables/useFeriados'
|
||||
import DatePicker from 'primevue/datepicker'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
const props = defineProps({
|
||||
// Quando passados pelas páginas de agenda, dispensam o boot() interno
|
||||
ownerId: { type: String, default: null },
|
||||
tenantId: { type: String, default: null },
|
||||
workRules: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['bloqueado'])
|
||||
|
||||
const router = useRouter()
|
||||
const tenantStore = useTenantStore()
|
||||
const toast = useToast()
|
||||
|
||||
const { nacionais, municipais, todos, loading, load, criar, remover, isDuplicata, doMes } = useFeriados()
|
||||
|
||||
// ── Auth — só faz boot interno se as props não vieram ────────
|
||||
const _ownerId = ref(props.ownerId)
|
||||
const _tenantId = ref(props.tenantId)
|
||||
|
||||
watch(() => props.ownerId, v => { if (v) _ownerId.value = v })
|
||||
watch(() => props.tenantId, v => { if (v) _tenantId.value = v })
|
||||
|
||||
async function boot () {
|
||||
if (!_ownerId.value) {
|
||||
const { data } = await supabase.auth.getUser()
|
||||
_ownerId.value = data?.user?.id || null
|
||||
}
|
||||
if (!_tenantId.value) {
|
||||
_tenantId.value = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null
|
||||
}
|
||||
if (_tenantId.value) await load(_tenantId.value)
|
||||
}
|
||||
onMounted(boot)
|
||||
|
||||
// ── Feriados do mês atual ────────────────────────────────────
|
||||
const mesAtual = new Date().getMonth() + 1
|
||||
const feriadosMes = computed(() => doMes(mesAtual))
|
||||
|
||||
const MESES = ['Janeiro','Fevereiro','Março','Abril','Maio','Junho','Julho','Agosto','Setembro','Outubro','Novembro','Dezembro']
|
||||
const nomeMes = MESES[mesAtual - 1]
|
||||
|
||||
// ── Dias de trabalho (dow) ────────────────────────────────────
|
||||
const workDowSet = computed(() =>
|
||||
new Set((props.workRules || []).filter(r => r.ativo).map(r => Number(r.dia_semana)))
|
||||
)
|
||||
|
||||
function isDiaUtil (iso) {
|
||||
if (!iso) return false
|
||||
const [y, m, d] = iso.split('-').map(Number)
|
||||
const dow = new Date(y, m - 1, d).getDay()
|
||||
// Se não tem workRules, assume que todo dia pode ser relevante
|
||||
if (!props.workRules?.length) return true
|
||||
return workDowSet.value.has(dow)
|
||||
}
|
||||
|
||||
// ── Bloqueios já existentes para o mês ───────────────────────
|
||||
const bloqueiosDatas = ref(new Set()) // Set de ISO strings já bloqueadas (feriado)
|
||||
const loadingBloqueios = ref(false)
|
||||
|
||||
async function loadBloqueiosMes () {
|
||||
if (!_ownerId.value) return
|
||||
const ano = new Date().getFullYear()
|
||||
const start = `${ano}-${String(mesAtual).padStart(2,'0')}-01`
|
||||
const end = `${ano}-${String(mesAtual).padStart(2,'0')}-31`
|
||||
loadingBloqueios.value = true
|
||||
try {
|
||||
const { data } = await supabase
|
||||
.from('agenda_bloqueios')
|
||||
.select('data_inicio')
|
||||
.eq('owner_id', _ownerId.value)
|
||||
.in('origem', ['agenda_feriado', 'agenda_dia'])
|
||||
.gte('data_inicio', start)
|
||||
.lte('data_inicio', end)
|
||||
bloqueiosDatas.value = new Set((data || []).map(r => r.data_inicio))
|
||||
} catch { /* silencioso */ }
|
||||
finally { loadingBloqueios.value = false }
|
||||
}
|
||||
|
||||
watch(_ownerId, v => { if (v) loadBloqueiosMes() })
|
||||
onMounted(() => { if (_ownerId.value) loadBloqueiosMes() })
|
||||
|
||||
function jaFoiBloqueado (iso) {
|
||||
return bloqueiosDatas.value.has(iso)
|
||||
}
|
||||
|
||||
// ── Dupla confirmação inline ──────────────────────────────────
|
||||
const confirmandoIso = ref(null) // ISO do feriado aguardando confirmação
|
||||
const salvandoIso = ref(null) // ISO sendo gravado
|
||||
|
||||
function pedirConfirmacao (iso) {
|
||||
// Se já está confirmando outro, cancela e abre o novo
|
||||
confirmandoIso.value = confirmandoIso.value === iso ? null : iso
|
||||
}
|
||||
|
||||
function cancelarConfirmacao () {
|
||||
confirmandoIso.value = null
|
||||
}
|
||||
|
||||
async function confirmarBloqueio (feriado) {
|
||||
if (!_ownerId.value || !_tenantId.value) {
|
||||
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Configurações da agenda não carregadas.', life: 3000 })
|
||||
return
|
||||
}
|
||||
salvandoIso.value = feriado.data
|
||||
confirmandoIso.value = null
|
||||
try {
|
||||
const row = {
|
||||
owner_id: _ownerId.value,
|
||||
tenant_id: _tenantId.value,
|
||||
tipo: 'bloqueio',
|
||||
recorrente: false,
|
||||
titulo: `Feriado: ${feriado.nome}`,
|
||||
data_inicio: feriado.data,
|
||||
data_fim: feriado.data,
|
||||
hora_inicio: null,
|
||||
hora_fim: null,
|
||||
origem: 'agenda_feriado'
|
||||
}
|
||||
|
||||
const { error } = await supabase.from('agenda_bloqueios').insert([row])
|
||||
if (error) throw error
|
||||
|
||||
// Marcar sessões existentes no dia como 'remarcar'
|
||||
await supabase
|
||||
.from('agenda_eventos')
|
||||
.update({ status: 'remarcar' })
|
||||
.eq('owner_id', _ownerId.value)
|
||||
.eq('tipo', 'sessao')
|
||||
.gte('inicio_em', `${feriado.data}T00:00:00`)
|
||||
.lte('inicio_em', `${feriado.data}T23:59:59`)
|
||||
|
||||
bloqueiosDatas.value = new Set([...bloqueiosDatas.value, feriado.data])
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Dia bloqueado',
|
||||
detail: `${feriado.nome} bloqueado. Sessões existentes marcadas para reagendamento.`,
|
||||
life: 4000
|
||||
})
|
||||
emit('bloqueado', feriado)
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao bloquear.', life: 4000 })
|
||||
} finally {
|
||||
salvandoIso.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dialog cadastro municipal ─────────────────────────────────
|
||||
const dlgOpen = ref(false)
|
||||
const saving = ref(false)
|
||||
const form = ref({ nome: '', data: null, observacao: '', bloqueia_sessoes: false })
|
||||
|
||||
const formValid = computed(() => !!form.value.nome.trim() && !!form.value.data)
|
||||
|
||||
function abrirDialog () {
|
||||
form.value = { nome: '', data: null, observacao: '', bloqueia_sessoes: false }
|
||||
dlgOpen.value = true
|
||||
}
|
||||
|
||||
function dateToISO (d) {
|
||||
if (!d) return null
|
||||
const dt = d instanceof Date ? d : new Date(d)
|
||||
return `${dt.getFullYear()}-${String(dt.getMonth()+1).padStart(2,'0')}-${String(dt.getDate()).padStart(2,'0')}`
|
||||
}
|
||||
|
||||
async function salvar () {
|
||||
if (!formValid.value) return
|
||||
const iso = dateToISO(form.value.data)
|
||||
if (isDuplicata(iso, form.value.nome)) {
|
||||
toast.add({ severity: 'warn', summary: 'Duplicado', detail: 'Já existe um feriado com esse nome nessa data.', life: 3000 })
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await criar({
|
||||
tenant_id: _tenantId.value,
|
||||
owner_id: _ownerId.value,
|
||||
tipo: 'municipal',
|
||||
nome: form.value.nome.trim(),
|
||||
data: iso,
|
||||
observacao: form.value.observacao || null,
|
||||
bloqueia_sessoes: form.value.bloqueia_sessoes
|
||||
})
|
||||
toast.add({ severity: 'success', summary: 'Feriado cadastrado', life: 1800 })
|
||||
dlgOpen.value = false
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3500 })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────
|
||||
function fmtDate (iso) {
|
||||
if (!iso) return ''
|
||||
const [, m, d] = String(iso).split('-')
|
||||
return `${d}/${m}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-bind="$attrs" class="rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden shadow-sm">
|
||||
|
||||
<!-- Cabeçalho -->
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border)]">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-star text-amber-500 text-sm" />
|
||||
<span class="font-semibold text-sm">Próximos feriados</span>
|
||||
</div>
|
||||
<span class="text-xs text-[var(--text-color-secondary)]">{{ nomeMes }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Lista -->
|
||||
<div class="px-4 py-3">
|
||||
<div v-if="loading" class="flex justify-center py-3">
|
||||
<i class="pi pi-spinner pi-spin opacity-40" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="!feriadosMes.length" class="text-sm text-[var(--text-color-secondary)] py-1">
|
||||
Nenhum feriado este mês.
|
||||
</div>
|
||||
|
||||
<ul v-else class="flex flex-col gap-2">
|
||||
<li
|
||||
v-for="f in feriadosMes"
|
||||
:key="f.data + f.nome"
|
||||
class="flex flex-col gap-1"
|
||||
>
|
||||
<!-- Linha principal do feriado -->
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-[var(--text-color-secondary)] font-mono text-xs w-10 shrink-0">{{ fmtDate(f.data) }}</span>
|
||||
<span class="flex-1 truncate" :class="{ 'line-through opacity-50': jaFoiBloqueado(f.data) }">{{ f.nome }}</span>
|
||||
<Tag
|
||||
:value="f.tipo === 'nacional' ? 'Nacional' : 'Municipal'"
|
||||
:severity="f.tipo === 'nacional' ? 'info' : 'warn'"
|
||||
class="text-xs shrink-0"
|
||||
/>
|
||||
|
||||
<!-- Botão bloquear / já bloqueado -->
|
||||
<template v-if="isDiaUtil(f.data)">
|
||||
<!-- Já bloqueado -->
|
||||
<span
|
||||
v-if="jaFoiBloqueado(f.data)"
|
||||
v-tooltip.top="'Dia já bloqueado'"
|
||||
class="pfc-lock pfc-lock--done"
|
||||
>
|
||||
<i class="pi pi-lock text-xs" />
|
||||
</span>
|
||||
|
||||
<!-- Salvando -->
|
||||
<span v-else-if="salvandoIso === f.data" class="pfc-lock">
|
||||
<i class="pi pi-spinner pi-spin text-xs" />
|
||||
</span>
|
||||
|
||||
<!-- Aguardando confirmação — ícone ativo -->
|
||||
<button
|
||||
v-else-if="confirmandoIso === f.data"
|
||||
v-tooltip.top="'Cancelar'"
|
||||
class="pfc-lock pfc-lock--active"
|
||||
@click="cancelarConfirmacao"
|
||||
>
|
||||
<i class="pi pi-times text-xs" />
|
||||
</button>
|
||||
|
||||
<!-- Estado normal — abre confirmação -->
|
||||
<button
|
||||
v-else
|
||||
v-tooltip.top="'Bloquear este dia'"
|
||||
class="pfc-lock pfc-lock--idle"
|
||||
@click="pedirConfirmacao(f.data)"
|
||||
>
|
||||
<i class="pi pi-lock-open text-xs" />
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Confirmação inline (expande abaixo do item) -->
|
||||
<Transition name="pfc-expand">
|
||||
<div
|
||||
v-if="confirmandoIso === f.data"
|
||||
class="pfc-confirm"
|
||||
>
|
||||
<i class="pi pi-exclamation-triangle pfc-confirm__icon" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs font-semibold mb-0.5">Bloquear {{ f.nome }}?</p>
|
||||
<p class="text-xs opacity-70 leading-snug">O dia inteiro ficará indisponível. Sessões existentes serão marcadas para reagendamento.</p>
|
||||
</div>
|
||||
<div class="flex gap-1.5 shrink-0">
|
||||
<Button label="Não" size="small" severity="secondary" outlined class="rounded-full h-7 text-xs px-3" @click="cancelarConfirmacao" />
|
||||
<Button label="Bloquear" size="small" severity="danger" icon="pi pi-lock" class="rounded-full h-7 text-xs px-3" @click="confirmarBloqueio(f)" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Ações -->
|
||||
<div class="flex flex-col gap-1.5 px-4 pb-4">
|
||||
<Button
|
||||
icon="pi pi-plus"
|
||||
label="Cadastrar feriado municipal"
|
||||
severity="secondary"
|
||||
outlined
|
||||
size="small"
|
||||
class="w-full rounded-full"
|
||||
@click="abrirDialog"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-list"
|
||||
label="Ver todos os feriados"
|
||||
text
|
||||
size="small"
|
||||
class="w-full rounded-full"
|
||||
@click="router.push('/configuracoes/bloqueios')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Dialog cadastro ──────────────────────────────────── -->
|
||||
<Dialog
|
||||
v-model:visible="dlgOpen"
|
||||
modal
|
||||
:draggable="false"
|
||||
header="Cadastrar feriado municipal"
|
||||
:style="{ width: '420px' }"
|
||||
>
|
||||
<div class="flex flex-col gap-4 pt-1">
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-color-secondary)] font-medium">Nome do feriado *</label>
|
||||
<InputText v-model="form.nome" class="w-full mt-1" placeholder="Ex.: Aniversário da cidade, Padroeiro…" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-color-secondary)] font-medium">Data *</label>
|
||||
<DatePicker
|
||||
v-model="form.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="text-xs text-[var(--text-color-secondary)] font-medium">Observação <span class="opacity-60">(opcional)</span></label>
|
||||
<Textarea v-model="form.observacao" class="w-full mt-1" rows="2" autoResize placeholder="Nota interna…" />
|
||||
</div>
|
||||
<div v-if="form.data && form.nome && isDuplicata(dateToISO(form.data), form.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="dlgOpen = false" />
|
||||
<Button
|
||||
label="Cadastrar"
|
||||
icon="pi pi-check"
|
||||
:disabled="!formValid || (form.data && form.nome && isDuplicata(dateToISO(form.data), form.nome))"
|
||||
:loading="saving"
|
||||
@click="salvar"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── Ícone de cadeado por feriado ────────────────────────── */
|
||||
.pfc-lock {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.14s;
|
||||
}
|
||||
.pfc-lock--idle {
|
||||
color: var(--text-color-secondary);
|
||||
background: transparent;
|
||||
border: 1.5px solid var(--surface-border);
|
||||
cursor: pointer;
|
||||
}
|
||||
.pfc-lock--idle:hover {
|
||||
color: var(--red-600, #dc2626);
|
||||
border-color: var(--red-400, #f87171);
|
||||
background: color-mix(in srgb, var(--red-400, #f87171) 10%, transparent);
|
||||
}
|
||||
.pfc-lock--active {
|
||||
color: var(--red-600, #dc2626);
|
||||
border: 1.5px solid var(--red-400, #f87171);
|
||||
background: color-mix(in srgb, var(--red-400, #f87171) 12%, transparent);
|
||||
cursor: pointer;
|
||||
}
|
||||
.pfc-lock--done {
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.45;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── Confirmação inline ───────────────────────────────────── */
|
||||
.pfc-confirm {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.625rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border-radius: 0.875rem;
|
||||
background: color-mix(in srgb, var(--red-400, #f87171) 10%, var(--surface-card));
|
||||
border: 1px solid color-mix(in srgb, var(--red-400, #f87171) 30%, transparent);
|
||||
margin-left: 2.75rem; /* alinha com o nome, após a data */
|
||||
}
|
||||
.pfc-confirm__icon {
|
||||
color: var(--red-500, #ef4444);
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* ── Transição expand ─────────────────────────────────────── */
|
||||
.pfc-expand-enter-active,
|
||||
.pfc-expand-leave-active {
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.pfc-expand-enter-from,
|
||||
.pfc-expand-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
</style>
|
||||
277
src/features/agenda/composables/__tests__/useRecurrence.spec.js
Normal file
277
src/features/agenda/composables/__tests__/useRecurrence.spec.js
Normal file
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* useRecurrence.spec.js
|
||||
*
|
||||
* Testa as funções puras do módulo de recorrência:
|
||||
* - generateDates → geração de datas por tipo de regra
|
||||
* - expandRules → aplicação de exceções sobre as ocorrências
|
||||
* - mergeWithStoredSessions → merge de ocorrências virtuais com eventos reais
|
||||
*
|
||||
* Não usa Supabase — sem mocks necessários.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { generateDates, expandRules, mergeWithStoredSessions } from '../useRecurrence.js'
|
||||
|
||||
// ─── helpers de fixture ───────────────────────────────────────────────────────
|
||||
|
||||
function d (iso) {
|
||||
const [y, m, day] = iso.split('-').map(Number)
|
||||
return new Date(y, m - 1, day)
|
||||
}
|
||||
|
||||
function rule (overrides = {}) {
|
||||
return {
|
||||
id: 'rule-1',
|
||||
owner_id: 'owner-1',
|
||||
tenant_id: 'tenant-1',
|
||||
patient_id: 'patient-1',
|
||||
therapist_id: 'therapist-1',
|
||||
status: 'ativo',
|
||||
type: 'weekly',
|
||||
weekdays: [1], // segunda
|
||||
interval: 1,
|
||||
start_date: '2026-03-02', // segunda
|
||||
end_date: null,
|
||||
max_occurrences: null,
|
||||
open_ended: true,
|
||||
start_time: '09:00',
|
||||
end_time: '10:00',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function exception (overrides = {}) {
|
||||
return {
|
||||
id: 'exc-1',
|
||||
recurrence_id: 'rule-1',
|
||||
original_date: '2026-03-09',
|
||||
type: 'cancel_session',
|
||||
new_date: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── generateDates ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('generateDates — weekly', () => {
|
||||
it('gera ocorrências semanais dentro do range', () => {
|
||||
const r = rule({ type: 'weekly', weekdays: [1], start_date: '2026-03-02' })
|
||||
const dates = generateDates(r, d('2026-03-01'), d('2026-03-31'))
|
||||
const isos = dates.map(d => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`)
|
||||
expect(isos).toEqual(['2026-03-02', '2026-03-09', '2026-03-16', '2026-03-23', '2026-03-30'])
|
||||
})
|
||||
|
||||
it('não gera antes do start_date da regra', () => {
|
||||
const r = rule({ type: 'weekly', weekdays: [1], start_date: '2026-03-16' })
|
||||
const dates = generateDates(r, d('2026-03-01'), d('2026-03-31'))
|
||||
expect(dates.every(d => d >= new Date(2026, 2, 16))).toBe(true)
|
||||
})
|
||||
|
||||
it('não gera após o end_date da regra', () => {
|
||||
const r = rule({ type: 'weekly', weekdays: [1], start_date: '2026-03-02', end_date: '2026-03-16' })
|
||||
const dates = generateDates(r, d('2026-03-01'), d('2026-03-31'))
|
||||
expect(dates.length).toBe(3) // 02, 09, 16
|
||||
})
|
||||
|
||||
it('respeita max_occurrences dentro do range', () => {
|
||||
const r = rule({ type: 'weekly', weekdays: [1], start_date: '2026-03-02', max_occurrences: 2 })
|
||||
const dates = generateDates(r, d('2026-03-01'), d('2026-03-31'))
|
||||
expect(dates.length).toBe(2)
|
||||
})
|
||||
|
||||
it('respeita max_occurrences globalmente — range começa na 3ª semana', () => {
|
||||
// 4 ocorrências totais, range começa na semana 3 → só 2 dentro do range
|
||||
const r = rule({ type: 'weekly', weekdays: [1], start_date: '2026-03-02', max_occurrences: 4 })
|
||||
const dates = generateDates(r, d('2026-03-15'), d('2026-04-30'))
|
||||
// 16, 23, 30 → mas max=4 globalmente (2 antes do range + 2 no range)
|
||||
expect(dates.length).toBe(2) // 2026-03-16, 2026-03-23
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateDates — biweekly', () => {
|
||||
it('gera ocorrências a cada 2 semanas', () => {
|
||||
const r = rule({ type: 'biweekly', weekdays: [1], interval: 2, start_date: '2026-03-02' })
|
||||
const dates = generateDates(r, d('2026-03-01'), d('2026-04-30'))
|
||||
const isos = dates.map(d => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`)
|
||||
expect(isos).toEqual(['2026-03-02', '2026-03-16', '2026-03-30', '2026-04-13', '2026-04-27'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateDates — custom_weekdays', () => {
|
||||
it('gera ocorrências em múltiplos dias da semana', () => {
|
||||
const r = rule({ type: 'custom_weekdays', weekdays: [1, 3], start_date: '2026-03-02' }) // seg e qua
|
||||
const dates = generateDates(r, d('2026-03-01'), d('2026-03-08'))
|
||||
const isos = dates.map(d => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`)
|
||||
expect(isos).toEqual(['2026-03-02', '2026-03-04'])
|
||||
})
|
||||
|
||||
it('respeita max_occurrences globalmente com custom_weekdays', () => {
|
||||
// 2 dias/semana, max=3 → semana 1 (02,04), semana 2 (09) e para
|
||||
const r = rule({ type: 'custom_weekdays', weekdays: [1, 3], start_date: '2026-03-02', max_occurrences: 3 })
|
||||
const dates = generateDates(r, d('2026-03-01'), d('2026-03-31'))
|
||||
expect(dates.length).toBe(3)
|
||||
})
|
||||
|
||||
it('max_occurrences globalmente — range começa na semana 2', () => {
|
||||
// semana 1 já consumiu 2 ocorrências (02, 04), max=3 → só 1 no range (09)
|
||||
const r = rule({ type: 'custom_weekdays', weekdays: [1, 3], start_date: '2026-03-02', max_occurrences: 3 })
|
||||
const dates = generateDates(r, d('2026-03-08'), d('2026-03-31'))
|
||||
expect(dates.length).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateDates — monthly', () => {
|
||||
it('gera ocorrências mensais no mesmo dia', () => {
|
||||
const r = rule({ type: 'monthly', weekdays: [1], start_date: '2026-01-15' })
|
||||
const dates = generateDates(r, d('2026-01-01'), d('2026-04-30'))
|
||||
expect(dates.length).toBe(4)
|
||||
expect(dates.every(d => d.getDate() === 15)).toBe(true)
|
||||
})
|
||||
|
||||
it('respeita max_occurrences globalmente — range começa no mês 3', () => {
|
||||
const r = rule({ type: 'monthly', weekdays: [1], start_date: '2026-01-15', max_occurrences: 3 })
|
||||
const dates = generateDates(r, d('2026-03-01'), d('2026-12-31'))
|
||||
expect(dates.length).toBe(1) // só março (jan+fev já consumiram 2 de 3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateDates — yearly', () => {
|
||||
it('gera ocorrências anuais', () => {
|
||||
const r = rule({ type: 'yearly', weekdays: [1], start_date: '2024-06-15' })
|
||||
const dates = generateDates(r, d('2024-01-01'), d('2027-12-31'))
|
||||
expect(dates.length).toBe(4) // 2024, 2025, 2026, 2027
|
||||
})
|
||||
|
||||
it('respeita max_occurrences globalmente — range começa no ano 3', () => {
|
||||
const r = rule({ type: 'yearly', weekdays: [1], start_date: '2024-06-15', max_occurrences: 3 })
|
||||
const dates = generateDates(r, d('2026-01-01'), d('2030-12-31'))
|
||||
expect(dates.length).toBe(1) // só 2026 (2024+2025 já consumiram 2 de 3)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── expandRules ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('expandRules', () => {
|
||||
it('gera ocorrência normal sem exceção', () => {
|
||||
const rules = [rule()]
|
||||
const occs = expandRules(rules, [], d('2026-03-01'), d('2026-03-08'))
|
||||
expect(occs.length).toBe(1)
|
||||
expect(occs[0].status).toBe('agendado')
|
||||
expect(occs[0].exception_type).toBeNull()
|
||||
})
|
||||
|
||||
it('cancela ocorrência com cancel_session', () => {
|
||||
const rules = [rule()]
|
||||
const excs = [exception({ type: 'cancel_session', original_date: '2026-03-02' })]
|
||||
const occs = expandRules(rules, excs, d('2026-03-01'), d('2026-03-08'))
|
||||
expect(occs[0].status).toBe('cancelado')
|
||||
expect(occs[0].exception_type).toBe('cancel_session')
|
||||
})
|
||||
|
||||
it('marca falta com patient_missed', () => {
|
||||
const rules = [rule()]
|
||||
const excs = [exception({ type: 'patient_missed', original_date: '2026-03-02' })]
|
||||
const occs = expandRules(rules, excs, d('2026-03-01'), d('2026-03-08'))
|
||||
expect(occs[0].status).toBe('faltou')
|
||||
})
|
||||
|
||||
it('remarca ocorrência para nova data', () => {
|
||||
const rules = [rule()]
|
||||
const excs = [exception({
|
||||
type: 'reschedule_session',
|
||||
original_date: '2026-03-02',
|
||||
new_date: '2026-03-04',
|
||||
})]
|
||||
const occs = expandRules(rules, excs, d('2026-03-01'), d('2026-03-08'))
|
||||
// A ocorrência do dia 02 foi movida para 04
|
||||
expect(occs[0].status).toBe('remarcado')
|
||||
expect(occs[0].exception_type).toBe('reschedule_session')
|
||||
// inicio_em reflete a nova data (04); original_date no main loop recebe new_date
|
||||
expect(occs[0].inicio_em).toContain('2026-03-04')
|
||||
})
|
||||
|
||||
it('post-pass: remarcação inbound — original fora do range, new_date dentro', () => {
|
||||
// Regra começa em 02/03 (segunda). original_date = 09/03 está FORA do range 16-22.
|
||||
// new_date = 17/03 está DENTRO do range.
|
||||
const rules = [rule({ start_date: '2026-03-02' })]
|
||||
const excs = [exception({
|
||||
type: 'reschedule_session',
|
||||
original_date: '2026-03-09', // fora do range
|
||||
new_date: '2026-03-17', // dentro do range
|
||||
})]
|
||||
const occs = expandRules(rules, excs, d('2026-03-16'), d('2026-03-22'))
|
||||
const remarcado = occs.find(o => o.status === 'remarcado')
|
||||
expect(remarcado).toBeDefined()
|
||||
expect(remarcado.original_date).toBe('2026-03-09')
|
||||
expect(remarcado.inicio_em).toContain('2026-03-17')
|
||||
})
|
||||
|
||||
it('ignora regra cancelada', () => {
|
||||
const rules = [rule({ status: 'cancelado' })]
|
||||
const occs = expandRules(rules, [], d('2026-03-01'), d('2026-03-31'))
|
||||
expect(occs.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── mergeWithStoredSessions ──────────────────────────────────────────────────
|
||||
|
||||
describe('mergeWithStoredSessions', () => {
|
||||
it('sessão real substitui ocorrência virtual para a mesma regra+data', () => {
|
||||
const occs = [{
|
||||
recurrence_id: 'rule-1',
|
||||
original_date: '2026-03-02',
|
||||
status: 'agendado',
|
||||
is_occurrence: true,
|
||||
is_real_session: false,
|
||||
titulo: 'Virtual',
|
||||
}]
|
||||
const storedRows = [{
|
||||
id: 'ev-real-1',
|
||||
recurrence_id: 'rule-1',
|
||||
recurrence_date: '2026-03-02',
|
||||
status: 'realizado',
|
||||
titulo: 'Real',
|
||||
}]
|
||||
const merged = mergeWithStoredSessions(occs, storedRows)
|
||||
expect(merged.length).toBe(1)
|
||||
expect(merged[0].is_real_session).toBe(true)
|
||||
expect(merged[0].status).toBe('realizado')
|
||||
expect(merged[0].titulo).toBe('Real')
|
||||
})
|
||||
|
||||
it('mantém ocorrência virtual quando não há sessão real', () => {
|
||||
const occs = [{
|
||||
recurrence_id: 'rule-1',
|
||||
original_date: '2026-03-02',
|
||||
status: 'agendado',
|
||||
is_occurrence: true,
|
||||
}]
|
||||
const merged = mergeWithStoredSessions(occs, [])
|
||||
expect(merged.length).toBe(1)
|
||||
expect(merged[0].is_occurrence).toBe(true)
|
||||
})
|
||||
|
||||
it('adiciona sessão real órfã (sem ocorrência correspondente)', () => {
|
||||
const storedRows = [{
|
||||
id: 'ev-orphan',
|
||||
recurrence_id: 'rule-1',
|
||||
recurrence_date: '2026-03-30', // data fora do range expandido
|
||||
status: 'agendado',
|
||||
}]
|
||||
const merged = mergeWithStoredSessions([], storedRows)
|
||||
expect(merged.length).toBe(1)
|
||||
expect(merged[0].is_real_session).toBe(true)
|
||||
})
|
||||
|
||||
it('não duplica quando há tanto ocorrência quanto sessão real', () => {
|
||||
const occs = [
|
||||
{ recurrence_id: 'rule-1', original_date: '2026-03-02', is_occurrence: true },
|
||||
{ recurrence_id: 'rule-1', original_date: '2026-03-09', is_occurrence: true },
|
||||
]
|
||||
const stored = [
|
||||
{ recurrence_id: 'rule-1', recurrence_date: '2026-03-02', status: 'realizado' }
|
||||
]
|
||||
const merged = mergeWithStoredSessions(occs, stored)
|
||||
expect(merged.length).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -1,106 +1,186 @@
|
||||
// src/features/agenda/composables/useAgendaEvents.js
|
||||
import { ref } from 'vue'
|
||||
/**
|
||||
* useAgendaEvents.js
|
||||
* src/features/agenda/composables/useAgendaEvents.js
|
||||
*
|
||||
* Gerencia apenas eventos reais (agenda_eventos).
|
||||
* Sessões com recurrence_id são sessões reais de uma série.
|
||||
*/
|
||||
|
||||
import {
|
||||
listMyAgendaEvents,
|
||||
listClinicEvents,
|
||||
createAgendaEvento,
|
||||
updateAgendaEvento,
|
||||
deleteAgendaEvento
|
||||
} from '../services/agendaRepository.js'
|
||||
import { ref } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
|
||||
// ─── helpers internos ────────────────────────────────────────────────────────
|
||||
|
||||
function assertTenantId (tenantId) {
|
||||
if (!tenantId || tenantId === 'null' || tenantId === 'undefined') {
|
||||
throw new Error('Tenant ativo inválido. Selecione a clínica/tenant antes de operar na agenda.')
|
||||
}
|
||||
}
|
||||
|
||||
async function getUid () {
|
||||
const { data, error } = await supabase.auth.getUser()
|
||||
if (error) throw error
|
||||
const uid = data?.user?.id
|
||||
if (!uid) throw new Error('Usuário não autenticado.')
|
||||
return uid
|
||||
}
|
||||
|
||||
const BASE_SELECT = `
|
||||
id, owner_id, patient_id, tipo, status,
|
||||
titulo, titulo_custom, observacoes, inicio_em, fim_em,
|
||||
terapeuta_id, tenant_id, visibility_scope,
|
||||
determined_commitment_id, link_online, extra_fields, modalidade,
|
||||
recurrence_id, recurrence_date,
|
||||
mirror_of_event_id, price,
|
||||
patients!agenda_eventos_patient_id_fkey (
|
||||
id, nome_completo, avatar_url
|
||||
),
|
||||
determined_commitments!agenda_eventos_determined_commitment_fk (
|
||||
id, bg_color, text_color
|
||||
)
|
||||
`.trim()
|
||||
|
||||
export function useAgendaEvents () {
|
||||
const rows = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const rows = ref([])
|
||||
const error = ref(null)
|
||||
|
||||
async function loadMyRange (start, end, ownerId) {
|
||||
if (!ownerId) return
|
||||
|
||||
const tenantStore = useTenantStore()
|
||||
const tenantId = tenantStore.activeTenantId
|
||||
assertTenantId(tenantId)
|
||||
|
||||
async function loadMyRange (startISO, endISO) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
error.value = null
|
||||
try {
|
||||
rows.value = await listMyAgendaEvents({ startISO, endISO })
|
||||
return rows.value
|
||||
const { data, error: err } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select(BASE_SELECT)
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('owner_id', ownerId)
|
||||
.is('mirror_of_event_id', null)
|
||||
.gte('inicio_em', start)
|
||||
.lte('inicio_em', end)
|
||||
.order('inicio_em', { ascending: true })
|
||||
|
||||
if (err) throw err
|
||||
rows.value = (data || []).map(flattenRow)
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao carregar eventos.'
|
||||
error.value = e?.message || 'Erro ao carregar eventos'
|
||||
rows.value = []
|
||||
return []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadClinicRange (ownerIds, startISO, endISO) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
// ✅ evita erro "invalid input syntax for type uuid: null"
|
||||
const safeIds = (ownerIds || []).filter(id => typeof id === 'string' && id && id !== 'null' && id !== 'undefined')
|
||||
if (!safeIds.length) {
|
||||
rows.value = []
|
||||
return []
|
||||
}
|
||||
|
||||
rows.value = await listClinicEvents({ ownerIds: safeIds, startISO, endISO })
|
||||
return rows.value
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao carregar eventos da clínica.'
|
||||
rows.value = []
|
||||
return []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cria um evento injetando tenant_id e owner_id automaticamente.
|
||||
* owner_id é sempre o usuário autenticado — nunca vem do payload externo.
|
||||
* tenant_id vem do tenantStore ativo — nunca do payload externo.
|
||||
*/
|
||||
async function create (payload) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const created = await createAgendaEvento(payload)
|
||||
return created
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao criar evento.'
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
const tenantStore = useTenantStore()
|
||||
const tenantId = tenantStore.activeTenantId
|
||||
assertTenantId(tenantId)
|
||||
|
||||
const uid = await getUid()
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { paciente_id: _dropped, ...rest } = payload
|
||||
const safePayload = {
|
||||
...rest,
|
||||
tenant_id: tenantId,
|
||||
owner_id: uid,
|
||||
}
|
||||
|
||||
const { data, error: err } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.insert([safePayload])
|
||||
.select(BASE_SELECT)
|
||||
.single()
|
||||
if (err) throw err
|
||||
return flattenRow(data)
|
||||
}
|
||||
|
||||
async function update (id, patch) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const updated = await updateAgendaEvento(id, patch)
|
||||
return updated
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao atualizar evento.'
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
if (!id) throw new Error('ID inválido.')
|
||||
|
||||
const tenantStore = useTenantStore()
|
||||
const tenantId = tenantStore.activeTenantId
|
||||
assertTenantId(tenantId)
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { paciente_id: _dropped, ...safePatch } = patch
|
||||
|
||||
const { data, error: err } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.update(safePatch)
|
||||
.eq('id', id)
|
||||
.eq('tenant_id', tenantId)
|
||||
.select(BASE_SELECT)
|
||||
.single()
|
||||
if (err) throw err
|
||||
return flattenRow(data)
|
||||
}
|
||||
|
||||
async function remove (id) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await deleteAgendaEvento(id)
|
||||
return true
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao excluir evento.'
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
if (!id) throw new Error('ID inválido.')
|
||||
|
||||
const tenantStore = useTenantStore()
|
||||
const tenantId = tenantStore.activeTenantId
|
||||
assertTenantId(tenantId)
|
||||
|
||||
const { error: err } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
.eq('tenant_id', tenantId)
|
||||
if (err) throw err
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
rows,
|
||||
loadMyRange,
|
||||
loadClinicRange,
|
||||
create,
|
||||
update,
|
||||
remove
|
||||
async function removeSeriesFrom (recurrenceId, fromDateISO) {
|
||||
if (!recurrenceId) throw new Error('recurrenceId inválido.')
|
||||
|
||||
const tenantStore = useTenantStore()
|
||||
const tenantId = tenantStore.activeTenantId
|
||||
assertTenantId(tenantId)
|
||||
|
||||
const { error: err } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.delete()
|
||||
.eq('recurrence_id', recurrenceId)
|
||||
.eq('tenant_id', tenantId)
|
||||
.gte('recurrence_date', fromDateISO)
|
||||
if (err) throw err
|
||||
}
|
||||
|
||||
async function removeAllSeries (recurrenceId) {
|
||||
if (!recurrenceId) throw new Error('recurrenceId inválido.')
|
||||
|
||||
const tenantStore = useTenantStore()
|
||||
const tenantId = tenantStore.activeTenantId
|
||||
assertTenantId(tenantId)
|
||||
|
||||
const { error: err } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.delete()
|
||||
.eq('recurrence_id', recurrenceId)
|
||||
.eq('tenant_id', tenantId)
|
||||
if (err) throw err
|
||||
}
|
||||
|
||||
return { rows, loading, error, loadMyRange, create, update, remove, removeSeriesFrom, removeAllSeries }
|
||||
}
|
||||
|
||||
function flattenRow (r) {
|
||||
if (!r) return r
|
||||
const patient = r.patients || null
|
||||
const out = { ...r }
|
||||
delete out.patients
|
||||
out.paciente_nome = patient?.nome_completo || out.paciente_nome || ''
|
||||
out.paciente_avatar = patient?.avatar_url || out.paciente_avatar || ''
|
||||
return out
|
||||
}
|
||||
@@ -1,24 +1,31 @@
|
||||
// src/features/agenda/composables/useAgendaSettings.js
|
||||
import { ref } from 'vue'
|
||||
import { getMyAgendaSettings } from '../services/agendaRepository'
|
||||
import { getMyAgendaSettings, getMyWorkSchedule } from '../services/agendaRepository'
|
||||
|
||||
export function useAgendaSettings () {
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const settings = ref(null)
|
||||
const workRules = ref([]) // [{ dia_semana, hora_inicio, hora_fim }]
|
||||
|
||||
async function load () {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
settings.value = await getMyAgendaSettings()
|
||||
const [cfg, rules] = await Promise.all([
|
||||
getMyAgendaSettings(),
|
||||
getMyWorkSchedule()
|
||||
])
|
||||
settings.value = cfg
|
||||
workRules.value = rules
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao carregar configurações da agenda.'
|
||||
settings.value = null
|
||||
workRules.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { loading, error, settings, load }
|
||||
}
|
||||
return { loading, error, settings, workRules, load }
|
||||
}
|
||||
|
||||
57
src/features/agenda/composables/useProfessionalPricing.js
Normal file
57
src/features/agenda/composables/useProfessionalPricing.js
Normal file
@@ -0,0 +1,57 @@
|
||||
// src/features/agenda/composables/useProfessionalPricing.js
|
||||
//
|
||||
// Carrega a tabela professional_pricing do owner logado e expõe
|
||||
// getPriceFor(commitmentId) → number | null
|
||||
//
|
||||
// null = commitment_id
|
||||
// Regra: lookup exato → fallback NULL → null se nenhum configurado
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
export function useProfessionalPricing () {
|
||||
const rows = ref([]) // professional_pricing rows
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
// ── Carregar todos os preços do owner ──────────────────────────────
|
||||
async function load (ownerId) {
|
||||
if (!ownerId) return
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const { data, error: err } = await supabase
|
||||
.from('professional_pricing')
|
||||
.select('id, determined_commitment_id, price, notes')
|
||||
.eq('owner_id', ownerId)
|
||||
|
||||
if (err) throw err
|
||||
rows.value = data || []
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao carregar precificação.'
|
||||
rows.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Consulta: preço para um tipo de compromisso ────────────────────
|
||||
// 1. Linha com determined_commitment_id === commitmentId
|
||||
// 2. Fallback: linha com determined_commitment_id === null (preço padrão)
|
||||
// 3. null se nada configurado
|
||||
function getPriceFor (commitmentId) {
|
||||
if (!rows.value.length) return null
|
||||
|
||||
// match exato
|
||||
if (commitmentId) {
|
||||
const exact = rows.value.find(r => r.determined_commitment_id === commitmentId)
|
||||
if (exact && exact.price != null) return Number(exact.price)
|
||||
}
|
||||
|
||||
// fallback padrão (commitment_id IS NULL)
|
||||
const def = rows.value.find(r => r.determined_commitment_id === null)
|
||||
return def && def.price != null ? Number(def.price) : null
|
||||
}
|
||||
|
||||
return { rows, loading, error, load, getPriceFor }
|
||||
}
|
||||
653
src/features/agenda/composables/useRecurrence.js
Normal file
653
src/features/agenda/composables/useRecurrence.js
Normal file
@@ -0,0 +1,653 @@
|
||||
/**
|
||||
* useRecurrence.js
|
||||
* src/features/agenda/composables/useRecurrence.js
|
||||
*
|
||||
* Coração da nova arquitetura de recorrência.
|
||||
* Gera ocorrências dinamicamente no frontend a partir das regras.
|
||||
* Nunca grava eventos futuros no banco — apenas regras + exceções.
|
||||
*
|
||||
* Fluxo:
|
||||
* 1. loadRules(ownerId, rangeStart, rangeEnd) → busca regras ativas
|
||||
* 2. loadExceptions(ruleIds, rangeStart, rangeEnd) → busca exceções no range
|
||||
* 3. expandRules(rules, exceptions, rangeStart, rangeEnd) → gera ocorrências
|
||||
* 4. mergeWithStoredSessions(occurrences, storedRows) → sessões reais sobrepõem
|
||||
*/
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { logRecurrence, logError, logPerf } from '@/support/supportLogger'
|
||||
|
||||
// ─── helpers de data ────────────────────────────────────────────────────────
|
||||
|
||||
/** 'YYYY-MM-DD' → Date (local, sem UTC shift) */
|
||||
function parseDate (iso) {
|
||||
const [y, m, d] = String(iso).slice(0, 10).split('-').map(Number)
|
||||
return new Date(y, m - 1, d)
|
||||
}
|
||||
|
||||
/** Date → 'YYYY-MM-DD' */
|
||||
function toISO (d) {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** 'HH:MM' ou 'HH:MM:SS' → { hours, minutes } */
|
||||
function parseTime (t) {
|
||||
const [h, m] = String(t || '00:00').split(':').map(Number)
|
||||
return { hours: h || 0, minutes: m || 0 }
|
||||
}
|
||||
|
||||
/** Aplica HH:MM a um Date, retorna novo Date */
|
||||
function applyTime (date, timeStr) {
|
||||
const d = new Date(date)
|
||||
const { hours, minutes } = parseTime(timeStr)
|
||||
d.setHours(hours, minutes, 0, 0)
|
||||
return d
|
||||
}
|
||||
|
||||
/** Avança cursor para o próximo dia-da-semana especificado */
|
||||
function nextWeekday (fromDate, targetDow) {
|
||||
const d = new Date(fromDate)
|
||||
const diff = (targetDow - d.getDay() + 7) % 7
|
||||
d.setDate(d.getDate() + (diff === 0 ? 0 : diff))
|
||||
return d
|
||||
}
|
||||
|
||||
// ─── geradores de datas por tipo ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Gera array de datas (Date) para uma regra no intervalo [rangeStart, rangeEnd]
|
||||
* Respeita: start_date, end_date, max_occurrences, open_ended
|
||||
*/
|
||||
export function generateDates (rule, rangeStart, rangeEnd) {
|
||||
const ruleStart = parseDate(rule.start_date)
|
||||
const ruleEnd = rule.end_date ? parseDate(rule.end_date) : null
|
||||
const effStart = ruleStart > rangeStart ? ruleStart : rangeStart
|
||||
const effEnd = ruleEnd && ruleEnd < rangeEnd ? ruleEnd : rangeEnd
|
||||
const interval = Number(rule.interval || 1)
|
||||
const weekdays = (rule.weekdays || []).map(Number)
|
||||
|
||||
if (!weekdays.length) return []
|
||||
|
||||
const dates = []
|
||||
|
||||
if (rule.type === 'weekly' || rule.type === 'biweekly') {
|
||||
const dow = weekdays[0]
|
||||
// primeira ocorrência da série (a partir do start_date da regra)
|
||||
const firstInSerie = nextWeekday(ruleStart, dow)
|
||||
|
||||
// conta quantas ocorrências já existem ANTES do range atual
|
||||
// para saber o occurrenceCount global correto
|
||||
let globalCount = 0
|
||||
const counter = new Date(firstInSerie)
|
||||
while (counter < effStart) {
|
||||
globalCount++
|
||||
counter.setDate(counter.getDate() + 7 * interval)
|
||||
}
|
||||
|
||||
// agora itera a partir do effStart gerando as do range
|
||||
const cur = new Date(counter) // está na primeira data >= effStart
|
||||
while (cur <= effEnd) {
|
||||
if (rule.max_occurrences && globalCount >= rule.max_occurrences) break
|
||||
dates.push(new Date(cur))
|
||||
globalCount++
|
||||
cur.setDate(cur.getDate() + 7 * interval)
|
||||
}
|
||||
|
||||
} else if (rule.type === 'custom_weekdays') {
|
||||
// múltiplos dias da semana, intervalo semanal
|
||||
// Conta ocorrências ANTES do range para respeitar max_occurrences globalmente
|
||||
let occurrenceCount = 0
|
||||
const sortedDows = [...weekdays].sort()
|
||||
|
||||
// Início da semana de ruleStart
|
||||
const preStart = new Date(ruleStart)
|
||||
preStart.setDate(preStart.getDate() - preStart.getDay())
|
||||
|
||||
// Pré-conta ocorrências entre ruleStart e effStart (cursor separado)
|
||||
const preCur = new Date(preStart)
|
||||
while (preCur < effStart) {
|
||||
for (const dow of sortedDows) {
|
||||
const d = new Date(preCur)
|
||||
d.setDate(d.getDate() + dow)
|
||||
if (d >= ruleStart && d < effStart) occurrenceCount++
|
||||
}
|
||||
preCur.setDate(preCur.getDate() + 7)
|
||||
}
|
||||
|
||||
// Itera a partir da semana que contém effStart (cursor independente do preCur)
|
||||
const weekOfEffStart = new Date(effStart)
|
||||
weekOfEffStart.setDate(weekOfEffStart.getDate() - weekOfEffStart.getDay())
|
||||
const cur = new Date(weekOfEffStart)
|
||||
|
||||
while (cur <= effEnd) {
|
||||
for (const dow of sortedDows) {
|
||||
const d = new Date(cur)
|
||||
d.setDate(d.getDate() + dow)
|
||||
if (d >= effStart && d <= effEnd && d >= ruleStart) {
|
||||
if (rule.max_occurrences && occurrenceCount >= rule.max_occurrences) break
|
||||
dates.push(new Date(d))
|
||||
occurrenceCount++
|
||||
}
|
||||
}
|
||||
cur.setDate(cur.getDate() + 7)
|
||||
}
|
||||
|
||||
} else if (rule.type === 'monthly') {
|
||||
// mesmo dia do mês
|
||||
// Conta ocorrências ANTES do range para respeitar max_occurrences globalmente
|
||||
let occurrenceCount = 0
|
||||
const dayOfMonth = ruleStart.getDate()
|
||||
|
||||
// Pré-conta: de ruleStart até o mês anterior a effStart
|
||||
const preCur = new Date(ruleStart.getFullYear(), ruleStart.getMonth(), dayOfMonth)
|
||||
while (preCur < effStart) {
|
||||
if (preCur >= ruleStart) occurrenceCount++
|
||||
preCur.setMonth(preCur.getMonth() + interval)
|
||||
}
|
||||
|
||||
// Itera a partir do primeiro mês dentro do range
|
||||
const cur = new Date(preCur)
|
||||
while (cur <= effEnd) {
|
||||
if (cur >= ruleStart) {
|
||||
if (rule.max_occurrences && occurrenceCount >= rule.max_occurrences) break
|
||||
dates.push(new Date(cur))
|
||||
occurrenceCount++
|
||||
}
|
||||
cur.setMonth(cur.getMonth() + interval)
|
||||
}
|
||||
|
||||
} else if (rule.type === 'yearly') {
|
||||
// Conta ocorrências ANTES do range para respeitar max_occurrences globalmente
|
||||
let occurrenceCount = 0
|
||||
|
||||
// Pré-conta: de ruleStart até o ano anterior a effStart
|
||||
const preCur = new Date(ruleStart)
|
||||
while (preCur < effStart) {
|
||||
occurrenceCount++
|
||||
preCur.setFullYear(preCur.getFullYear() + interval)
|
||||
}
|
||||
|
||||
// Itera a partir do primeiro ano dentro do range
|
||||
const cur = new Date(preCur)
|
||||
while (cur <= effEnd) {
|
||||
if (rule.max_occurrences && occurrenceCount >= rule.max_occurrences) break
|
||||
dates.push(new Date(cur))
|
||||
occurrenceCount++
|
||||
cur.setFullYear(cur.getFullYear() + interval)
|
||||
}
|
||||
}
|
||||
|
||||
return dates
|
||||
}
|
||||
|
||||
// ─── expansão principal ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Expande regras em ocorrências, aplica exceções.
|
||||
*
|
||||
* @param {Array} rules - regras do banco
|
||||
* @param {Array} exceptions - exceções do banco (todas as das regras carregadas)
|
||||
* @param {Date} rangeStart
|
||||
* @param {Date} rangeEnd
|
||||
* @returns {Array} occurrences — objetos com shape compatível com FullCalendar
|
||||
*/
|
||||
export function expandRules (rules, exceptions, rangeStart, rangeEnd) {
|
||||
// índice de exceções por regra+data
|
||||
const exMap = new Map()
|
||||
for (const ex of exceptions || []) {
|
||||
const key = `${ex.recurrence_id}::${ex.original_date}`
|
||||
exMap.set(key, ex)
|
||||
}
|
||||
|
||||
const occurrences = []
|
||||
// Rastreia IDs de exceções consumidas no loop principal
|
||||
const handledExIds = new Set()
|
||||
|
||||
for (const rule of rules || []) {
|
||||
if (rule.status === 'cancelado') continue
|
||||
|
||||
const dates = generateDates(rule, rangeStart, rangeEnd)
|
||||
|
||||
for (const date of dates) {
|
||||
const iso = toISO(date)
|
||||
const exKey = `${rule.id}::${iso}`
|
||||
const exception = exMap.get(exKey)
|
||||
|
||||
if (exception) handledExIds.add(exception.id)
|
||||
|
||||
// ── exceção: cancela esta ocorrência ──
|
||||
if (exception?.type === 'cancel_session'
|
||||
|| exception?.type === 'patient_missed'
|
||||
|| exception?.type === 'therapist_canceled'
|
||||
|| exception?.type === 'holiday_block') {
|
||||
// ainda inclui no calendário mas com status especial
|
||||
occurrences.push(buildOccurrence(rule, date, iso, exception))
|
||||
continue
|
||||
}
|
||||
|
||||
// ── exceção: remarca esta ocorrência ──
|
||||
if (exception?.type === 'reschedule_session') {
|
||||
const newDate = exception.new_date ? parseDate(exception.new_date) : date
|
||||
const newIso = exception.new_date || iso
|
||||
occurrences.push(buildOccurrence(rule, newDate, newIso, exception))
|
||||
continue
|
||||
}
|
||||
|
||||
// ── ocorrência normal ──
|
||||
occurrences.push(buildOccurrence(rule, date, iso, null))
|
||||
}
|
||||
}
|
||||
|
||||
// ── post-pass: remarcações inbound ──────────────────────────────────────────
|
||||
// Cobre exceções do tipo reschedule_session cujo original_date estava FORA do
|
||||
// range (não gerado pelo loop acima) mas cujo new_date cai DENTRO do range.
|
||||
// Essas exceções chegam aqui via loadExceptions query 2, mas nunca são
|
||||
// alcançadas no loop principal — sem este post-pass o slot ficaria vazio.
|
||||
const ruleMap = new Map((rules || []).map(r => [r.id, r]))
|
||||
const startISO = toISO(rangeStart)
|
||||
const endISO = toISO(rangeEnd)
|
||||
|
||||
for (const ex of exceptions || []) {
|
||||
if (handledExIds.has(ex.id)) continue
|
||||
if (ex.type !== 'reschedule_session') continue
|
||||
if (!ex.new_date) continue
|
||||
if (ex.new_date < startISO || ex.new_date > endISO) continue
|
||||
|
||||
const rule = ruleMap.get(ex.recurrence_id)
|
||||
if (!rule || rule.status === 'cancelado') continue
|
||||
|
||||
const newDate = parseDate(ex.new_date)
|
||||
occurrences.push(buildOccurrence(rule, newDate, ex.original_date, ex))
|
||||
}
|
||||
|
||||
return occurrences
|
||||
}
|
||||
|
||||
/**
|
||||
* Constrói o objeto de ocorrência no formato que o calendário e o dialog esperam
|
||||
*/
|
||||
function buildOccurrence (rule, date, originalIso, exception) {
|
||||
const effectiveStartTime = exception?.new_start_time || rule.start_time
|
||||
const effectiveEndTime = exception?.new_end_time || rule.end_time
|
||||
|
||||
const start = applyTime(date, effectiveStartTime)
|
||||
const end = applyTime(date, effectiveEndTime)
|
||||
|
||||
const exType = exception?.type || null
|
||||
|
||||
return {
|
||||
// identificação
|
||||
id: `rec::${rule.id}::${originalIso}`, // id virtual
|
||||
recurrence_id: rule.id,
|
||||
original_date: originalIso,
|
||||
is_occurrence: true, // flag para diferenciar de eventos reais
|
||||
is_real_session: false,
|
||||
|
||||
// dados da regra
|
||||
determined_commitment_id: rule.determined_commitment_id,
|
||||
patient_id: rule.patient_id,
|
||||
paciente_id: rule.patient_id,
|
||||
owner_id: rule.owner_id,
|
||||
therapist_id: rule.therapist_id,
|
||||
terapeuta_id: rule.therapist_id,
|
||||
tenant_id: rule.tenant_id,
|
||||
|
||||
// nome do paciente — injetado pelo loadAndExpand via _patient
|
||||
paciente_nome: rule._patient?.nome_completo ?? null,
|
||||
paciente_avatar: rule._patient?.avatar_url ?? null,
|
||||
patient_name: rule._patient?.nome_completo ?? null,
|
||||
|
||||
// tempo
|
||||
inicio_em: start.toISOString(),
|
||||
fim_em: end.toISOString(),
|
||||
|
||||
// campos opcionais
|
||||
modalidade: exception?.modalidade || rule.modalidade || 'presencial',
|
||||
titulo_custom: exception?.titulo_custom || rule.titulo_custom || null,
|
||||
observacoes: exception?.observacoes || rule.observacoes || null,
|
||||
extra_fields: exception?.extra_fields || rule.extra_fields || null,
|
||||
price: rule.price ?? null,
|
||||
|
||||
// estado da exceção
|
||||
exception_type: exType,
|
||||
exception_id: exception?.id || null,
|
||||
exception_reason: exception?.reason || null,
|
||||
|
||||
// status derivado da exceção
|
||||
status: _statusFromException(exType),
|
||||
|
||||
// para o FullCalendar
|
||||
tipo: 'sessao',
|
||||
}
|
||||
}
|
||||
|
||||
function _statusFromException (exType) {
|
||||
if (!exType) return 'agendado'
|
||||
if (exType === 'cancel_session') return 'cancelado'
|
||||
if (exType === 'patient_missed') return 'faltou'
|
||||
if (exType === 'therapist_canceled') return 'cancelado'
|
||||
if (exType === 'holiday_block') return 'bloqueado'
|
||||
if (exType === 'reschedule_session') return 'remarcado'
|
||||
return 'agendado'
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge ocorrências geradas com sessões reais do banco.
|
||||
* Sessões reais (is_real_session=true) sobrepõem ocorrências geradas
|
||||
* para a mesma regra+data.
|
||||
*
|
||||
* @param {Array} occurrences - geradas por expandRules
|
||||
* @param {Array} storedRows - linhas de agenda_eventos com recurrence_id
|
||||
* @returns {Array} merged
|
||||
*/
|
||||
export function mergeWithStoredSessions (occurrences, storedRows) {
|
||||
// índice de sessões reais por recurrence_id + recurrence_date
|
||||
const realMap = new Map()
|
||||
for (const row of storedRows || []) {
|
||||
if (!row.recurrence_id || !row.recurrence_date) continue
|
||||
const key = `${row.recurrence_id}::${row.recurrence_date}`
|
||||
realMap.set(key, { ...row, is_real_session: true, is_occurrence: false })
|
||||
}
|
||||
|
||||
const result = []
|
||||
for (const occ of occurrences) {
|
||||
const key = `${occ.recurrence_id}::${occ.original_date}`
|
||||
if (realMap.has(key)) {
|
||||
result.push(realMap.get(key))
|
||||
realMap.delete(key) // evita duplicata
|
||||
} else {
|
||||
result.push(occ)
|
||||
}
|
||||
}
|
||||
|
||||
// adiciona sessões reais que não tiveram ocorrência correspondente
|
||||
// (ex: sessões avulsas ligadas a uma regra mas fora do range normal)
|
||||
for (const real of realMap.values()) {
|
||||
result.push(real)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ─── composable principal ────────────────────────────────────────────────────
|
||||
|
||||
export function useRecurrence () {
|
||||
const rules = ref([])
|
||||
const exceptions = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
/**
|
||||
* Carrega regras ativas para um owner no range dado.
|
||||
* @param {string} ownerId
|
||||
* @param {Date} rangeStart
|
||||
* @param {Date} rangeEnd
|
||||
* @param {string|null} tenantId — se fornecido, filtra também por tenant (multi-clínica)
|
||||
*/
|
||||
async function loadRules (ownerId, rangeStart, rangeEnd, tenantId = null) {
|
||||
if (!ownerId) { logRecurrence('loadRules: ownerId vazio, abortando'); return }
|
||||
const endPerf = logPerf('useRecurrence', 'loadRules')
|
||||
try {
|
||||
const startISO = toISO(rangeStart)
|
||||
const endISO = toISO(rangeEnd)
|
||||
logRecurrence('loadRules →', { ownerId, tenantId, startISO, endISO })
|
||||
|
||||
// Busca regras sem end_date (abertas) + regras com end_date >= rangeStart
|
||||
// Dois selects separados evitam problemas com .or() + .is.null no Supabase JS
|
||||
const baseQuery = () => {
|
||||
let q = supabase
|
||||
.from('recurrence_rules')
|
||||
.select('*')
|
||||
.eq('owner_id', ownerId)
|
||||
.eq('status', 'ativo')
|
||||
.lte('start_date', endISO)
|
||||
.order('start_date', { ascending: true })
|
||||
// Filtra por tenant quando disponível — defesa em profundidade
|
||||
if (tenantId && tenantId !== 'null' && tenantId !== 'undefined') {
|
||||
q = q.eq('tenant_id', tenantId)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
const [resOpen, resWithEnd] = await Promise.all([
|
||||
baseQuery().is('end_date', null),
|
||||
baseQuery().gte('end_date', startISO).not('end_date', 'is', null),
|
||||
])
|
||||
|
||||
if (resOpen.error) throw resOpen.error
|
||||
if (resWithEnd.error) throw resWithEnd.error
|
||||
|
||||
// deduplica por id (improvável mas seguro)
|
||||
const merged = [...(resOpen.data || []), ...(resWithEnd.data || [])]
|
||||
const seen = new Set()
|
||||
rules.value = merged.filter(r => { if (seen.has(r.id)) return false; seen.add(r.id); return true })
|
||||
logRecurrence('loadRules ← regras encontradas', { count: rules.value.length })
|
||||
endPerf({ ruleCount: rules.value.length })
|
||||
} catch (e) {
|
||||
logError('useRecurrence', 'loadRules ERRO', e)
|
||||
error.value = e?.message || 'Erro ao carregar regras'
|
||||
rules.value = []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Carrega exceções para as regras carregadas no range.
|
||||
*
|
||||
* Dois casos cobertos:
|
||||
* 1. original_date no range → cobre cancels, faltas, remarcações-para-fora e remarcações-normais
|
||||
* 2. reschedule_session com new_date no range (original fora do range)
|
||||
* → "remarcação inbound": sessão de outra semana/mês movida para cair neste range
|
||||
*
|
||||
* Ambos os resultados são mesclados e deduplicados por id.
|
||||
*/
|
||||
async function loadExceptions (rangeStart, rangeEnd) {
|
||||
const ids = rules.value.map(r => r.id)
|
||||
if (!ids.length) { exceptions.value = []; return }
|
||||
|
||||
try {
|
||||
const startISO = toISO(rangeStart)
|
||||
const endISO = toISO(rangeEnd)
|
||||
|
||||
// Query 1 — comportamento original: exceções cujo original_date está no range
|
||||
const q1 = supabase
|
||||
.from('recurrence_exceptions')
|
||||
.select('*')
|
||||
.in('recurrence_id', ids)
|
||||
.gte('original_date', startISO)
|
||||
.lte('original_date', endISO)
|
||||
|
||||
// Query 2 — bug fix: remarcações cujo new_date cai neste range
|
||||
// (original_date pode estar antes ou depois do range)
|
||||
const q2 = supabase
|
||||
.from('recurrence_exceptions')
|
||||
.select('*')
|
||||
.in('recurrence_id', ids)
|
||||
.eq('type', 'reschedule_session')
|
||||
.not('new_date', 'is', null)
|
||||
.gte('new_date', startISO)
|
||||
.lte('new_date', endISO)
|
||||
|
||||
const [res1, res2] = await Promise.all([q1, q2])
|
||||
|
||||
if (res1.error) throw res1.error
|
||||
if (res2.error) throw res2.error
|
||||
|
||||
// Mescla e deduplica por id
|
||||
const merged = [...(res1.data || []), ...(res2.data || [])]
|
||||
const seen = new Set()
|
||||
exceptions.value = merged.filter(ex => {
|
||||
if (seen.has(ex.id)) return false
|
||||
seen.add(ex.id)
|
||||
return true
|
||||
})
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao carregar exceções'
|
||||
exceptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Carrega tudo e retorna ocorrências expandidas + merged com sessões reais.
|
||||
* @param {string} ownerId
|
||||
* @param {Date} rangeStart
|
||||
* @param {Date} rangeEnd
|
||||
* @param {Array} storedRows — eventos reais já carregados
|
||||
* @param {string|null} tenantId — filtra regras por tenant (multi-clínica)
|
||||
*/
|
||||
async function loadAndExpand (ownerId, rangeStart, rangeEnd, storedRows = [], tenantId = null) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const endPerf = logPerf('useRecurrence', 'loadAndExpand')
|
||||
logRecurrence('loadAndExpand START', { ownerId, tenantId, storedRows: storedRows.length })
|
||||
try {
|
||||
await loadRules(ownerId, rangeStart, rangeEnd, tenantId)
|
||||
await loadExceptions(rangeStart, rangeEnd)
|
||||
|
||||
// Busca nomes dos pacientes das regras carregadas
|
||||
const patientIds = [...new Set(rules.value.map(r => r.patient_id).filter(Boolean))]
|
||||
if (patientIds.length) {
|
||||
const { data: patients } = await supabase
|
||||
.from('patients')
|
||||
.select('id, nome_completo, avatar_url')
|
||||
.in('id', patientIds)
|
||||
// injeta nome diretamente na regra para o buildOccurrence usar
|
||||
const pMap = new Map((patients || []).map(p => [p.id, p]))
|
||||
for (const rule of rules.value) {
|
||||
if (rule.patient_id && pMap.has(rule.patient_id)) {
|
||||
rule._patient = pMap.get(rule.patient_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const occurrences = expandRules(rules.value, exceptions.value, rangeStart, rangeEnd)
|
||||
logRecurrence('expandRules → ocorrências', { count: occurrences.length })
|
||||
const merged = mergeWithStoredSessions(occurrences, storedRows)
|
||||
logRecurrence('merged final', { count: merged.length })
|
||||
endPerf({ occurrences: occurrences.length, merged: merged.length })
|
||||
return merged
|
||||
} catch (e) {
|
||||
logError('useRecurrence', 'loadAndExpand ERRO', e)
|
||||
error.value = e?.message || 'Erro ao expandir recorrências'
|
||||
return []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── CRUD de regras ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Cria uma nova regra de recorrência
|
||||
* @param {Object} rule - campos da tabela recurrence_rules
|
||||
* @returns {Object} regra criada
|
||||
*/
|
||||
async function createRule (rule) {
|
||||
logRecurrence('createRule →', { patient_id: rule?.patient_id, type: rule?.type })
|
||||
const { data, error: err } = await supabase
|
||||
.from('recurrence_rules')
|
||||
.insert([rule])
|
||||
.select('*')
|
||||
.single()
|
||||
if (err) { logError('useRecurrence', 'createRule ERRO', err); throw err }
|
||||
logRecurrence('createRule ← criado', { id: data?.id })
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualiza a regra toda (editar todos)
|
||||
*/
|
||||
async function updateRule (id, patch) {
|
||||
const { data, error: err } = await supabase
|
||||
.from('recurrence_rules')
|
||||
.update({ ...patch, updated_at: new Date().toISOString() })
|
||||
.eq('id', id)
|
||||
.select('*')
|
||||
.single()
|
||||
if (err) throw err
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancela a série inteira
|
||||
*/
|
||||
async function cancelRule (id) {
|
||||
const { error: err } = await supabase
|
||||
.from('recurrence_rules')
|
||||
.update({ status: 'cancelado', updated_at: new Date().toISOString() })
|
||||
.eq('id', id)
|
||||
if (err) throw err
|
||||
}
|
||||
|
||||
/**
|
||||
* Divide a série a partir de uma data (este e os seguintes)
|
||||
* Retorna o id da nova regra criada
|
||||
*/
|
||||
async function splitRuleAt (id, fromDateISO) {
|
||||
const { data, error: err } = await supabase
|
||||
.rpc('split_recurrence_at', {
|
||||
p_recurrence_id: id,
|
||||
p_from_date: fromDateISO
|
||||
})
|
||||
if (err) throw err
|
||||
return data // new rule id
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancela a série a partir de uma data
|
||||
*/
|
||||
async function cancelRuleFrom (id, fromDateISO) {
|
||||
const { error: err } = await supabase
|
||||
.rpc('cancel_recurrence_from', {
|
||||
p_recurrence_id: id,
|
||||
p_from_date: fromDateISO
|
||||
})
|
||||
if (err) throw err
|
||||
}
|
||||
|
||||
// ── CRUD de exceções ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Cria ou atualiza uma exceção para uma ocorrência específica
|
||||
*/
|
||||
async function upsertException (ex) {
|
||||
const { data, error: err } = await supabase
|
||||
.from('recurrence_exceptions')
|
||||
.upsert([ex], { onConflict: 'recurrence_id,original_date' })
|
||||
.select('*')
|
||||
.single()
|
||||
if (err) throw err
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove uma exceção (restaura a ocorrência ao normal)
|
||||
*/
|
||||
async function deleteException (recurrenceId, originalDate) {
|
||||
const { error: err } = await supabase
|
||||
.from('recurrence_exceptions')
|
||||
.delete()
|
||||
.eq('recurrence_id', recurrenceId)
|
||||
.eq('original_date', originalDate)
|
||||
if (err) throw err
|
||||
}
|
||||
|
||||
return {
|
||||
rules,
|
||||
exceptions,
|
||||
loading,
|
||||
error,
|
||||
|
||||
loadRules,
|
||||
loadExceptions,
|
||||
loadAndExpand,
|
||||
|
||||
createRule,
|
||||
updateRule,
|
||||
cancelRule,
|
||||
splitRuleAt,
|
||||
cancelRuleFrom,
|
||||
|
||||
upsertException,
|
||||
deleteException,
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
605
src/features/agenda/pages/AgendaRecorrenciasPage.vue
Normal file
605
src/features/agenda/pages/AgendaRecorrenciasPage.vue
Normal file
@@ -0,0 +1,605 @@
|
||||
<!-- src/features/agenda/pages/AgendaRecorrenciasPage.vue -->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
import { useAgendaClinicStaff } from '@/features/agenda/composables/useAgendaClinicStaff'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const tenantStore = useTenantStore()
|
||||
|
||||
const mode = computed(() => route.meta?.mode || 'therapist')
|
||||
const isClinic = computed(() => mode.value === 'clinic')
|
||||
const tenantId = computed(() => tenantStore.activeTenantId || tenantStore.tenantId)
|
||||
|
||||
// ── state ──────────────────────────────────────────────────────────────────────
|
||||
const loading = ref(false)
|
||||
const userId = ref(null)
|
||||
const rules = ref([])
|
||||
const exceptionsMap = ref({}) // ruleId → Exception[]
|
||||
const sessionsMap = ref({}) // ruleId → AgendaEvento[]
|
||||
const expandedId = ref(null)
|
||||
|
||||
const filterStatus = ref('ativo')
|
||||
const filterOwner = ref(null)
|
||||
|
||||
const { staff, load: loadStaff } = useAgendaClinicStaff()
|
||||
|
||||
const staffOptions = computed(() =>
|
||||
(staff.value || []).map(s => ({
|
||||
label: s.full_name || s.nome || s.name || 'Profissional',
|
||||
value: s.user_id
|
||||
}))
|
||||
)
|
||||
const staffMap = computed(() => {
|
||||
const m = {}
|
||||
for (const s of staff.value || []) m[s.user_id] = s.full_name || s.nome || s.name || 'Profissional'
|
||||
return m
|
||||
})
|
||||
|
||||
// ── auth / init ────────────────────────────────────────────────────────────────
|
||||
async function init () {
|
||||
const { data } = await supabase.auth.getUser()
|
||||
userId.value = data?.user?.id || null
|
||||
if (isClinic.value && tenantId.value) await loadStaff(tenantId.value)
|
||||
await load()
|
||||
}
|
||||
|
||||
// ── data load ──────────────────────────────────────────────────────────────────
|
||||
async function load () {
|
||||
if (!userId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
let q = supabase.from('recurrence_rules').select('*').order('start_date', { ascending: false })
|
||||
|
||||
if (isClinic.value) {
|
||||
if (!tenantId.value) return
|
||||
q = q.eq('tenant_id', tenantId.value)
|
||||
if (filterOwner.value) q = q.eq('owner_id', filterOwner.value)
|
||||
} else {
|
||||
q = q.eq('owner_id', userId.value)
|
||||
}
|
||||
if (filterStatus.value !== 'all') q = q.eq('status', filterStatus.value)
|
||||
|
||||
const { data: rData, error: rErr } = await q
|
||||
if (rErr) throw rErr
|
||||
const rawRules = rData || []
|
||||
|
||||
// patient names
|
||||
const patientIds = [...new Set(rawRules.map(r => r.patient_id).filter(Boolean))]
|
||||
const patientMap = {}
|
||||
if (patientIds.length) {
|
||||
const { data: pts } = await supabase.from('patients').select('id, nome_completo, avatar_url').in('id', patientIds)
|
||||
for (const p of pts || []) patientMap[p.id] = p
|
||||
}
|
||||
for (const r of rawRules) r._patient = patientMap[r.patient_id] || null
|
||||
|
||||
rules.value = rawRules
|
||||
|
||||
const ruleIds = rawRules.map(r => r.id)
|
||||
if (ruleIds.length) await reloadSessions(ruleIds)
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'warn', summary: 'Erro ao carregar', detail: e?.message, life: 3500 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadSessions (ruleIds) {
|
||||
const [exRes, sessRes] = await Promise.all([
|
||||
supabase.from('recurrence_exceptions').select('*').in('recurrence_id', ruleIds).order('original_date'),
|
||||
supabase.from('agenda_eventos')
|
||||
.select('id, recurrence_id, recurrence_date, status, inicio_em, fim_em')
|
||||
.in('recurrence_id', ruleIds).order('inicio_em')
|
||||
])
|
||||
const exm = {}
|
||||
for (const ex of exRes.data || []) {
|
||||
if (!exm[ex.recurrence_id]) exm[ex.recurrence_id] = []
|
||||
exm[ex.recurrence_id].push(ex)
|
||||
}
|
||||
exceptionsMap.value = { ...exceptionsMap.value, ...exm, ...Object.fromEntries(ruleIds.filter(id => !exm[id]).map(id => [id, []])) }
|
||||
|
||||
const sm = {}
|
||||
for (const s of sessRes.data || []) {
|
||||
if (!sm[s.recurrence_id]) sm[s.recurrence_id] = []
|
||||
sm[s.recurrence_id].push(s)
|
||||
}
|
||||
sessionsMap.value = { ...sessionsMap.value, ...sm, ...Object.fromEntries(ruleIds.filter(id => !sm[id]).map(id => [id, []])) }
|
||||
}
|
||||
|
||||
// ── date generation ────────────────────────────────────────────────────────────
|
||||
function generateAllDates (rule) {
|
||||
const { type, interval = 1, weekdays = [], start_date, end_date, max_occurrences } = rule
|
||||
if (!start_date || !Array.isArray(weekdays) || !weekdays.length) return []
|
||||
const maxOcc = Math.min(max_occurrences || 500, 500)
|
||||
const endLimitISO = end_date || null
|
||||
const dates = []
|
||||
|
||||
if (type === 'custom_weekdays') {
|
||||
const cursor = new Date(start_date + 'T12:00:00')
|
||||
let safety = 0
|
||||
while (dates.length < maxOcc && safety < 3000) {
|
||||
safety++
|
||||
const iso = cursor.toISOString().slice(0, 10)
|
||||
if (endLimitISO && iso > endLimitISO) break
|
||||
if (weekdays.includes(cursor.getDay())) dates.push(iso)
|
||||
cursor.setDate(cursor.getDate() + 1)
|
||||
}
|
||||
} else {
|
||||
// weekly / biweekly
|
||||
const dow = weekdays[0]
|
||||
const cursor = new Date(start_date + 'T12:00:00')
|
||||
while (cursor.getDay() !== dow) cursor.setDate(cursor.getDate() + 1)
|
||||
while (dates.length < maxOcc) {
|
||||
const iso = cursor.toISOString().slice(0, 10)
|
||||
if (endLimitISO && iso > endLimitISO) break
|
||||
dates.push(iso)
|
||||
cursor.setDate(cursor.getDate() + 7 * (interval || 1))
|
||||
}
|
||||
}
|
||||
return dates
|
||||
}
|
||||
|
||||
// ── sessions (merged) ──────────────────────────────────────────────────────────
|
||||
const TODAY = new Date().toISOString().slice(0, 10)
|
||||
|
||||
function buildSessions (rule) {
|
||||
const exByDate = {}
|
||||
for (const ex of exceptionsMap.value[rule.id] || []) exByDate[ex.original_date] = ex
|
||||
const sessByDate = {}
|
||||
for (const s of sessionsMap.value[rule.id] || []) sessByDate[s.recurrence_date] = s
|
||||
|
||||
return generateAllDates(rule).map(iso => {
|
||||
const real = sessByDate[iso]
|
||||
const ex = exByDate[iso]
|
||||
let status = 'agendado'
|
||||
if (real) {
|
||||
status = real.status || 'agendado'
|
||||
} else if (ex) {
|
||||
if (ex.type === 'cancel_session' || ex.type === 'therapist_canceled') status = 'cancelado'
|
||||
else if (ex.type === 'patient_missed') status = 'faltou'
|
||||
else if (ex.type === 'reschedule_session') status = 'remarcado'
|
||||
}
|
||||
return { date: iso, status, real_id: real?.id || null }
|
||||
})
|
||||
}
|
||||
|
||||
// ── stats ──────────────────────────────────────────────────────────────────────
|
||||
const STATUS_DONE = new Set(['compareceu', 'veio', 'realizado', 'presente'])
|
||||
|
||||
function ruleStats (rule) {
|
||||
const sessions = buildSessions(rule)
|
||||
const total = sessions.length
|
||||
const done = sessions.filter(s => STATUS_DONE.has(s.status)).length
|
||||
const faltou = sessions.filter(s => s.status === 'faltou').length
|
||||
const cancelado = sessions.filter(s => s.status === 'cancelado').length
|
||||
const pendentes = sessions.filter(s => s.status === 'agendado' || s.status === 'remarcado').length
|
||||
const progress = total ? Math.round((done / total) * 100) : 0
|
||||
return { total, done, faltou, cancelado, pendentes, progress }
|
||||
}
|
||||
|
||||
// ── formatters ─────────────────────────────────────────────────────────────────
|
||||
const DIAS_SHORT = ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb']
|
||||
|
||||
function fmtDate (iso) {
|
||||
if (!iso) return ''
|
||||
const [y, m, d] = iso.split('-')
|
||||
return `${d}/${m}/${y}`
|
||||
}
|
||||
function fmtRuleDesc (rule) {
|
||||
const days = (rule.weekdays || []).map(d => DIAS_SHORT[d]).join(', ')
|
||||
const time = rule.start_time ? rule.start_time.slice(0, 5) : ''
|
||||
const freq = rule.interval > 1 ? `a cada ${rule.interval} sem.` : ''
|
||||
return [days, time, freq].filter(Boolean).join(' · ')
|
||||
}
|
||||
function fmtPeriod (rule) {
|
||||
const s = fmtDate(rule.start_date)
|
||||
if (rule.end_date) return `${s} até ${fmtDate(rule.end_date)}`
|
||||
if (rule.max_occurrences) return `${s} · ${rule.max_occurrences} sessões`
|
||||
return `A partir de ${s}`
|
||||
}
|
||||
function fmtPillDate (iso) {
|
||||
const [, m, d] = iso.split('-')
|
||||
const dow = DIAS_SHORT[new Date(iso + 'T12:00:00').getDay()]
|
||||
return `${dow} ${Number(d)}/${Number(m)}`
|
||||
}
|
||||
|
||||
// ── status UI ──────────────────────────────────────────────────────────────────
|
||||
const STATUS_OPTS = [
|
||||
{ label: 'Agendado', value: 'agendado' },
|
||||
{ label: 'Compareceu', value: 'compareceu' },
|
||||
{ label: 'Faltou', value: 'faltou' },
|
||||
{ label: 'Cancelado', value: 'cancelado' },
|
||||
{ label: 'Remarcado', value: 'remarcado' },
|
||||
]
|
||||
|
||||
const PILL_CLASS = {
|
||||
agendado: 'pill--pending',
|
||||
compareceu: 'pill--done',
|
||||
veio: 'pill--done',
|
||||
realizado: 'pill--done',
|
||||
presente: 'pill--done',
|
||||
faltou: 'pill--missed',
|
||||
cancelado: 'pill--canceled',
|
||||
remarcado: 'pill--rescheduled',
|
||||
}
|
||||
|
||||
// ── actions ────────────────────────────────────────────────────────────────────
|
||||
async function onPillStatusChange (rule, s, newStatus) {
|
||||
try {
|
||||
if (s.real_id) {
|
||||
await supabase.from('agenda_eventos').update({ status: newStatus }).eq('id', s.real_id)
|
||||
} else {
|
||||
const { data: ex } = await supabase
|
||||
.from('agenda_eventos').select('id')
|
||||
.eq('recurrence_id', rule.id).eq('recurrence_date', s.date).maybeSingle()
|
||||
if (ex?.id) {
|
||||
await supabase.from('agenda_eventos').update({ status: newStatus }).eq('id', ex.id)
|
||||
} else {
|
||||
await supabase.from('agenda_eventos').insert({
|
||||
recurrence_id: rule.id,
|
||||
recurrence_date: s.date,
|
||||
owner_id: rule.owner_id,
|
||||
tenant_id: rule.tenant_id,
|
||||
tipo: 'sessao',
|
||||
status: newStatus,
|
||||
inicio_em: s.date + 'T' + (rule.start_time || '00:00') + ':00',
|
||||
fim_em: s.date + 'T' + (rule.end_time || '01:00') + ':00',
|
||||
visibility_scope: 'public',
|
||||
titulo: 'Sessão',
|
||||
paciente_id: rule.patient_id || null,
|
||||
patient_id: rule.patient_id || null,
|
||||
})
|
||||
}
|
||||
}
|
||||
toast.add({ severity: 'success', summary: 'Status atualizado', life: 1500 })
|
||||
await reloadSessions([rule.id])
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'warn', summary: 'Erro', detail: e?.message, life: 3500 })
|
||||
}
|
||||
}
|
||||
|
||||
async function onCancelRule (rule) {
|
||||
const name = rule._patient?.nome_completo || 'paciente'
|
||||
if (!confirm(`Encerrar a série de "${name}"?\n\nSessões futuras deixarão de ser geradas. Sessões passadas já registradas são mantidas.`)) return
|
||||
try {
|
||||
await supabase.from('recurrence_rules')
|
||||
.update({ status: 'cancelado', updated_at: new Date().toISOString() })
|
||||
.eq('id', rule.id)
|
||||
toast.add({ severity: 'success', summary: 'Série encerrada', life: 2000 })
|
||||
await load()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'warn', summary: 'Erro', detail: e?.message, life: 3500 })
|
||||
}
|
||||
}
|
||||
|
||||
async function onReactivateRule (rule) {
|
||||
try {
|
||||
await supabase.from('recurrence_rules')
|
||||
.update({ status: 'ativo', updated_at: new Date().toISOString() })
|
||||
.eq('id', rule.id)
|
||||
toast.add({ severity: 'success', summary: 'Série reativada', life: 2000 })
|
||||
await load()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'warn', summary: 'Erro', detail: e?.message, life: 3500 })
|
||||
}
|
||||
}
|
||||
|
||||
function toggleExpand (ruleId) {
|
||||
expandedId.value = expandedId.value === ruleId ? null : ruleId
|
||||
}
|
||||
|
||||
// ── navigation ─────────────────────────────────────────────────────────────────
|
||||
function goBack () {
|
||||
if (isClinic.value) router.push({ name: 'admin-agenda-clinica' })
|
||||
else router.push({ name: 'therapist-agenda' })
|
||||
}
|
||||
|
||||
onMounted(init)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Toast />
|
||||
|
||||
<!-- ─── Header ─────────────────────────────────────────────────── -->
|
||||
<div class="rr-page mx-3 md:mx-5">
|
||||
<div class="rr-header">
|
||||
<div class="flex items-center gap-3">
|
||||
<Button icon="pi pi-arrow-left" text severity="secondary" class="h-9 w-9 rounded-full shrink-0" v-tooltip.bottom="'Voltar à agenda'" @click="goBack" />
|
||||
<div>
|
||||
<div class="text-xl font-bold leading-tight">Recorrências</div>
|
||||
<div class="text-sm opacity-55">
|
||||
{{ isClinic ? 'Todas as séries da clínica' : 'Suas séries de sessões recorrentes' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<!-- Status filter -->
|
||||
<SelectButton
|
||||
v-model="filterStatus"
|
||||
:options="[
|
||||
{ label: 'Ativas', value: 'ativo' },
|
||||
{ label: 'Encerradas', value: 'cancelado' },
|
||||
{ label: 'Todas', value: 'all' }
|
||||
]"
|
||||
optionLabel="label" optionValue="value" :allowEmpty="false"
|
||||
@change="load"
|
||||
/>
|
||||
|
||||
<!-- Therapist filter (clinic only) -->
|
||||
<Select
|
||||
v-if="isClinic && staffOptions.length"
|
||||
v-model="filterOwner"
|
||||
:options="[{ label: 'Todos os terapeutas', value: null }, ...staffOptions]"
|
||||
optionLabel="label" optionValue="value"
|
||||
placeholder="Todos os terapeutas"
|
||||
class="w-[220px]"
|
||||
@change="load"
|
||||
/>
|
||||
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" v-tooltip.bottom="'Recarregar'" @click="load" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ─── Loading ──────────────────────────────────────────────── -->
|
||||
<div v-if="loading" class="flex flex-col gap-3 mt-4">
|
||||
<Skeleton v-for="i in 4" :key="i" height="130px" class="rounded-2xl" />
|
||||
</div>
|
||||
|
||||
<!-- ─── Empty ────────────────────────────────────────────────── -->
|
||||
<div v-else-if="!rules.length" class="rr-empty">
|
||||
<i class="pi pi-calendar-times text-5xl opacity-25" />
|
||||
<div class="text-lg font-semibold opacity-50">Nenhuma série encontrada</div>
|
||||
<div class="text-sm opacity-35">
|
||||
{{ filterStatus === 'ativo' ? 'Crie sessões recorrentes na agenda para vê-las aqui.' : 'Altere o filtro de status.' }}
|
||||
</div>
|
||||
<Button label="Voltar à agenda" icon="pi pi-calendar" outlined severity="secondary" class="rounded-full mt-2" @click="goBack" />
|
||||
</div>
|
||||
|
||||
<!-- ─── Rule cards ───────────────────────────────────────────── -->
|
||||
<div v-else class="flex flex-col gap-4 mt-4 pb-8">
|
||||
<div v-for="rule in rules" :key="rule.id" class="rr-card">
|
||||
|
||||
<!-- Card head: patient info + status badge -->
|
||||
<div class="rr-card__head">
|
||||
<div class="flex items-start gap-3 min-w-0 flex-1">
|
||||
<Avatar
|
||||
:label="(rule._patient?.nome_completo || '?')[0].toUpperCase()"
|
||||
shape="circle" size="large"
|
||||
class="shrink-0"
|
||||
style="background:var(--primary-100,#e0e7ff);color:var(--primary-700,#3730a3);font-weight:700"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="font-bold text-base truncate leading-tight">
|
||||
{{ rule._patient?.nome_completo || 'Paciente não encontrado' }}
|
||||
</div>
|
||||
<div v-if="isClinic && staffMap[rule.owner_id]" class="text-xs opacity-55 mt-0.5 truncate">
|
||||
<i class="pi pi-user text-xs mr-1" />{{ staffMap[rule.owner_id] }}
|
||||
</div>
|
||||
<div class="text-sm opacity-65 mt-1">
|
||||
<i class="pi pi-clock text-xs mr-1" />{{ fmtRuleDesc(rule) }}
|
||||
</div>
|
||||
<div class="text-xs opacity-45 mt-0.5">
|
||||
<i class="pi pi-calendar text-xs mr-1" />{{ fmtPeriod(rule) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Tag
|
||||
:value="rule.status === 'ativo' ? 'Ativa' : 'Encerrada'"
|
||||
:severity="rule.status === 'ativo' ? 'success' : 'secondary'"
|
||||
class="shrink-0 self-start"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Stats + progress -->
|
||||
<template v-for="stats in [ruleStats(rule)]" :key="'stats-' + rule.id">
|
||||
<div class="rr-stats-row">
|
||||
<span class="rr-stat rr-stat--done">{{ stats.done }} compareceu</span>
|
||||
<span v-if="stats.faltou" class="rr-stat rr-stat--missed">{{ stats.faltou }} faltou</span>
|
||||
<span v-if="stats.cancelado" class="rr-stat rr-stat--canceled">{{ stats.cancelado }} cancelada{{ stats.cancelado !== 1 ? 's' : '' }}</span>
|
||||
<span class="rr-stat rr-stat--pending">{{ stats.pendentes }} pendente{{ stats.pendentes !== 1 ? 's' : '' }}</span>
|
||||
<span class="rr-stat rr-stat--total ml-auto">{{ stats.total }} sessões</span>
|
||||
</div>
|
||||
<div class="px-4 pb-1">
|
||||
<ProgressBar :value="stats.progress" class="h-1.5 rounded-full" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Card footer: actions -->
|
||||
<div class="rr-card__foot">
|
||||
<Button
|
||||
:icon="expandedId === rule.id ? 'pi pi-chevron-up' : 'pi pi-list'"
|
||||
:label="expandedId === rule.id ? 'Ocultar sessões' : `Ver sessões (${ruleStats(rule).total})`"
|
||||
severity="secondary" outlined size="small" class="rounded-full"
|
||||
@click="toggleExpand(rule.id)"
|
||||
/>
|
||||
<div class="flex gap-2 ml-auto">
|
||||
<Button
|
||||
v-if="rule.status === 'ativo'"
|
||||
label="Encerrar série"
|
||||
icon="pi pi-times-circle"
|
||||
severity="danger" text size="small" class="rounded-full"
|
||||
@click="onCancelRule(rule)"
|
||||
/>
|
||||
<Button
|
||||
v-else
|
||||
label="Reativar"
|
||||
icon="pi pi-undo"
|
||||
severity="success" text size="small" class="rounded-full"
|
||||
@click="onReactivateRule(rule)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sessions panel (expanded) -->
|
||||
<div v-if="expandedId === rule.id" class="rr-sessions">
|
||||
<div class="rr-sessions__grid">
|
||||
<div
|
||||
v-for="s in buildSessions(rule)"
|
||||
:key="s.date"
|
||||
class="rr-pill"
|
||||
:class="[
|
||||
PILL_CLASS[s.status] || 'pill--pending',
|
||||
s.date < TODAY ? 'rr-pill--past' : s.date === TODAY ? 'rr-pill--today' : 'rr-pill--future'
|
||||
]"
|
||||
>
|
||||
<div class="rr-pill__date">{{ fmtPillDate(s.date) }}</div>
|
||||
<Select
|
||||
:modelValue="s.status"
|
||||
:options="STATUS_OPTS"
|
||||
optionLabel="label" optionValue="value"
|
||||
class="rr-pill__sel"
|
||||
@change="e => onPillStatusChange(rule, s, e.value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── Page ─────────────────────────────────────────────────────────── */
|
||||
.rr-page {
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
/* ── Header ───────────────────────────────────────────────────────── */
|
||||
.rr-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 20px 0 16px;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* ── Empty ────────────────────────────────────────────────────────── */
|
||||
.rr-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 64px 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Card ─────────────────────────────────────────────────────────── */
|
||||
.rr-card {
|
||||
border-radius: 1.25rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
.rr-card:hover {
|
||||
box-shadow: 0 2px 16px color-mix(in srgb, var(--primary-400, #818cf8) 10%, transparent);
|
||||
}
|
||||
|
||||
.rr-card__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 16px 16px 12px;
|
||||
}
|
||||
|
||||
.rr-card__foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px 14px;
|
||||
border-top: 1px solid var(--surface-border);
|
||||
}
|
||||
|
||||
/* ── Stats row ────────────────────────────────────────────────────── */
|
||||
.rr-stats-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 16px 8px;
|
||||
}
|
||||
|
||||
.rr-stat {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
.rr-stat--done { background: var(--green-100, #dcfce7); color: var(--green-700, #15803d); }
|
||||
.rr-stat--missed { background: var(--red-100, #fee2e2); color: var(--red-700, #b91c1c); }
|
||||
.rr-stat--canceled { background: var(--orange-100, #ffedd5); color: var(--orange-700, #c2410c); }
|
||||
.rr-stat--pending { background: var(--surface-200, #e5e7eb); color: var(--text-color-secondary); }
|
||||
.rr-stat--total { background: transparent; color: var(--text-color-secondary); font-weight: 400; }
|
||||
|
||||
/* ── Sessions panel ───────────────────────────────────────────────── */
|
||||
.rr-sessions {
|
||||
border-top: 1px solid var(--surface-border);
|
||||
background: color-mix(in srgb, var(--surface-ground) 60%, transparent);
|
||||
padding: 14px 16px;
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.rr-sessions__grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ── Pill ─────────────────────────────────────────────────────────── */
|
||||
.rr-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 999px;
|
||||
padding: 4px 4px 4px 10px;
|
||||
border: 1px solid transparent;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rr-pill--past { opacity: 0.7; }
|
||||
.rr-pill--today { box-shadow: 0 0 0 2px var(--primary-400, #818cf8); opacity: 1 !important; }
|
||||
.rr-pill--future { }
|
||||
|
||||
.pill--pending { background: var(--surface-200, #e5e7eb); border-color: var(--surface-300, #d1d5db); color: var(--text-color-secondary); }
|
||||
.pill--done { background: var(--green-50, #f0fdf4); border-color: var(--green-200, #bbf7d0); color: var(--green-800, #166534); }
|
||||
.pill--missed { background: var(--red-50, #fff1f2); border-color: var(--red-200, #fecaca); color: var(--red-700, #b91c1c); }
|
||||
.pill--canceled { background: var(--orange-50, #fff7ed); border-color: var(--orange-200, #fed7aa); color: var(--orange-700, #c2410c); }
|
||||
.pill--rescheduled{ background: var(--blue-50, #eff6ff); border-color: var(--blue-200, #bfdbfe); color: var(--blue-700, #1d4ed8); }
|
||||
|
||||
.rr-pill__date {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.rr-pill__sel {
|
||||
/* shrink the PrimeVue Select to pill size */
|
||||
--p-select-padding-x: 6px;
|
||||
--p-select-padding-y: 2px;
|
||||
font-size: 0.7rem;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
:deep(.rr-pill__sel .p-select-label) {
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 4px;
|
||||
font-weight: 600;
|
||||
}
|
||||
:deep(.rr-pill__sel .p-select-dropdown) {
|
||||
width: 1.4rem;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
849
src/features/agenda/pages/AgendamentosRecebidosPage.vue
Normal file
849
src/features/agenda/pages/AgendamentosRecebidosPage.vue
Normal file
@@ -0,0 +1,849 @@
|
||||
<!-- src/features/agenda/pages/AgendamentosRecebidosPage.vue -->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.vue'
|
||||
import { useAgendaSettings } from '@/features/agenda/composables/useAgendaSettings'
|
||||
import { useDeterminedCommitments } from '@/features/agenda/composables/useDeterminedCommitments'
|
||||
import { useAgendaEvents } from '@/features/agenda/composables/useAgendaEvents'
|
||||
|
||||
const toast = useToast()
|
||||
const router = useRouter()
|
||||
const tenantStore = useTenantStore()
|
||||
|
||||
// ── Identidade do usuário logado ─────────────────────────────────
|
||||
const isClinic = computed(() => tenantStore.role === 'clinic_admin' || tenantStore.role === 'tenant_admin')
|
||||
const tenantId = computed(() => tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null)
|
||||
|
||||
// owner_id = auth user ID do terapeuta (não é o tenant_id)
|
||||
const ownerId = ref(null)
|
||||
async function loadOwnerId () {
|
||||
const { data } = await supabase.auth.getUser()
|
||||
ownerId.value = data?.user?.id || null
|
||||
}
|
||||
|
||||
// ── Filtros ──────────────────────────────────────────────────────
|
||||
const filtroStatus = ref('pendente')
|
||||
const filtroBusca = ref('')
|
||||
|
||||
const statusOpts = [
|
||||
{ label: 'Pendentes', value: 'pendente', icon: 'pi-clock', sev: 'warn' },
|
||||
{ label: 'Autorizados', value: 'autorizado', icon: 'pi-check-circle', sev: 'success' },
|
||||
{ label: 'Convertidos', value: 'convertido', icon: 'pi-calendar-plus', sev: 'info' },
|
||||
{ label: 'Recusados', value: 'recusado', icon: 'pi-times-circle', sev: 'danger' },
|
||||
{ label: 'Todos', value: null, icon: 'pi-list', sev: 'secondary' }
|
||||
]
|
||||
|
||||
// ── Lista ────────────────────────────────────────────────────────
|
||||
const solicitacoes = ref([])
|
||||
const loading = ref(false)
|
||||
const totalPendentes = ref(0)
|
||||
|
||||
async function load () {
|
||||
if (!ownerId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
let q = supabase
|
||||
.from('agendador_solicitacoes')
|
||||
.select(`
|
||||
id, owner_id, tenant_id,
|
||||
paciente_nome, paciente_sobrenome, paciente_email, paciente_celular, paciente_cpf,
|
||||
tipo, modalidade, data_solicitada, hora_solicitada,
|
||||
reservado_ate, motivo, como_conheceu,
|
||||
status, created_at
|
||||
`)
|
||||
.order('data_solicitada', { ascending: false })
|
||||
.order('hora_solicitada', { ascending: true })
|
||||
|
||||
if (isClinic.value) {
|
||||
q = q.eq('tenant_id', tenantId.value)
|
||||
} else {
|
||||
q = q.eq('owner_id', ownerId.value)
|
||||
}
|
||||
|
||||
if (filtroStatus.value) q = q.eq('status', filtroStatus.value)
|
||||
|
||||
const { data, error } = await q
|
||||
if (error) throw error
|
||||
solicitacoes.value = data || []
|
||||
|
||||
// Conta pendentes para badge
|
||||
if (filtroStatus.value !== 'pendente') {
|
||||
let qp = supabase
|
||||
.from('agendador_solicitacoes')
|
||||
.select('id', { count: 'exact', head: true })
|
||||
.eq('status', 'pendente')
|
||||
if (isClinic.value) qp = qp.eq('tenant_id', tenantId.value)
|
||||
else qp = qp.eq('owner_id', ownerId.value)
|
||||
const { count } = await qp
|
||||
totalPendentes.value = count || 0
|
||||
} else {
|
||||
totalPendentes.value = solicitacoes.value.length
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[AgendamentosRecebidos]', e)
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: 'Não foi possível carregar as solicitações.', life: 4000 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(filtroStatus, load)
|
||||
|
||||
// ── Filtro de busca local ────────────────────────────────────────
|
||||
const listaFiltrada = computed(() => {
|
||||
const q = filtroBusca.value.trim().toLowerCase()
|
||||
if (!q) return solicitacoes.value
|
||||
return solicitacoes.value.filter(s =>
|
||||
`${s.paciente_nome} ${s.paciente_sobrenome}`.toLowerCase().includes(q) ||
|
||||
(s.paciente_email || '').toLowerCase().includes(q) ||
|
||||
(s.paciente_celular || '').includes(q)
|
||||
)
|
||||
})
|
||||
|
||||
// ── Helpers de formatação ────────────────────────────────────────
|
||||
function fmtData (iso) {
|
||||
if (!iso) return '—'
|
||||
const [y, m, d] = iso.split('-')
|
||||
const dias = ['Dom','Seg','Ter','Qua','Qui','Sex','Sáb']
|
||||
const dow = new Date(+y, +m - 1, +d).getDay()
|
||||
return `${dias[dow]}, ${d}/${m}/${y}`
|
||||
}
|
||||
function fmtHora (h) { return h ? String(h).slice(0, 5) : '—' }
|
||||
function nomeCompleto (s) { return `${s.paciente_nome || ''} ${s.paciente_sobrenome || ''}`.trim() || '—' }
|
||||
|
||||
const tipoLabel = { primeira: 'Primeira Entrevista', retorno: 'Retorno', reagendar: 'Reagendamento' }
|
||||
const modalLabel = { presencial: 'Presencial', online: 'Online', ambos: 'Ambos' }
|
||||
|
||||
function statusSev (st) {
|
||||
return { pendente: 'warn', autorizado: 'success', recusado: 'danger', convertido: 'info', expirado: 'secondary' }[st] || 'secondary'
|
||||
}
|
||||
function statusLabel (st) {
|
||||
return { pendente: 'Pendente', autorizado: 'Autorizado', recusado: 'Recusado', convertido: 'Convertido', expirado: 'Expirado' }[st] || st
|
||||
}
|
||||
|
||||
function isExpirada (s) {
|
||||
if (s.status !== 'pendente') return false
|
||||
if (!s.reservado_ate) return false
|
||||
return new Date(s.reservado_ate) < new Date()
|
||||
}
|
||||
|
||||
// ── Detalhe / expandido ──────────────────────────────────────────
|
||||
const expandedId = ref(null)
|
||||
function toggleExpand (id) {
|
||||
expandedId.value = expandedId.value === id ? null : id
|
||||
}
|
||||
|
||||
// ── Aprovar ──────────────────────────────────────────────────────
|
||||
const aprovando = ref(null)
|
||||
async function aprovar (s) {
|
||||
aprovando.value = s.id
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('agendador_solicitacoes')
|
||||
.update({ status: 'autorizado', autorizado_em: new Date().toISOString() })
|
||||
.eq('id', s.id)
|
||||
if (error) throw error
|
||||
toast.add({ severity: 'success', summary: 'Autorizado', detail: `Solicitação de ${nomeCompleto(s)} autorizada.`, life: 3000 })
|
||||
await load()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 })
|
||||
} finally {
|
||||
aprovando.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Recusar ──────────────────────────────────────────────────────
|
||||
const recusandoId = ref(null)
|
||||
const recusaMotivo = ref('')
|
||||
const recusaDialogOpen = ref(false)
|
||||
let _recusaTarget = null
|
||||
|
||||
function abrirRecusa (s) {
|
||||
_recusaTarget = s
|
||||
recusaMotivo.value = ''
|
||||
recusaDialogOpen.value = true
|
||||
recusandoId.value = null
|
||||
}
|
||||
|
||||
async function confirmarRecusa () {
|
||||
const s = _recusaTarget
|
||||
if (!s) return
|
||||
recusandoId.value = s.id
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('agendador_solicitacoes')
|
||||
.update({ status: 'recusado', recusado_motivo: recusaMotivo.value || null })
|
||||
.eq('id', s.id)
|
||||
if (error) throw error
|
||||
recusaDialogOpen.value = false
|
||||
toast.add({ severity: 'info', summary: 'Recusado', detail: `Solicitação de ${nomeCompleto(s)} recusada.`, life: 3000 })
|
||||
await load()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 })
|
||||
} finally {
|
||||
recusandoId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Converter em sessão ─────────────────────────────────────────
|
||||
const { settings, load: loadSettings } = useAgendaSettings()
|
||||
const { create: createEvento } = useAgendaEvents()
|
||||
|
||||
const { rows: commitmentRows, load: loadCommitments } = useDeterminedCommitments(tenantId)
|
||||
const commitmentOptions = computed(() => (commitmentRows.value || []).filter(c => c.active !== false))
|
||||
const sessionCommitmentId = computed(() => {
|
||||
const c = commitmentOptions.value.find(c => c.native_key === 'session')
|
||||
return c?.id || null
|
||||
})
|
||||
|
||||
const eventDialogOpen = ref(false)
|
||||
const eventRow = ref(null)
|
||||
const convertendoId = ref(null)
|
||||
let _convertTarget = null
|
||||
|
||||
async function converterEmSessao (s) {
|
||||
_convertTarget = s
|
||||
convertendoId.value = s.id
|
||||
|
||||
try {
|
||||
// 1. Busca ou cria o paciente
|
||||
const pacienteId = await encontrarOuCriarPaciente(s)
|
||||
|
||||
// 2. Monta o eventRow com paciente já vinculado
|
||||
// inicio_em como ISO local para resetForm() calcular dia e startTime corretamente
|
||||
const hora = fmtHora(s.hora_solicitada) // "HH:MM"
|
||||
const inicio_em = `${s.data_solicitada}T${hora}:00`
|
||||
|
||||
eventRow.value = {
|
||||
owner_id: s.owner_id,
|
||||
tipo: 'sessao',
|
||||
modalidade: s.modalidade || 'presencial',
|
||||
inicio_em,
|
||||
patient_id: pacienteId,
|
||||
paciente_id: pacienteId, // alias para o dialog pré-preencher o nome
|
||||
paciente_nome: nomeCompleto(s),
|
||||
_solicitacaoId: s.id,
|
||||
}
|
||||
|
||||
eventDialogOpen.value = true
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 })
|
||||
} finally {
|
||||
convertendoId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function encontrarOuCriarPaciente (s) {
|
||||
const email = s.paciente_email?.toLowerCase().trim()
|
||||
|
||||
// Tenta achar paciente pelo email no tenant
|
||||
if (email) {
|
||||
const { data: found } = await supabase
|
||||
.from('patients')
|
||||
.select('id, nome_completo')
|
||||
.eq('tenant_id', tenantId.value)
|
||||
.ilike('email_principal', email)
|
||||
.maybeSingle()
|
||||
if (found?.id) return found.id
|
||||
}
|
||||
|
||||
// Não encontrou → busca o responsible_member_id do usuário logado
|
||||
const { data: memberData, error: memberErr } = await supabase
|
||||
.from('tenant_members')
|
||||
.select('id')
|
||||
.eq('tenant_id', tenantId.value)
|
||||
.eq('user_id', ownerId.value)
|
||||
.eq('status', 'active')
|
||||
.maybeSingle()
|
||||
if (memberErr || !memberData?.id) throw new Error('Membro ativo não encontrado para criação do paciente.')
|
||||
|
||||
// Cria o paciente com os dados da solicitação
|
||||
// Se veio pelo link da clínica → scope 'clinic'; pelo link do terapeuta → scope 'therapist'
|
||||
const scope = isClinic.value ? 'clinic' : 'therapist'
|
||||
const nomeCompleto_ = [s.paciente_nome, s.paciente_sobrenome].filter(Boolean).join(' ')
|
||||
const { data: novo, error: criErr } = await supabase
|
||||
.from('patients')
|
||||
.insert({
|
||||
tenant_id: tenantId.value,
|
||||
responsible_member_id: memberData.id,
|
||||
owner_id: ownerId.value,
|
||||
nome_completo: nomeCompleto_,
|
||||
email_principal: email || null,
|
||||
telefone: s.paciente_celular?.replace(/\D/g, '') || null,
|
||||
cpf: s.paciente_cpf?.replace(/\D/g, '') || null,
|
||||
onde_nos_conheceu: s.como_conheceu || null,
|
||||
observacoes: s.motivo ? `Motivo da consulta: ${s.motivo}` : null,
|
||||
patient_scope: scope,
|
||||
therapist_member_id: scope === 'therapist' ? memberData.id : null,
|
||||
status: 'Ativo',
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
if (criErr) throw new Error(`Falha ao criar paciente: ${criErr.message}`)
|
||||
|
||||
toast.add({ severity: 'info', summary: 'Paciente criado', detail: `${nomeCompleto_} foi adicionado à sua lista de pacientes.`, life: 3000 })
|
||||
return novo.id
|
||||
}
|
||||
|
||||
function isUuid (v) {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(v || ''))
|
||||
}
|
||||
|
||||
async function onEventSaved (arg) {
|
||||
eventDialogOpen.value = false
|
||||
if (!_convertTarget) return
|
||||
|
||||
const target = _convertTarget
|
||||
_convertTarget = null
|
||||
convertendoId.value = target.id
|
||||
|
||||
try {
|
||||
// 1. Normaliza o payload do dialog (mesmo padrão do AgendaTerapeutaPage)
|
||||
const isWrapped = !!arg && Object.prototype.hasOwnProperty.call(arg, 'payload')
|
||||
const raw = isWrapped ? arg.payload : arg
|
||||
|
||||
const normalized = { ...raw }
|
||||
if (!normalized.owner_id) normalized.owner_id = ownerId.value
|
||||
normalized.tenant_id = tenantId.value
|
||||
normalized.tipo = 'sessao'
|
||||
if (!normalized.status) normalized.status = 'agendado'
|
||||
if (!String(normalized.titulo || '').trim()) normalized.titulo = 'Sessão'
|
||||
if (!normalized.visibility_scope) normalized.visibility_scope = 'public'
|
||||
if (!isUuid(normalized.paciente_id)) normalized.paciente_id = null
|
||||
if (normalized.determined_commitment_id && !isUuid(normalized.determined_commitment_id)) {
|
||||
normalized.determined_commitment_id = null
|
||||
}
|
||||
|
||||
// 2. Salva o evento na agenda
|
||||
const dbFields = [
|
||||
'tenant_id','owner_id','terapeuta_id','patient_id','tipo','status','titulo',
|
||||
'observacoes','inicio_em','fim_em','visibility_scope',
|
||||
'determined_commitment_id','titulo_custom','extra_fields','modalidade',
|
||||
]
|
||||
const dbPayload = {}
|
||||
for (const k of dbFields) { if (normalized[k] !== undefined) dbPayload[k] = normalized[k] }
|
||||
|
||||
await createEvento(dbPayload)
|
||||
|
||||
// 3. Marca solicitação como convertida
|
||||
const { error } = await supabase
|
||||
.from('agendador_solicitacoes')
|
||||
.update({ status: 'convertido' })
|
||||
.eq('id', target.id)
|
||||
if (error) throw error
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Convertido!',
|
||||
detail: `Sessão criada para ${nomeCompleto(target)}.`,
|
||||
life: 4000,
|
||||
})
|
||||
await load()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao converter', detail: e.message, life: 4000 })
|
||||
} finally {
|
||||
convertendoId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// ── agendaSettings para o dialog ────────────────────────────────
|
||||
onMounted(async () => {
|
||||
await loadOwnerId()
|
||||
await Promise.all([loadSettings(), loadCommitments(), load()])
|
||||
})
|
||||
|
||||
// ── Navegar para a agenda na data do agendamento ─────────────────
|
||||
function irParaAgenda (s) {
|
||||
const base = isClinic.value ? '/admin/agenda/clinica' : '/therapist/agenda'
|
||||
router.push({ path: base, query: { date: s.data_solicitada } })
|
||||
}
|
||||
|
||||
// ── Fechar dialog sem converter ──────────────────────────────────
|
||||
function onEventDialogClose () {
|
||||
eventDialogOpen.value = false
|
||||
_convertTarget = null
|
||||
eventRow.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Toast />
|
||||
|
||||
<!-- SENTINEL -->
|
||||
<div class="ar-sentinel" />
|
||||
|
||||
<!-- HERO ─────────────────────────────────────────────────────── -->
|
||||
<div class="ar-hero mx-3 md:mx-5 mb-4">
|
||||
<!-- blobs decorativos -->
|
||||
<div class="ar-blobs" aria-hidden="true">
|
||||
<div class="ar-blob ar-blob--1" />
|
||||
<div class="ar-blob ar-blob--2" />
|
||||
<div class="ar-blob ar-blob--3" />
|
||||
</div>
|
||||
|
||||
<!-- Linha principal -->
|
||||
<div class="ar-hero__row">
|
||||
<!-- Brand -->
|
||||
<div class="ar-hero__brand">
|
||||
<div class="ar-hero__icon">
|
||||
<i class="pi pi-inbox text-lg" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="ar-hero__title">
|
||||
Agendamentos Recebidos
|
||||
<span v-if="totalPendentes > 0" class="ar-badge-count">{{ totalPendentes }}</span>
|
||||
</div>
|
||||
<div class="ar-hero__sub">
|
||||
{{ isClinic ? 'Toda a clínica' : 'Sua agenda online' }} · Solicitações públicas
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Busca -->
|
||||
<div class="ar-hero__search">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText
|
||||
v-model="filtroBusca"
|
||||
placeholder="Buscar por nome, e-mail..."
|
||||
class="w-full"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</IconField>
|
||||
</div>
|
||||
|
||||
<!-- Atualizar -->
|
||||
<Button
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="h-9 w-9 rounded-full shrink-0"
|
||||
:loading="loading"
|
||||
title="Atualizar"
|
||||
@click="load"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Chips de filtro -->
|
||||
<div class="ar-status-chips">
|
||||
<button
|
||||
v-for="opt in statusOpts"
|
||||
:key="opt.value ?? 'all'"
|
||||
class="ar-chip"
|
||||
:class="{ 'ar-chip--active': filtroStatus === opt.value }"
|
||||
@click="filtroStatus = opt.value"
|
||||
>
|
||||
<i :class="`pi ${opt.icon} text-xs`" />
|
||||
{{ opt.label }}
|
||||
<span v-if="opt.value === 'pendente' && totalPendentes > 0" class="ar-chip-badge">
|
||||
{{ totalPendentes }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CONTEÚDO ─────────────────────────────────────────────────── -->
|
||||
<div class="mx-3 md:mx-5">
|
||||
|
||||
<!-- Loading skeleton -->
|
||||
<div v-if="loading" class="flex flex-col gap-3">
|
||||
<div v-for="n in 4" :key="n" class="ar-card ar-card--skel">
|
||||
<div class="ar-skel ar-skel--avatar" />
|
||||
<div class="flex flex-col gap-2 flex-1">
|
||||
<div class="ar-skel ar-skel--title" />
|
||||
<div class="ar-skel ar-skel--sub" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vazio -->
|
||||
<div v-else-if="!listaFiltrada.length" class="ar-empty">
|
||||
<div class="ar-empty__icon">
|
||||
<i class="pi pi-inbox text-4xl" />
|
||||
</div>
|
||||
<div class="ar-empty__title">Nenhuma solicitação</div>
|
||||
<div class="ar-empty__sub">
|
||||
{{ filtroStatus ? `Não há solicitações com status "${statusLabel(filtroStatus)}".` : 'Nenhuma solicitação encontrada.' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lista -->
|
||||
<div v-else class="flex flex-col gap-3 pb-8">
|
||||
<div
|
||||
v-for="s in listaFiltrada"
|
||||
:key="s.id"
|
||||
class="ar-card"
|
||||
:class="{ 'ar-card--expanded': expandedId === s.id, 'ar-card--expirada': isExpirada(s) }"
|
||||
>
|
||||
<!-- Linha principal -->
|
||||
<div class="ar-card__main" @click="toggleExpand(s.id)">
|
||||
|
||||
<!-- Avatar inicial -->
|
||||
<div class="ar-avatar">
|
||||
{{ (s.paciente_nome || '?')[0].toUpperCase() }}
|
||||
</div>
|
||||
|
||||
<!-- Dados -->
|
||||
<div class="ar-card__info flex-1 min-w-0">
|
||||
<div class="ar-card__name">
|
||||
{{ nomeCompleto(s) }}
|
||||
<Tag
|
||||
:value="statusLabel(s.status)"
|
||||
:severity="statusSev(s.status)"
|
||||
class="ml-2 text-xs"
|
||||
/>
|
||||
<Tag
|
||||
v-if="isExpirada(s)"
|
||||
value="Reserva expirada"
|
||||
severity="secondary"
|
||||
class="ml-1 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div class="ar-card__meta">
|
||||
<span><i class="pi pi-calendar text-xs mr-1" />{{ fmtData(s.data_solicitada) }}</span>
|
||||
<span><i class="pi pi-clock text-xs mr-1" />{{ fmtHora(s.hora_solicitada) }}</span>
|
||||
<span><i class="pi pi-tag text-xs mr-1" />{{ tipoLabel[s.tipo] || s.tipo }}</span>
|
||||
<span v-if="s.modalidade"><i class="pi pi-map-marker text-xs mr-1" />{{ modalLabel[s.modalidade] || s.modalidade }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ações rápidas (pendente) -->
|
||||
<div v-if="s.status === 'pendente'" class="ar-card__actions" @click.stop>
|
||||
<Button
|
||||
label="Aprovar"
|
||||
icon="pi pi-check"
|
||||
size="small"
|
||||
severity="success"
|
||||
class="rounded-full"
|
||||
:loading="aprovando === s.id"
|
||||
@click="aprovar(s)"
|
||||
/>
|
||||
<Button
|
||||
label="Recusar"
|
||||
icon="pi pi-times"
|
||||
size="small"
|
||||
severity="danger"
|
||||
outlined
|
||||
class="rounded-full"
|
||||
@click="abrirRecusa(s)"
|
||||
/>
|
||||
<Button
|
||||
label="Converter"
|
||||
icon="pi pi-calendar-plus"
|
||||
size="small"
|
||||
severity="info"
|
||||
outlined
|
||||
class="rounded-full"
|
||||
:loading="convertendoId === s.id"
|
||||
@click="converterEmSessao(s)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Ações para autorizado (ainda pode converter) -->
|
||||
<div v-else-if="s.status === 'autorizado'" class="ar-card__actions" @click.stop>
|
||||
<Button
|
||||
label="Converter em sessão"
|
||||
icon="pi pi-calendar-plus"
|
||||
size="small"
|
||||
severity="info"
|
||||
outlined
|
||||
class="rounded-full"
|
||||
:loading="convertendoId === s.id"
|
||||
@click="converterEmSessao(s)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Ações para convertido: ir à agenda -->
|
||||
<div v-else-if="s.status === 'convertido'" class="ar-card__actions" @click.stop>
|
||||
<Button
|
||||
label="Ver na agenda"
|
||||
icon="pi pi-calendar"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="rounded-full"
|
||||
@click="irParaAgenda(s)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Chevron -->
|
||||
<i
|
||||
class="pi ar-chevron shrink-0"
|
||||
:class="expandedId === s.id ? 'pi-chevron-up' : 'pi-chevron-down'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Detalhe expandido -->
|
||||
<Transition name="ar-expand">
|
||||
<div v-if="expandedId === s.id" class="ar-card__detail">
|
||||
<div class="ar-detail-grid">
|
||||
<div class="ar-detail-item">
|
||||
<span class="ar-detail-label">E-mail</span>
|
||||
<span class="ar-detail-val">{{ s.paciente_email || '—' }}</span>
|
||||
</div>
|
||||
<div class="ar-detail-item">
|
||||
<span class="ar-detail-label">Celular</span>
|
||||
<span class="ar-detail-val">{{ s.paciente_celular || '—' }}</span>
|
||||
</div>
|
||||
<div class="ar-detail-item">
|
||||
<span class="ar-detail-label">CPF</span>
|
||||
<span class="ar-detail-val">{{ s.paciente_cpf || '—' }}</span>
|
||||
</div>
|
||||
<div class="ar-detail-item">
|
||||
<span class="ar-detail-label">Solicitado em</span>
|
||||
<span class="ar-detail-val">{{ s.created_at ? new Date(s.created_at).toLocaleString('pt-BR') : '—' }}</span>
|
||||
</div>
|
||||
<div v-if="s.motivo" class="ar-detail-item col-span-2">
|
||||
<span class="ar-detail-label">Motivo</span>
|
||||
<span class="ar-detail-val">{{ s.motivo }}</span>
|
||||
</div>
|
||||
<div v-if="s.como_conheceu" class="ar-detail-item">
|
||||
<span class="ar-detail-label">Como conheceu</span>
|
||||
<span class="ar-detail-val">{{ s.como_conheceu }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── DIALOG RECUSAR ─────────────────────────────────────────── -->
|
||||
<Dialog
|
||||
v-model:visible="recusaDialogOpen"
|
||||
modal
|
||||
header="Recusar solicitação"
|
||||
:draggable="false"
|
||||
:style="{ width: '440px', maxWidth: '96vw' }"
|
||||
>
|
||||
<p class="text-sm text-color-secondary mb-4">
|
||||
Você pode informar o motivo da recusa. O paciente poderá visualizar isso na sua conta.
|
||||
</p>
|
||||
<FloatLabel variant="on">
|
||||
<Textarea
|
||||
id="ar-recusa-motivo"
|
||||
v-model="recusaMotivo"
|
||||
rows="3"
|
||||
class="w-full"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<label for="ar-recusa-motivo">Motivo da recusa <span class="text-color-secondary">(opcional)</span></label>
|
||||
</FloatLabel>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" @click="recusaDialogOpen = false" />
|
||||
<Button
|
||||
label="Confirmar recusa"
|
||||
icon="pi pi-times"
|
||||
severity="danger"
|
||||
class="rounded-full"
|
||||
:loading="!!recusandoId"
|
||||
@click="confirmarRecusa"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- ── AGENDA EVENT DIALOG (converter) ───────────────────────── -->
|
||||
<AgendaEventDialog
|
||||
v-model="eventDialogOpen"
|
||||
:event-row="eventRow"
|
||||
:owner-id="ownerId"
|
||||
:tenant-id="tenantId"
|
||||
:agenda-settings="settings"
|
||||
:commitment-options="commitmentOptions"
|
||||
:preset-commitment-id="sessionCommitmentId"
|
||||
:restrict-patients-to-owner="!isClinic"
|
||||
:patient-scope-owner-id="!isClinic ? ownerId : null"
|
||||
@save="onEventSaved"
|
||||
@update:modelValue="v => { if (!v) onEventDialogClose() }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── Sentinel ─────────────────────────────────────────────────── */
|
||||
.ar-sentinel { height: 1px; }
|
||||
|
||||
/* ── Hero ─────────────────────────────────────────────────────── */
|
||||
.ar-hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: 1.75rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
padding: 1.25rem 1.5rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .875rem;
|
||||
}
|
||||
|
||||
/* blobs */
|
||||
.ar-blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
|
||||
.ar-blob { position: absolute; border-radius: 50%; filter: blur(65px); }
|
||||
.ar-blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(99,102,241,.10); }
|
||||
.ar-blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(52,211,153,.08); }
|
||||
.ar-blob--3 { width: 14rem; height: 14rem; bottom: -2rem; right: 22%; background: rgba(251,146,60,.07); }
|
||||
|
||||
/* Row principal */
|
||||
.ar-hero__row {
|
||||
position: relative; z-index: 1;
|
||||
display: flex; align-items: center; gap: 1rem; flex-wrap: wrap;
|
||||
}
|
||||
.ar-hero__brand { display: flex; align-items: center; gap: .75rem; flex-shrink: 0; }
|
||||
.ar-hero__icon {
|
||||
display: grid; place-items: center;
|
||||
width: 2.5rem; height: 2.5rem; border-radius: .875rem; flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
|
||||
color: var(--p-primary-500, #6366f1);
|
||||
}
|
||||
.ar-hero__title {
|
||||
font-size: 1.05rem; font-weight: 700;
|
||||
letter-spacing: -.02em;
|
||||
color: var(--text-color);
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.ar-hero__sub { font-size: .75rem; color: var(--text-color-secondary); margin-top: 2px; }
|
||||
|
||||
.ar-badge-count {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
min-width: 20px; height: 20px; border-radius: 999px; padding: 0 5px;
|
||||
background: var(--p-orange-500, #f97316); color: #fff;
|
||||
font-size: .7rem; font-weight: 800;
|
||||
}
|
||||
|
||||
.ar-hero__search { flex: 1; min-width: 200px; max-width: 280px; }
|
||||
|
||||
/* Chips de status */
|
||||
.ar-status-chips {
|
||||
position: relative; z-index: 1;
|
||||
display: flex; flex-wrap: wrap; gap: 6px;
|
||||
}
|
||||
.ar-chip {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
padding: 5px 14px; border-radius: 999px;
|
||||
font-size: .78rem; font-weight: 600;
|
||||
border: 1.5px solid var(--surface-border);
|
||||
background: var(--surface-ground);
|
||||
color: var(--text-color-secondary);
|
||||
cursor: pointer; transition: all .15s;
|
||||
position: relative;
|
||||
}
|
||||
.ar-chip:hover { border-color: var(--p-primary-400, #818cf8); color: var(--text-color); }
|
||||
.ar-chip--active {
|
||||
background: var(--p-primary-500, #6366f1);
|
||||
border-color: var(--p-primary-500, #6366f1);
|
||||
color: #fff;
|
||||
}
|
||||
.ar-chip-badge {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
min-width: 18px; height: 18px; border-radius: 999px;
|
||||
background: rgba(255,255,255,.3); font-size: .68rem; font-weight: 800;
|
||||
padding: 0 4px;
|
||||
}
|
||||
.ar-chip--active .ar-chip-badge { background: rgba(255,255,255,.25); }
|
||||
|
||||
/* ── Cards ────────────────────────────────────────────────────── */
|
||||
.ar-card {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 1.25rem;
|
||||
overflow: hidden;
|
||||
transition: box-shadow .15s;
|
||||
}
|
||||
.ar-card:hover { box-shadow: 0 4px 20px rgba(0,0,0,.08); }
|
||||
.ar-card--expirada { opacity: .65; }
|
||||
.ar-card--skel { padding: 1rem; display: flex; gap: 1rem; align-items: center; }
|
||||
|
||||
.ar-card__main {
|
||||
display: flex; align-items: center; gap: .875rem;
|
||||
padding: 1rem 1.25rem;
|
||||
cursor: pointer;
|
||||
transition: background .12s;
|
||||
}
|
||||
.ar-card__main:hover { background: var(--surface-hover); }
|
||||
|
||||
.ar-avatar {
|
||||
width: 42px; height: 42px; border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 15%, transparent);
|
||||
color: var(--p-primary-500, #6366f1);
|
||||
display: grid; place-items: center;
|
||||
font-weight: 800; font-size: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ar-card__name {
|
||||
font-weight: 700; font-size: .92rem;
|
||||
color: var(--text-color);
|
||||
display: flex; align-items: center; flex-wrap: wrap; gap: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.ar-card__meta {
|
||||
display: flex; flex-wrap: wrap; gap: 10px;
|
||||
font-size: .75rem; color: var(--text-color-secondary);
|
||||
}
|
||||
.ar-card__actions {
|
||||
display: flex; gap: 6px; flex-wrap: wrap; flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ar-chevron { color: var(--text-color-secondary); font-size: .8rem; transition: transform .2s; }
|
||||
.ar-card--expanded .ar-chevron { transform: rotate(180deg); }
|
||||
|
||||
/* Detalhe expandido */
|
||||
.ar-card__detail {
|
||||
padding: .75rem 1.25rem 1rem;
|
||||
border-top: 1px solid var(--surface-border);
|
||||
background: var(--surface-ground);
|
||||
}
|
||||
.ar-detail-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: .75rem;
|
||||
}
|
||||
.ar-detail-item { display: flex; flex-direction: column; gap: 2px; }
|
||||
.ar-detail-label { font-size: .7rem; font-weight: 700; color: var(--text-color-secondary); text-transform: uppercase; letter-spacing: .06em; }
|
||||
.ar-detail-val { font-size: .85rem; color: var(--text-color); word-break: break-word; }
|
||||
|
||||
/* ── Skeletons ────────────────────────────────────────────────── */
|
||||
.ar-skel {
|
||||
border-radius: .5rem;
|
||||
background: linear-gradient(90deg, var(--surface-border) 25%, var(--surface-hover) 50%, var(--surface-border) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: ar-shimmer 1.2s infinite;
|
||||
}
|
||||
.ar-skel--avatar { width: 42px; height: 42px; border-radius: 50%; flex-shrink: 0; }
|
||||
.ar-skel--title { height: 14px; width: 60%; }
|
||||
.ar-skel--sub { height: 11px; width: 40%; }
|
||||
|
||||
/* ── Empty ────────────────────────────────────────────────────── */
|
||||
.ar-empty {
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
padding: 4rem 2rem; text-align: center;
|
||||
}
|
||||
.ar-empty__icon {
|
||||
width: 72px; height: 72px; border-radius: 1.5rem;
|
||||
background: var(--surface-hover); display: grid; place-items: center;
|
||||
color: var(--text-color-secondary); margin-bottom: 1rem;
|
||||
}
|
||||
.ar-empty__title { font-weight: 700; font-size: 1rem; color: var(--text-color); margin-bottom: 4px; }
|
||||
.ar-empty__sub { font-size: .85rem; color: var(--text-color-secondary); }
|
||||
|
||||
/* ── Expand transition ────────────────────────────────────────── */
|
||||
.ar-expand-enter-active,
|
||||
.ar-expand-leave-active { transition: all .22s ease; overflow: hidden; }
|
||||
.ar-expand-enter-from,
|
||||
.ar-expand-leave-to { opacity: 0; max-height: 0; }
|
||||
.ar-expand-enter-to,
|
||||
.ar-expand-leave-from { opacity: 1; max-height: 400px; }
|
||||
|
||||
/* ── Responsivo ───────────────────────────────────────────────── */
|
||||
@media (max-width: 640px) {
|
||||
.ar-card__actions { display: none; }
|
||||
.ar-card--expanded .ar-card__actions { display: flex; padding: .75rem 1.25rem; border-top: 1px solid var(--surface-border); }
|
||||
}
|
||||
|
||||
/* ── Animations ───────────────────────────────────────────────── */
|
||||
@keyframes ar-shimmer { to { background-position: -200% 0; } }
|
||||
</style>
|
||||
288
src/features/agenda/services/__tests__/agendaMappers.spec.js
Normal file
288
src/features/agenda/services/__tests__/agendaMappers.spec.js
Normal file
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* agendaMappers.spec.js
|
||||
*
|
||||
* Testa as funções de mapeamento de dados da agenda:
|
||||
* - mapAgendaEventosToCalendarEvents
|
||||
* - mapAgendaEventosToClinicResourceEvents
|
||||
* - buildNextSessions
|
||||
* - minutesToDuration
|
||||
* - tituloFallback
|
||||
* - calcDefaultSlotDuration
|
||||
* - buildWeeklyBreakBackgroundEvents
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
mapAgendaEventosToCalendarEvents,
|
||||
mapAgendaEventosToClinicResourceEvents,
|
||||
buildNextSessions,
|
||||
minutesToDuration,
|
||||
tituloFallback,
|
||||
calcDefaultSlotDuration,
|
||||
buildWeeklyBreakBackgroundEvents,
|
||||
} from '../agendaMappers.js'
|
||||
|
||||
// ─── fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function evento (overrides = {}) {
|
||||
return {
|
||||
id: 'ev-1',
|
||||
titulo: 'Sessão Teste',
|
||||
tipo: 'sessao',
|
||||
status: 'agendado',
|
||||
inicio_em: '2026-03-10T09:00:00',
|
||||
fim_em: '2026-03-10T10:00:00',
|
||||
owner_id: 'owner-1',
|
||||
tenant_id: 'tenant-1',
|
||||
patient_id: 'patient-1',
|
||||
modalidade: 'presencial',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── mapAgendaEventosToCalendarEvents ─────────────────────────────────────────
|
||||
|
||||
describe('mapAgendaEventosToCalendarEvents', () => {
|
||||
it('mapeia um evento simples para o shape do FullCalendar', () => {
|
||||
const [ev] = mapAgendaEventosToCalendarEvents([evento()])
|
||||
expect(ev.id).toBe('ev-1')
|
||||
expect(ev.start).toBe('2026-03-10T09:00:00')
|
||||
expect(ev.end).toBe('2026-03-10T10:00:00')
|
||||
expect(ev.extendedProps.tipo).toBe('sessao')
|
||||
expect(ev.extendedProps.status).toBe('agendado')
|
||||
})
|
||||
|
||||
it('filtra rows null/undefined', () => {
|
||||
const result = mapAgendaEventosToCalendarEvents([null, undefined, evento()])
|
||||
expect(result.length).toBe(1)
|
||||
})
|
||||
|
||||
it('retorna array vazio para input vazio', () => {
|
||||
expect(mapAgendaEventosToCalendarEvents([])).toEqual([])
|
||||
expect(mapAgendaEventosToCalendarEvents(null)).toEqual([])
|
||||
})
|
||||
|
||||
it('inclui ícone ✓ no título para status realizado', () => {
|
||||
const [ev] = mapAgendaEventosToCalendarEvents([evento({ status: 'realizado' })])
|
||||
expect(ev.title).toContain('✓')
|
||||
})
|
||||
|
||||
it('inclui ícone ✗ no título para status faltou', () => {
|
||||
const [ev] = mapAgendaEventosToCalendarEvents([evento({ status: 'faltou' })])
|
||||
expect(ev.title).toContain('✗')
|
||||
})
|
||||
|
||||
it('inclui ícone ∅ no título para status cancelado', () => {
|
||||
const [ev] = mapAgendaEventosToCalendarEvents([evento({ status: 'cancelado' })])
|
||||
expect(ev.title).toContain('∅')
|
||||
})
|
||||
|
||||
it('inclui ícone ↺ no título para status remarcado', () => {
|
||||
const [ev] = mapAgendaEventosToCalendarEvents([evento({ status: 'remarcado' })])
|
||||
expect(ev.title).toContain('↺')
|
||||
})
|
||||
|
||||
it('inclui ícone ↻ para ocorrências de série', () => {
|
||||
const [ev] = mapAgendaEventosToCalendarEvents([evento({ recurrence_id: 'rule-1', is_occurrence: true })])
|
||||
expect(ev.title).toContain('↻')
|
||||
})
|
||||
|
||||
it('aplica cor de fundo para status faltou', () => {
|
||||
const [ev] = mapAgendaEventosToCalendarEvents([evento({ status: 'faltou' })])
|
||||
expect(ev.backgroundColor).toBe('#ef4444')
|
||||
})
|
||||
|
||||
it('aplica cor de fundo para status cancelado', () => {
|
||||
const [ev] = mapAgendaEventosToCalendarEvents([evento({ status: 'cancelado' })])
|
||||
expect(ev.backgroundColor).toBe('#f97316')
|
||||
})
|
||||
|
||||
it('aplica cor de fundo para status remarcado', () => {
|
||||
const [ev] = mapAgendaEventosToCalendarEvents([evento({ status: 'remarcado' })])
|
||||
expect(ev.backgroundColor).toBe('#a855f7')
|
||||
})
|
||||
|
||||
it('usa titulo_custom quando disponível', () => {
|
||||
const [ev] = mapAgendaEventosToCalendarEvents([evento({ titulo_custom: 'Personalizado' })])
|
||||
expect(ev.title).toContain('Personalizado')
|
||||
})
|
||||
|
||||
it('usa nome do paciente via patients join quando titulo ausente', () => {
|
||||
const [ev] = mapAgendaEventosToCalendarEvents([evento({
|
||||
titulo: null,
|
||||
titulo_custom: null,
|
||||
patients: { nome_completo: 'João Silva', avatar_url: null }
|
||||
})])
|
||||
expect(ev.title).toContain('João Silva')
|
||||
})
|
||||
|
||||
it('mapeia patient_id corretamente', () => {
|
||||
const [ev] = mapAgendaEventosToCalendarEvents([evento({ patient_id: 'p-123' })])
|
||||
expect(ev.extendedProps.patient_id).toBe('p-123')
|
||||
expect(ev.extendedProps.paciente_id).toBe('p-123') // alias
|
||||
})
|
||||
|
||||
it('mapeia recurrence_id e original_date', () => {
|
||||
const [ev] = mapAgendaEventosToCalendarEvents([evento({
|
||||
recurrence_id: 'rule-abc',
|
||||
original_date: '2026-03-10',
|
||||
})])
|
||||
expect(ev.extendedProps.recurrence_id).toBe('rule-abc')
|
||||
expect(ev.extendedProps.original_date).toBe('2026-03-10')
|
||||
})
|
||||
|
||||
it('mapeia exception_type', () => {
|
||||
const [ev] = mapAgendaEventosToCalendarEvents([evento({
|
||||
exception_type: 'patient_missed',
|
||||
status: 'faltou',
|
||||
})])
|
||||
expect(ev.extendedProps.exception_type).toBe('patient_missed')
|
||||
expect(ev.extendedProps.is_exception).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── mapAgendaEventosToClinicResourceEvents ───────────────────────────────────
|
||||
|
||||
describe('mapAgendaEventosToClinicResourceEvents', () => {
|
||||
it('adiciona resourceId baseado em owner_id', () => {
|
||||
const [ev] = mapAgendaEventosToClinicResourceEvents([evento({ owner_id: 'owner-99' })])
|
||||
expect(ev.resourceId).toBe('owner-99')
|
||||
})
|
||||
|
||||
it('usa terapeuta_id como fallback para resourceId', () => {
|
||||
const [ev] = mapAgendaEventosToClinicResourceEvents([evento({ owner_id: null, terapeuta_id: 'tera-1' })])
|
||||
expect(ev.resourceId).toBe('tera-1')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── buildNextSessions ────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildNextSessions', () => {
|
||||
it('filtra sessões no passado', () => {
|
||||
const now = new Date('2026-03-10T12:00:00')
|
||||
const rows = [
|
||||
evento({ id: 'past', fim_em: '2026-03-09T10:00:00' }),
|
||||
evento({ id: 'future', fim_em: '2026-03-11T10:00:00' }),
|
||||
]
|
||||
const result = buildNextSessions(rows, now)
|
||||
expect(result.length).toBe(1)
|
||||
expect(result[0].id).toBe('future')
|
||||
})
|
||||
|
||||
it('inclui sessão cujo fim_em é agora (mesmo ms)', () => {
|
||||
const now = new Date('2026-03-10T10:00:00')
|
||||
const rows = [evento({ fim_em: '2026-03-10T10:00:00' })]
|
||||
const result = buildNextSessions(rows, now)
|
||||
expect(result.length).toBe(1)
|
||||
})
|
||||
|
||||
it('limita a 6 sessões', () => {
|
||||
const now = new Date('2026-01-01')
|
||||
const rows = Array.from({ length: 10 }, (_, i) => evento({
|
||||
id: `ev-${i}`,
|
||||
fim_em: `2026-03-${String(i + 10).padStart(2,'0')}T10:00:00`,
|
||||
}))
|
||||
const result = buildNextSessions(rows, now)
|
||||
expect(result.length).toBe(6)
|
||||
})
|
||||
|
||||
it('retorna shape correto', () => {
|
||||
const now = new Date('2026-01-01')
|
||||
const [s] = buildNextSessions([evento()], now)
|
||||
expect(s).toMatchObject({
|
||||
id: 'ev-1',
|
||||
title: 'Sessão Teste',
|
||||
startISO: '2026-03-10T09:00:00',
|
||||
endISO: '2026-03-10T10:00:00',
|
||||
tipo: 'sessao',
|
||||
status: 'agendado',
|
||||
})
|
||||
})
|
||||
|
||||
it('mapeia pacienteId de patient_id', () => {
|
||||
const now = new Date('2026-01-01')
|
||||
const [s] = buildNextSessions([evento({ patient_id: 'p-999' })], now)
|
||||
expect(s.pacienteId).toBe('p-999')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── minutesToDuration ────────────────────────────────────────────────────────
|
||||
|
||||
describe('minutesToDuration', () => {
|
||||
it('30 minutos → 00:30:00', () => expect(minutesToDuration(30)).toBe('00:30:00'))
|
||||
it('60 minutos → 01:00:00', () => expect(minutesToDuration(60)).toBe('01:00:00'))
|
||||
it('90 minutos → 01:30:00', () => expect(minutesToDuration(90)).toBe('01:30:00'))
|
||||
it('0 minutos → 00:00:00', () => expect(minutesToDuration(0)).toBe('00:00:00'))
|
||||
})
|
||||
|
||||
// ─── tituloFallback ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('tituloFallback', () => {
|
||||
it('sessao → Sessão', () => expect(tituloFallback('sessao')).toBe('Sessão'))
|
||||
it('bloqueio → Bloqueio', () => expect(tituloFallback('bloqueio')).toBe('Bloqueio'))
|
||||
it('pessoal → Pessoal', () => expect(tituloFallback('pessoal')).toBe('Pessoal'))
|
||||
it('clinica → Clínica', () => expect(tituloFallback('clinica')).toBe('Clínica'))
|
||||
it('desconhecido → Compromisso', () => expect(tituloFallback('outro')).toBe('Compromisso'))
|
||||
it('null → Compromisso', () => expect(tituloFallback(null)).toBe('Compromisso'))
|
||||
})
|
||||
|
||||
// ─── calcDefaultSlotDuration ──────────────────────────────────────────────────
|
||||
|
||||
describe('calcDefaultSlotDuration', () => {
|
||||
it('usa granularidade custom quando ativa', () => {
|
||||
const s = { usar_granularidade_custom: true, granularidade_min: 15 }
|
||||
expect(calcDefaultSlotDuration(s)).toBe('00:15:00')
|
||||
})
|
||||
|
||||
it('usa admin_slot_visual_minutos como fallback', () => {
|
||||
const s = { admin_slot_visual_minutos: 20 }
|
||||
expect(calcDefaultSlotDuration(s)).toBe('00:20:00')
|
||||
})
|
||||
|
||||
it('usa 30 min como padrão quando nenhuma configuração', () => {
|
||||
expect(calcDefaultSlotDuration({})).toBe('00:30:00')
|
||||
expect(calcDefaultSlotDuration(null)).toBe('00:30:00')
|
||||
})
|
||||
})
|
||||
|
||||
// ─── buildWeeklyBreakBackgroundEvents ────────────────────────────────────────
|
||||
|
||||
describe('buildWeeklyBreakBackgroundEvents', () => {
|
||||
it('retorna vazio para input vazio', () => {
|
||||
const result = buildWeeklyBreakBackgroundEvents([], new Date('2026-03-01'), new Date('2026-03-08'))
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it('gera eventos de background para pausa no dia correto', () => {
|
||||
const pausas = [{ weekday: 1, start: '12:00', end: '13:00', label: 'Almoço' }] // segunda
|
||||
const result = buildWeeklyBreakBackgroundEvents(
|
||||
pausas,
|
||||
new Date(2026, 2, 1), // dom
|
||||
new Date(2026, 2, 8), // dom
|
||||
)
|
||||
expect(result.length).toBe(1)
|
||||
expect(result[0].display).toBe('background')
|
||||
expect(result[0].start).toContain('2026-03-02') // segunda
|
||||
expect(result[0].extendedProps.label).toBe('Almoço')
|
||||
})
|
||||
|
||||
it('gera uma pausa por semana quando range cobre 2 semanas', () => {
|
||||
const pausas = [{ weekday: 1, start: '12:00', end: '13:00' }] // toda segunda
|
||||
const result = buildWeeklyBreakBackgroundEvents(
|
||||
pausas,
|
||||
new Date(2026, 2, 1), // dom 01/03
|
||||
new Date(2026, 2, 15), // dom 15/03
|
||||
)
|
||||
expect(result.length).toBe(2) // seg 02 e seg 09
|
||||
})
|
||||
|
||||
it('não gera para dias diferentes', () => {
|
||||
const pausas = [{ weekday: 5, start: '12:00', end: '13:00' }] // sexta
|
||||
const result = buildWeeklyBreakBackgroundEvents(
|
||||
pausas,
|
||||
new Date(2026, 2, 2), // seg
|
||||
new Date(2026, 2, 5), // qui
|
||||
)
|
||||
expect(result.length).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -30,7 +30,7 @@ export async function listClinicEvents ({ tenantId, ownerIds, startISO, endISO }
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('*')
|
||||
.select('*, patients!agenda_eventos_patient_id_fkey(id, nome_completo, avatar_url), determined_commitments!agenda_eventos_determined_commitment_fk(id, bg_color, text_color)')
|
||||
.eq('tenant_id', tenantId)
|
||||
.in('owner_id', safeOwnerIds)
|
||||
.gte('inicio_em', startISO)
|
||||
|
||||
@@ -1,124 +1,189 @@
|
||||
// src/features/agenda/services/agendaMappers.js
|
||||
//
|
||||
// Suporta dois tipos de linha:
|
||||
// 1. Evento real (agenda_eventos do banco) — is_occurrence = false/undefined
|
||||
// 2. Ocorrência virtual (gerada por useRecurrence) — is_occurrence = true
|
||||
//
|
||||
// Em ambos os casos o shape de saída para o FullCalendar é idêntico.
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// mapAgendaEventosToCalendarEvents
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function mapAgendaEventosToCalendarEvents (rows) {
|
||||
return (rows || []).map((r) => {
|
||||
// 🔥 regra importante:
|
||||
// prioridade: owner_id
|
||||
// fallback: terapeuta_id
|
||||
const ownerId = normalizeId(r?.owner_id ?? r?.terapeuta_id ?? null)
|
||||
|
||||
const commitment = r.determined_commitments
|
||||
const bgColor = commitment?.bg_color ? `#${commitment.bg_color}` : undefined
|
||||
const txtColor = commitment?.text_color || undefined
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
title: r.titulo || tituloFallback(r.tipo),
|
||||
start: r.inicio_em,
|
||||
end: r.fim_em,
|
||||
...(bgColor && { backgroundColor: bgColor, borderColor: bgColor }),
|
||||
...(txtColor && { textColor: txtColor }),
|
||||
extendedProps: {
|
||||
// 🔥 ESSENCIAL PARA O MOSAICO
|
||||
owner_id: ownerId,
|
||||
|
||||
tipo: r.tipo ?? null,
|
||||
status: r.status ?? null,
|
||||
|
||||
paciente_id: r.paciente_id ?? null,
|
||||
paciente_nome: r.patients?.nome_completo ?? null,
|
||||
paciente_avatar: r.patients?.avatar_url ?? null,
|
||||
terapeuta_id: r.terapeuta_id ?? null,
|
||||
|
||||
observacoes: r.observacoes ?? null,
|
||||
|
||||
// ✅ usados na clínica p/ mascarar/privacidade
|
||||
visibility_scope: r.visibility_scope ?? null,
|
||||
masked: !!r.masked,
|
||||
|
||||
// ✅ compromisso determinístico
|
||||
determined_commitment_id: r.determined_commitment_id ?? null,
|
||||
commitment_bg_color: bgColor ?? null,
|
||||
commitment_text_color: txtColor ?? null,
|
||||
|
||||
// ✅ campos customizados
|
||||
titulo_custom: r.titulo_custom ?? null,
|
||||
extra_fields: r.extra_fields ?? null
|
||||
}
|
||||
}
|
||||
})
|
||||
return (rows || []).map(_mapRow).filter(Boolean)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// mapAgendaEventosToClinicResourceEvents
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function mapAgendaEventosToClinicResourceEvents (rows) {
|
||||
return (rows || []).map((r) => {
|
||||
const ev = _mapRow(r)
|
||||
if (!ev) return null
|
||||
ev.resourceId = normalizeId(r?.owner_id ?? r?.terapeuta_id ?? null)
|
||||
return ev
|
||||
}).filter(Boolean)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// mapper interno
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function _mapRow (r) {
|
||||
if (!r) return null
|
||||
|
||||
const isOccurrence = !!r.is_occurrence
|
||||
const isRealSession = !isOccurrence
|
||||
|
||||
const ownerId = normalizeId(r?.owner_id ?? r?.terapeuta_id ?? null)
|
||||
|
||||
// commitment / cores
|
||||
const commitment = r.determined_commitments ?? r.commitment ?? null
|
||||
const baseBg = commitment?.bg_color ? `#${commitment.bg_color}` : null
|
||||
const baseTxt = commitment?.text_color ? `#${commitment.text_color}` : null
|
||||
const statusBg = _statusBgColor(r.status)
|
||||
const bgColor = statusBg ?? baseBg ?? undefined
|
||||
const txtColor = baseTxt ?? (statusBg ? '#ffffff' : undefined)
|
||||
|
||||
// título
|
||||
const nomeP = r.patients?.nome_completo ?? r.paciente_nome ?? r.patient_name ?? ''
|
||||
const titleBase = r.titulo_custom || r.titulo || (nomeP ? nomeP : tituloFallback(r.tipo))
|
||||
const icon = _statusIcon(r.status, isOccurrence, !!r.recurrence_id)
|
||||
const title = `${icon}${titleBase}`
|
||||
|
||||
// recorrência — nova + fallback legada
|
||||
const recurrenceId = r.recurrence_id ?? null
|
||||
const originalDate = r.original_date ?? r.recurrence_date ?? null
|
||||
const exceptionType = r.exception_type ?? null
|
||||
|
||||
return {
|
||||
id: r.id ?? `occ::${recurrenceId}::${originalDate}`,
|
||||
title,
|
||||
start: r.inicio_em,
|
||||
end: r.fim_em,
|
||||
|
||||
...(bgColor && { backgroundColor: bgColor, borderColor: bgColor }),
|
||||
...(txtColor && { textColor: txtColor }),
|
||||
|
||||
extendedProps: {
|
||||
// identidade
|
||||
dbId: r.id ?? null,
|
||||
isOccurrence,
|
||||
isRealSession,
|
||||
|
||||
// owner
|
||||
owner_id: ownerId,
|
||||
terapeuta_id: normalizeId(r?.terapeuta_id ?? null),
|
||||
|
||||
// compromisso
|
||||
tipo: r.tipo ?? null,
|
||||
status: r.status ?? null,
|
||||
determined_commitment_id: r.determined_commitment_id ?? null,
|
||||
commitment_bg_color: bgColor ?? null,
|
||||
commitment_text_color: txtColor ?? null,
|
||||
|
||||
// paciente
|
||||
patient_id: r.patient_id ?? null,
|
||||
paciente_id: r.patient_id ?? null, // alias para compatibilidade com dialog/form
|
||||
paciente_nome: nomeP,
|
||||
paciente_avatar: r.patients?.avatar_url ?? r.paciente_avatar ?? null,
|
||||
|
||||
// campos
|
||||
observacoes: r.observacoes ?? null,
|
||||
titulo_custom: r.titulo_custom ?? null,
|
||||
extra_fields: r.extra_fields ?? null,
|
||||
modalidade: r.modalidade ?? null,
|
||||
|
||||
// privacidade (clínica)
|
||||
visibility_scope: r.visibility_scope ?? null,
|
||||
masked: !!r.masked,
|
||||
|
||||
// recorrência — NOVA arquitetura
|
||||
recurrence_id: recurrenceId,
|
||||
original_date: originalDate,
|
||||
exception_type: exceptionType,
|
||||
exception_id: r.exception_id ?? null,
|
||||
exception_reason: r.exception_reason ?? null,
|
||||
|
||||
// recorrência — fallback LEGADA (não quebra enquanto migra)
|
||||
serie_id: r.serie_id ?? recurrenceId ?? null,
|
||||
serie_dia_semana: r.agenda_series?.dia_semana ?? r.serie_dia_semana ?? null,
|
||||
serie_hora: r.agenda_series?.hora_inicio ?? r.serie_hora ?? null,
|
||||
serie_duracao: r.agenda_series?.duracao_min ?? r.serie_duracao ?? null,
|
||||
serie_status: r.agenda_series?.status ?? r.serie_status ?? null,
|
||||
is_exception: r.is_exception ?? (exceptionType != null),
|
||||
|
||||
// financeiro
|
||||
price: r.price ?? null,
|
||||
|
||||
// timestamps
|
||||
inicio_em: r.inicio_em,
|
||||
fim_em: r.fim_em,
|
||||
tenant_id: r.tenant_id ?? null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// buildNextSessions
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function buildNextSessions (rows, now = new Date()) {
|
||||
const nowMs = now.getTime()
|
||||
return (rows || [])
|
||||
.filter((r) => new Date(r.fim_em).getTime() >= nowMs)
|
||||
.filter(r => new Date(r.fim_em).getTime() >= nowMs)
|
||||
.slice(0, 6)
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.titulo || tituloFallback(r.tipo),
|
||||
.map(r => ({
|
||||
id: r.id,
|
||||
title: r.titulo || tituloFallback(r.tipo),
|
||||
startISO: r.inicio_em,
|
||||
endISO: r.fim_em,
|
||||
tipo: r.tipo,
|
||||
status: r.status,
|
||||
pacienteId: r.paciente_id || null
|
||||
endISO: r.fim_em,
|
||||
tipo: r.tipo,
|
||||
status: r.status,
|
||||
pacienteId: r.patient_id || null
|
||||
}))
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// calcDefaultSlotDuration
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function calcDefaultSlotDuration (settings) {
|
||||
const min =
|
||||
((settings?.usar_granularidade_custom && settings?.granularidade_min) || 0) ||
|
||||
settings?.admin_slot_visual_minutos ||
|
||||
30
|
||||
|
||||
return minutesToDuration(min)
|
||||
}
|
||||
|
||||
export function minutesToDuration (min) {
|
||||
const h = Math.floor(min / 60)
|
||||
const m = min % 60
|
||||
const hh = String(h).padStart(2, '0')
|
||||
const mm = String(m).padStart(2, '0')
|
||||
return `${hh}:${mm}:00`
|
||||
}
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// buildWeeklyBreakBackgroundEvents — código original preservado integralmente
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function tituloFallback (tipo) {
|
||||
const t = String(tipo || '').toLowerCase()
|
||||
if (t.includes('sess')) return 'Sessão'
|
||||
if (t.includes('block') || t.includes('bloq')) return 'Bloqueio'
|
||||
if (t.includes('pessoal')) return 'Pessoal'
|
||||
if (t.includes('clin')) return 'Clínica'
|
||||
return 'Compromisso'
|
||||
}
|
||||
|
||||
/**
|
||||
* Pausas semanais (jsonb) -> background events do FullCalendar.
|
||||
* Leitura flexível:
|
||||
* - esperado: [{ weekday: 1..7 ou 0..6, start:"HH:MM", end:"HH:MM", label }]
|
||||
*/
|
||||
export function buildWeeklyBreakBackgroundEvents (pausas, rangeStart, rangeEnd) {
|
||||
if (!Array.isArray(pausas) || pausas.length === 0) return []
|
||||
|
||||
const out = []
|
||||
const out = []
|
||||
const dayMs = 24 * 60 * 60 * 1000
|
||||
|
||||
for (let ts = startOfDay(rangeStart).getTime(); ts < rangeEnd.getTime(); ts += dayMs) {
|
||||
const d = new Date(ts)
|
||||
const dow = d.getDay() // 0..6
|
||||
const d = new Date(ts)
|
||||
const dow = d.getDay()
|
||||
|
||||
for (const p of pausas) {
|
||||
const wd = normalizeWeekday(p?.weekday)
|
||||
if (wd === null) continue
|
||||
if (wd !== dow) continue
|
||||
const wd = normalizeWeekday(p?.weekday ?? p?.dia_semana)
|
||||
if (wd === null || wd !== dow) continue
|
||||
|
||||
const start = asTime(p?.start ?? p?.inicio ?? p?.from)
|
||||
const end = asTime(p?.end ?? p?.fim ?? p?.to)
|
||||
const end = asTime(p?.end ?? p?.fim ?? p?.to)
|
||||
if (!start || !end) continue
|
||||
|
||||
out.push({
|
||||
id: `break-${ts}-${start}-${end}`,
|
||||
start: combineDateTimeISO(d, start),
|
||||
end: combineDateTimeISO(d, end),
|
||||
id: `break-${ts}-${start}-${end}`,
|
||||
start: combineDateTimeISO(d, start),
|
||||
end: combineDateTimeISO(d, end),
|
||||
display: 'background',
|
||||
overlap: false,
|
||||
extendedProps: { kind: 'break', label: p?.label ?? 'Pausa' }
|
||||
@@ -129,48 +194,53 @@ export function buildWeeklyBreakBackgroundEvents (pausas, rangeStart, rangeEnd)
|
||||
return out
|
||||
}
|
||||
|
||||
export function mapAgendaEventosToClinicResourceEvents (rows) {
|
||||
return (rows || []).map((r) => {
|
||||
const ownerId = normalizeId(r?.owner_id ?? r?.terapeuta_id ?? null)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// minutesToDuration / tituloFallback
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const commitment = r.determined_commitments
|
||||
const bgColor = commitment?.bg_color ? `#${commitment.bg_color}` : undefined
|
||||
const txtColor = commitment?.text_color || undefined
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
title: r.titulo || tituloFallback(r.tipo),
|
||||
start: r.inicio_em,
|
||||
end: r.fim_em,
|
||||
|
||||
// 🔥 resourceId também precisa ser confiável
|
||||
resourceId: ownerId,
|
||||
|
||||
...(bgColor && { backgroundColor: bgColor, borderColor: bgColor }),
|
||||
...(txtColor && { textColor: txtColor }),
|
||||
|
||||
extendedProps: {
|
||||
owner_id: ownerId,
|
||||
|
||||
tipo: r.tipo ?? null,
|
||||
status: r.status ?? null,
|
||||
|
||||
paciente_id: r.paciente_id ?? null,
|
||||
terapeuta_id: r.terapeuta_id ?? null,
|
||||
observacoes: r.observacoes ?? null,
|
||||
|
||||
visibility_scope: r.visibility_scope ?? null,
|
||||
masked: !!r.masked,
|
||||
|
||||
determined_commitment_id: r.determined_commitment_id ?? null,
|
||||
commitment_bg_color: bgColor ?? null,
|
||||
commitment_text_color: txtColor ?? null
|
||||
}
|
||||
}
|
||||
})
|
||||
export function minutesToDuration (min) {
|
||||
const h = Math.floor(min / 60)
|
||||
const m = min % 60
|
||||
return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:00`
|
||||
}
|
||||
|
||||
// -------------------- helpers --------------------
|
||||
export function tituloFallback (tipo) {
|
||||
const t = String(tipo || '').toLowerCase()
|
||||
if (t.includes('sess')) return 'Sessão'
|
||||
if (t.includes('block') || t.includes('bloq')) return 'Bloqueio'
|
||||
if (t.includes('pessoal')) return 'Pessoal'
|
||||
if (t.includes('clin')) return 'Clínica'
|
||||
return 'Compromisso'
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// helpers de status
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function _statusBgColor (status) {
|
||||
const map = {
|
||||
realizado: '#6b7280',
|
||||
faltou: '#ef4444',
|
||||
cancelado: '#f97316',
|
||||
bloqueado: '#6b7280',
|
||||
remarcado: '#a855f7',
|
||||
}
|
||||
return map[status] ?? null
|
||||
}
|
||||
|
||||
function _statusIcon (status, isOccurrence, hasSerie) {
|
||||
if (status === 'realizado') return '✓ '
|
||||
if (status === 'faltou') return '✗ '
|
||||
if (status === 'cancelado') return '∅ '
|
||||
if (status === 'bloqueado') return '⊘ '
|
||||
if (status === 'remarcado') return '↺ '
|
||||
if (hasSerie || isOccurrence) return '↻ '
|
||||
return ''
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// helpers internos — originais preservados
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function normalizeId (v) {
|
||||
if (v === null || v === undefined) return null
|
||||
@@ -190,7 +260,7 @@ function normalizeWeekday (value) {
|
||||
function asTime (v) {
|
||||
if (!v || typeof v !== 'string') return null
|
||||
const s = v.trim()
|
||||
if (/^\d{2}:\d{2}$/.test(s)) return `${s}:00`
|
||||
if (/^\d{2}:\d{2}$/.test(s)) return `${s}:00`
|
||||
if (/^\d{2}:\d{2}:\d{2}$/.test(s)) return s
|
||||
return null
|
||||
}
|
||||
@@ -203,7 +273,7 @@ function startOfDay (d) {
|
||||
|
||||
function combineDateTimeISO (date, timeHHMMSS) {
|
||||
const yyyy = date.getFullYear()
|
||||
const mm = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(date.getDate()).padStart(2, '0')
|
||||
const mm = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(date.getDate()).padStart(2, '0')
|
||||
return `${yyyy}-${mm}-${dd}T${timeHHMMSS}`
|
||||
}
|
||||
@@ -36,6 +36,24 @@ export async function getMyAgendaSettings () {
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Regras semanais de jornada (agenda_regras_semanais):
|
||||
* retorna os dias ativos com hora_inicio/hora_fim por dia.
|
||||
*/
|
||||
export async function getMyWorkSchedule () {
|
||||
const uid = await getUid()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_regras_semanais')
|
||||
.select('dia_semana, hora_inicio, hora_fim, ativo')
|
||||
.eq('owner_id', uid)
|
||||
.eq('ativo', true)
|
||||
.order('dia_semana')
|
||||
|
||||
if (error) throw error
|
||||
return data || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista agenda do terapeuta (somente do owner logado) dentro do tenant ativo.
|
||||
* Isso impede misturar eventos caso o terapeuta atue em múltiplas clínicas.
|
||||
@@ -59,27 +77,7 @@ export async function listMyAgendaEvents ({ startISO, endISO, tenantId: tenantId
|
||||
.order('inicio_em', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
const rows = data || []
|
||||
|
||||
// Eventos antigos têm paciente_id mas patient_id=null (sem FK) → join retorna null.
|
||||
// Fazemos um segundo fetch para esses casos e mesclamos.
|
||||
const orphanIds = [...new Set(
|
||||
rows.filter(r => r.paciente_id && !r.patients).map(r => r.paciente_id)
|
||||
)]
|
||||
if (orphanIds.length) {
|
||||
const { data: pts } = await supabase
|
||||
.from('patients')
|
||||
.select('id, nome_completo, avatar_url')
|
||||
.in('id', orphanIds)
|
||||
if (pts?.length) {
|
||||
const map = Object.fromEntries(pts.map(p => [p.id, p]))
|
||||
for (const r of rows) {
|
||||
if (r.paciente_id && !r.patients) r.patients = map[r.paciente_id] || null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rows
|
||||
return data || []
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -467,9 +467,10 @@
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column :key="'col-acoes'" header="Ações" style="width: 16rem;" frozen alignFrozen="right">
|
||||
<Column :key="'col-acoes'" header="Ações" style="width: 20rem;" frozen alignFrozen="right">
|
||||
<template #body="{ data }">
|
||||
<div class="flex gap-2 justify-end">
|
||||
<Button label="Sessões" icon="pi pi-calendar" size="small" severity="info" outlined @click="abrirSessoes(data)" />
|
||||
<Button label="Prontuário" icon="pi pi-file" size="small" @click="openProntuario(data)" />
|
||||
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" v-tooltip.top="'Editar'" @click="goEdit(data)" />
|
||||
<Button icon="pi pi-trash" severity="danger" outlined size="small" v-tooltip.top="'Excluir'" @click="confirmDeleteOne(data)" />
|
||||
@@ -521,7 +522,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Ações -->
|
||||
<div class="mt-3 flex gap-2 justify-end">
|
||||
<div class="mt-3 flex gap-2 justify-end flex-wrap">
|
||||
<Button label="Sessões" icon="pi pi-calendar" size="small" severity="info" outlined @click="abrirSessoes(pat)" />
|
||||
<Button label="Prontuário" icon="pi pi-file" size="small" @click="openProntuario(pat)" />
|
||||
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" @click="goEdit(pat)" />
|
||||
<Button icon="pi pi-trash" severity="danger" outlined size="small" @click="confirmDeleteOne(pat)" />
|
||||
@@ -585,6 +587,61 @@
|
||||
/>
|
||||
|
||||
<ConfirmDialog />
|
||||
|
||||
<!-- ── DIALOG SESSÕES DO PACIENTE ─────────────────────────── -->
|
||||
<Dialog
|
||||
v-model:visible="sessoesOpen"
|
||||
modal
|
||||
:draggable="false"
|
||||
:style="{ width: '700px', maxWidth: '96vw' }"
|
||||
:header="sessoesPaciente ? `Sessões — ${sessoesPaciente.nome_completo}` : 'Sessões'"
|
||||
>
|
||||
<div v-if="sessoesLoading" class="flex justify-center py-8">
|
||||
<ProgressSpinner />
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- Recorrências ativas -->
|
||||
<div v-if="recorrencias.length" class="mb-5">
|
||||
<div class="text-sm font-semibold text-color-secondary mb-2 flex items-center gap-2">
|
||||
<i class="pi pi-sync" /> Recorrências
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="r in recorrencias"
|
||||
:key="r.id"
|
||||
class="sess-rec-card"
|
||||
>
|
||||
<Tag :value="r.status === 'ativo' ? 'Ativa' : 'Encerrada'" :severity="r.status === 'ativo' ? 'success' : 'secondary'" />
|
||||
<span class="text-sm">{{ fmtRecorrencia(r) }}</span>
|
||||
<span class="text-xs text-color-secondary ml-auto">
|
||||
{{ r.start_date }} {{ r.end_date ? `→ ${r.end_date}` : '(em aberto)' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lista de sessões -->
|
||||
<div class="text-sm font-semibold text-color-secondary mb-2 flex items-center gap-2">
|
||||
<i class="pi pi-calendar" /> Sessões ({{ sessoesLista.length }})
|
||||
</div>
|
||||
|
||||
<div v-if="sessoesLista.length === 0" class="text-center py-6 text-color-secondary text-sm">
|
||||
Nenhuma sessão encontrada para este paciente.
|
||||
</div>
|
||||
|
||||
<div v-else class="sess-list">
|
||||
<div v-for="ev in sessoesLista" :key="ev.id" class="sess-item">
|
||||
<div class="flex items-center gap-3">
|
||||
<Tag :value="ev.status || 'agendado'" :severity="statusSessaoSev(ev.status)" />
|
||||
<span class="font-semibold text-sm">{{ fmtDataSessao(ev.inicio_em) }}</span>
|
||||
<Tag v-if="ev.modalidade" :value="ev.modalidade" severity="secondary" class="ml-auto" />
|
||||
</div>
|
||||
<div v-if="ev.titulo" class="text-xs text-color-secondary mt-1">{{ ev.titulo }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -602,6 +659,59 @@ import ProgressSpinner from 'primevue/progressspinner'
|
||||
import PatientProntuario from '@/features/patients/prontuario/PatientProntuario.vue'
|
||||
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue'
|
||||
|
||||
// ── Sessões do paciente ──────────────────────────────────────────
|
||||
const sessoesOpen = ref(false)
|
||||
const sessoesPaciente = ref(null) // { id, nome_completo }
|
||||
const sessoesLoading = ref(false)
|
||||
const sessoesLista = ref([])
|
||||
const recorrencias = ref([])
|
||||
|
||||
const MESES_BR = ['Jan','Fev','Mar','Abr','Mai','Jun','Jul','Ago','Set','Out','Nov','Dez']
|
||||
function fmtDataSessao (iso) {
|
||||
if (!iso) return '—'
|
||||
const d = new Date(iso)
|
||||
return `${String(d.getDate()).padStart(2,'0')} ${MESES_BR[d.getMonth()]} ${d.getFullYear()} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`
|
||||
}
|
||||
function statusSessaoSev (st) {
|
||||
return { agendado: 'info', realizado: 'success', cancelado: 'danger', faltou: 'warn' }[st] || 'secondary'
|
||||
}
|
||||
|
||||
async function abrirSessoes (pat) {
|
||||
sessoesPaciente.value = pat
|
||||
sessoesOpen.value = true
|
||||
sessoesLoading.value = true
|
||||
sessoesLista.value = []
|
||||
recorrencias.value = []
|
||||
try {
|
||||
const [evts, recs] = await Promise.all([
|
||||
supabase
|
||||
.from('agenda_eventos')
|
||||
.select('id, titulo, tipo, status, inicio_em, fim_em, modalidade')
|
||||
.eq('patient_id', pat.id)
|
||||
.order('inicio_em', { ascending: false })
|
||||
.limit(100),
|
||||
supabase
|
||||
.from('recurrence_rules')
|
||||
.select('id, type, interval, weekdays, start_date, end_date, start_time, duration_min, status')
|
||||
.eq('patient_id', pat.id)
|
||||
.order('start_date', { ascending: false }),
|
||||
])
|
||||
sessoesLista.value = evts.data || []
|
||||
recorrencias.value = recs.data || []
|
||||
} catch (e) {
|
||||
console.error('Erro ao carregar sessões:', e)
|
||||
} finally {
|
||||
sessoesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const DIAS_SEMANA = ['Dom','Seg','Ter','Qua','Qui','Sex','Sáb']
|
||||
function fmtRecorrencia (r) {
|
||||
const dias = (r.weekdays || []).map(d => DIAS_SEMANA[d]).join(', ')
|
||||
const freq = r.type === 'weekly' && r.interval === 2 ? 'Quinzenal' : r.type === 'weekly' ? 'Semanal' : 'Personalizado'
|
||||
return `${freq} · ${dias} · ${r.start_time?.slice(0,5) || '—'} · ${r.duration_min || 50}min`
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
@@ -1328,4 +1438,25 @@ function updateKpis() {
|
||||
/* Fade */
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
|
||||
/* ── Dialog Sessões ──────────────────────────────────────────── */
|
||||
.sess-list {
|
||||
display: flex; flex-direction: column; gap: .5rem;
|
||||
max-height: 55vh; overflow-y: auto;
|
||||
padding-right: .25rem;
|
||||
}
|
||||
.sess-item {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: .75rem;
|
||||
background: var(--surface-ground);
|
||||
}
|
||||
.sess-rec-card {
|
||||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: .75rem;
|
||||
background: var(--surface-ground);
|
||||
font-size: .85rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -55,7 +55,7 @@ function pick(obj, keys = []) {
|
||||
// ------------------------------------------------------
|
||||
// accordion (pode abrir vários) + scroll
|
||||
// ------------------------------------------------------
|
||||
const accordionValues = ['0', '1', '2', '3', '4']
|
||||
const accordionValues = ['0', '1', '2', '3', '4', '5']
|
||||
const activeValues = ref(['0']) // começa com o primeiro aberto
|
||||
const activeValue = computed(() => activeValues.value?.[0] ?? null)
|
||||
|
||||
@@ -91,7 +91,8 @@ const navItems = [
|
||||
{ value: '1', label: 'Endereço', icon: 'pi pi-map-marker' },
|
||||
{ value: '2', label: 'Dados adicionais', icon: 'pi pi-tags' },
|
||||
{ value: '3', label: 'Responsável', icon: 'pi pi-users' },
|
||||
{ value: '4', label: 'Anotações', icon: 'pi pi-file-edit' }
|
||||
{ value: '4', label: 'Anotações', icon: 'pi pi-file-edit' },
|
||||
{ value: '5', label: 'Sessões', icon: 'pi pi-calendar' }
|
||||
]
|
||||
|
||||
const navPopover = ref(null)
|
||||
@@ -307,6 +308,74 @@ const observacaoResponsavel = computed(() => pick(patientData.value, ['observaca
|
||||
// notas internas
|
||||
const notasInternas = computed(() => pick(patientData.value, ['notas_internas', 'notes']))
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Sessões do paciente (integração agenda)
|
||||
// ------------------------------------------------------
|
||||
const sessions = ref([])
|
||||
const sessionsLoading = ref(false)
|
||||
|
||||
const STATUS_LABEL = {
|
||||
agendado: 'Agendado',
|
||||
realizado: 'Realizado',
|
||||
faltou: 'Faltou',
|
||||
cancelado: 'Cancelado',
|
||||
remarcado: 'Remarcado',
|
||||
bloqueado: 'Bloqueado',
|
||||
}
|
||||
|
||||
const STATUS_SEVERITY = {
|
||||
agendado: 'info',
|
||||
realizado: 'success',
|
||||
faltou: 'danger',
|
||||
cancelado: 'warn',
|
||||
remarcado: 'secondary',
|
||||
bloqueado: 'secondary',
|
||||
}
|
||||
|
||||
function fmtDateTimeBR (iso) {
|
||||
if (!iso) return '—'
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
const dd = String(d.getDate()).padStart(2, '0')
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const yy = d.getFullYear()
|
||||
const hh = String(d.getHours()).padStart(2, '0')
|
||||
const mi = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${dd}/${mm}/${yy} ${hh}:${mi}`
|
||||
}
|
||||
|
||||
function sessionDuration (inicio, fim) {
|
||||
if (!inicio || !fim) return null
|
||||
const diff = new Date(fim) - new Date(inicio)
|
||||
if (diff <= 0) return null
|
||||
const min = Math.round(diff / 60000)
|
||||
if (min < 60) return `${min} min`
|
||||
const h = Math.floor(min / 60)
|
||||
const m = min % 60
|
||||
return m ? `${h}h ${m}min` : `${h}h`
|
||||
}
|
||||
|
||||
async function loadSessions (patientId) {
|
||||
sessionsLoading.value = true
|
||||
sessions.value = []
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('id, inicio_em, fim_em, status, modalidade, tipo, titulo, titulo_custom, observacoes, tenant_id')
|
||||
.eq('patient_id', patientId)
|
||||
.order('inicio_em', { ascending: false })
|
||||
.limit(100)
|
||||
|
||||
if (error) throw error
|
||||
sessions.value = data || []
|
||||
} catch (e) {
|
||||
// falha silenciosa — prontuário continua sem a seção de sessões
|
||||
sessions.value = []
|
||||
} finally {
|
||||
sessionsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function getPatientById(id) {
|
||||
const { data, error } = await supabase
|
||||
.from('patients')
|
||||
@@ -385,15 +454,21 @@ async function loadProntuario(id) {
|
||||
tags.value = []
|
||||
|
||||
try {
|
||||
const p = await getPatientById(id)
|
||||
const [p, rel] = await Promise.all([
|
||||
getPatientById(id),
|
||||
getPatientRelations(id),
|
||||
])
|
||||
if (!p) throw new Error('Paciente não retornou dados (RLS bloqueando ou ID não existe no banco).')
|
||||
|
||||
patientFull.value = p
|
||||
|
||||
const rel = await getPatientRelations(id)
|
||||
|
||||
groups.value = await getGroupsByIds(rel.groupIds || [])
|
||||
tags.value = await getTagsByIds(rel.tagIds || [])
|
||||
const [g, t] = await Promise.all([
|
||||
getGroupsByIds(rel.groupIds || []),
|
||||
getTagsByIds(rel.tagIds || []),
|
||||
loadSessions(id),
|
||||
])
|
||||
groups.value = g
|
||||
tags.value = t
|
||||
} catch (e) {
|
||||
loadError.value = e?.message || 'Falha ao buscar dados no Supabase.'
|
||||
toast.add({ severity: 'error', summary: 'Erro ao carregar prontuário', detail: loadError.value, life: 4500 })
|
||||
@@ -984,6 +1059,42 @@ Tags: ${(tags.value || []).map(t => t.name).filter(Boolean).join(', ') || '—'}
|
||||
</FloatLabel>
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
|
||||
<AccordionPanel value="5">
|
||||
<AccordionHeader :ref="el => setPanelHeaderRef(el, 5)">6. SESSÕES</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<div v-if="sessionsLoading" class="text-slate-500 text-sm py-2">Carregando sessões…</div>
|
||||
<div v-else-if="!sessions.length" class="text-slate-500 text-sm py-2">Nenhuma sessão registrada para este paciente.</div>
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="s in sessions"
|
||||
:key="s.id"
|
||||
class="rounded-xl border border-slate-200 bg-white px-4 py-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="font-medium text-slate-800">
|
||||
{{ s.titulo_custom || s.titulo || (s.tipo ? s.tipo : 'Sessão') }}
|
||||
</div>
|
||||
<div class="text-sm text-slate-500 flex flex-wrap gap-x-3 gap-y-1">
|
||||
<span><i class="pi pi-calendar mr-1 opacity-60" />{{ fmtDateTimeBR(s.inicio_em) }}</span>
|
||||
<span v-if="sessionDuration(s.inicio_em, s.fim_em)">
|
||||
<i class="pi pi-clock mr-1 opacity-60" />{{ sessionDuration(s.inicio_em, s.fim_em) }}
|
||||
</span>
|
||||
<span v-if="s.modalidade">
|
||||
<i class="pi pi-video mr-1 opacity-60" />{{ s.modalidade === 'online' ? 'Online' : 'Presencial' }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="s.observacoes" class="text-sm text-slate-600 mt-1 line-clamp-2">{{ s.observacoes }}</div>
|
||||
</div>
|
||||
<Tag
|
||||
:value="STATUS_LABEL[s.status] || s.status || 'Agendado'"
|
||||
:severity="STATUS_SEVERITY[s.status] || 'info'"
|
||||
class="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
</Accordion>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
<script setup>
|
||||
import { useLayout } from '@/layout/composables/layout'
|
||||
import { computed, onMounted, onBeforeUnmount, provide } from 'vue'
|
||||
import { computed, onMounted, onBeforeUnmount, provide, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import AppFooter from './AppFooter.vue'
|
||||
import AppSidebar from './AppSidebar.vue'
|
||||
import AppTopbar from './AppTopbar.vue'
|
||||
import AppRail from './AppRail.vue'
|
||||
import AppRailPanel from './AppRailPanel.vue'
|
||||
import AppRailTopbar from './AppRailTopbar.vue'
|
||||
import AppFooter from './AppFooter.vue'
|
||||
import AppSidebar from './AppSidebar.vue'
|
||||
import AppTopbar from './AppTopbar.vue'
|
||||
import AppRail from './AppRail.vue'
|
||||
import AppRailPanel from './AppRailPanel.vue'
|
||||
import AppRailTopbar from './AppRailTopbar.vue'
|
||||
import AjudaDrawer from '@/components/AjudaDrawer.vue'
|
||||
|
||||
import { fetchDocsForPath, useAjuda } from '@/composables/useAjuda'
|
||||
|
||||
const { drawerOpen } = useAjuda()
|
||||
|
||||
const ajudaPushStyle = computed(() => ({
|
||||
transition: 'padding-right 0.3s ease',
|
||||
paddingRight: drawerOpen.value ? '420px' : '0'
|
||||
}))
|
||||
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
import { useEntitlementsStore } from '@/stores/entitlementsStore'
|
||||
@@ -77,6 +87,9 @@ function onSessionRefreshed () {
|
||||
revalidateAfterSessionRefresh()
|
||||
}
|
||||
|
||||
// Dispara busca de docs de ajuda sempre que a rota muda
|
||||
watch(() => route.path, (path) => fetchDocsForPath(path), { immediate: true })
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('app:session-refreshed', onSessionRefreshed)
|
||||
})
|
||||
@@ -95,12 +108,13 @@ onBeforeUnmount(() => {
|
||||
<AppRailTopbar />
|
||||
<div class="l2-content">
|
||||
<AppRailPanel />
|
||||
<div class="l2-main">
|
||||
<div class="l2-main" :style="ajudaPushStyle">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AjudaDrawer />
|
||||
<Toast />
|
||||
</template>
|
||||
|
||||
@@ -109,7 +123,7 @@ onBeforeUnmount(() => {
|
||||
<div class="layout-wrapper" :class="containerClass">
|
||||
<AppTopbar />
|
||||
<AppSidebar />
|
||||
<div class="layout-main-container">
|
||||
<div class="layout-main-container" :style="ajudaPushStyle">
|
||||
<div class="layout-main">
|
||||
<router-view />
|
||||
</div>
|
||||
@@ -117,6 +131,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<div class="layout-mask animate-fadein" @click="hideMobileMenu" />
|
||||
</div>
|
||||
<AjudaDrawer />
|
||||
<Toast />
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -6,10 +6,12 @@ import AppConfigurator from './AppConfigurator.vue'
|
||||
import { useLayout } from '@/layout/composables/layout'
|
||||
import { useUserSettingsPersistence } from '@/composables/useUserSettingsPersistence'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
import { useAjuda } from '@/composables/useAjuda'
|
||||
|
||||
const { toggleDarkMode, isDarkTheme } = useLayout()
|
||||
const { queuePatch } = useUserSettingsPersistence()
|
||||
const tenantStore = useTenantStore()
|
||||
const { openDrawer: openAjudaDrawer } = useAjuda()
|
||||
|
||||
const tenantName = computed(() => {
|
||||
const t =
|
||||
@@ -64,6 +66,16 @@ async function toggleDarkAndPersist () {
|
||||
|
||||
<!-- Ações -->
|
||||
<div class="rail-topbar__actions">
|
||||
<!-- Ajuda -->
|
||||
<button
|
||||
type="button"
|
||||
class="rail-topbar__btn"
|
||||
title="Ajuda"
|
||||
@click="openAjudaDrawer"
|
||||
>
|
||||
<i class="pi pi-question-circle" />
|
||||
</button>
|
||||
|
||||
<!-- Dark mode -->
|
||||
<button
|
||||
type="button"
|
||||
@@ -156,4 +168,4 @@ async function toggleDarkAndPersist () {
|
||||
.rail-topbar__btn--highlight {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -15,6 +15,9 @@ import { useTenantStore } from '@/stores/tenantStore'
|
||||
import { useRoleGuard } from '@/composables/useRoleGuard'
|
||||
const { canSee } = useRoleGuard()
|
||||
|
||||
import { useAjuda } from '@/composables/useAjuda'
|
||||
const { openDrawer: openAjudaDrawer } = useAjuda()
|
||||
|
||||
import { useUserSettingsPersistence } from '@/composables/useUserSettingsPersistence'
|
||||
import { applyThemeEngine } from '@/theme/theme.options'
|
||||
|
||||
@@ -623,6 +626,15 @@ onMounted(async () => {
|
||||
:baseZIndex="3000"
|
||||
/>
|
||||
|
||||
<Button
|
||||
icon="pi pi-question-circle"
|
||||
label="Ajuda"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="ajuda-btn"
|
||||
@click="openAjudaDrawer"
|
||||
/>
|
||||
|
||||
<button type="button" class="layout-topbar-action">
|
||||
<i class="pi pi-calendar"></i>
|
||||
<span>Calendar</span>
|
||||
@@ -680,6 +692,13 @@ onMounted(async () => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ajuda-btn {
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.3rem 0.8rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.topbar-ctx-v {
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.95;
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
<!-- src/layout/ConfiguracoesPage.vue -->
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const secoes = [
|
||||
{
|
||||
key: 'agenda',
|
||||
label: 'Agenda',
|
||||
desc: 'Horários semanais, exceções, duração e intervalo padrão.',
|
||||
icon: 'pi pi-calendar',
|
||||
to: '/configuracoes/agenda',
|
||||
tags: ['Horários', 'Exceções', 'Duração']
|
||||
},
|
||||
|
||||
// Ative quando criar as rotas/páginas
|
||||
// {
|
||||
// key: 'clinica',
|
||||
// label: 'Clínica',
|
||||
// desc: 'Padrões clínicos, status e preferências de atendimento.',
|
||||
// icon: 'pi pi-heart',
|
||||
// to: '/configuracoes/clinica',
|
||||
// tags: ['Status', 'Modelos', 'Preferências']
|
||||
// },
|
||||
// {
|
||||
// key: 'intake',
|
||||
// label: 'Cadastros & Intake',
|
||||
// desc: 'Link externo, campos do formulário e mensagens padrão.',
|
||||
// icon: 'pi pi-file-edit',
|
||||
// to: '/configuracoes/intake',
|
||||
// tags: ['Formulário', 'Campos', 'Textos']
|
||||
// },
|
||||
// {
|
||||
// key: 'conta',
|
||||
// label: 'Conta',
|
||||
// desc: 'Perfil, segurança e preferências da conta.',
|
||||
// icon: 'pi pi-user',
|
||||
// to: '/configuracoes/conta',
|
||||
// tags: ['Perfil', 'Segurança', 'Preferências']
|
||||
// }
|
||||
]
|
||||
|
||||
const activeTo = computed(() => {
|
||||
const p = route.path || ''
|
||||
const hit = secoes.find(s => p.startsWith(s.to))
|
||||
return hit?.to || '/configuracoes/agenda'
|
||||
})
|
||||
|
||||
function ir(to) {
|
||||
if (!to) return
|
||||
if (route.path !== to) router.push(to)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<!-- HEADER CONCEITUAL -->
|
||||
<div class="mb-4 overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)]">
|
||||
<div class="relative p-5">
|
||||
<!-- blobs sutis -->
|
||||
<div class="pointer-events-none absolute inset-0 opacity-80">
|
||||
<div class="absolute -top-16 -right-16 h-52 w-52 rounded-full bg-emerald-400/10 blur-3xl" />
|
||||
<div class="absolute top-10 -left-24 h-60 w-60 rounded-full bg-indigo-400/10 blur-3xl" />
|
||||
<div class="absolute bottom-0 right-24 h-44 w-44 rounded-full bg-fuchsia-400/10 blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-900 text-2xl font-semibold leading-none">Configurações</div>
|
||||
<div class="text-600 mt-2 max-w-2xl">
|
||||
Defina como sua clínica funciona: agenda, cadastros e preferências. Tudo no mesmo lugar — sem espalhar opções pelo sistema.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
label="Voltar"
|
||||
icon="pi pi-arrow-left"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="hidden md:inline-flex"
|
||||
@click="router.back()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
<!-- Card pequeno “atalhos” opcional -->
|
||||
<div class="mt-4 hidden lg:block">
|
||||
<Card>
|
||||
<template #content>
|
||||
<div class="text-900 font-medium">Dica</div>
|
||||
<div class="text-600 text-sm mt-2 leading-relaxed">
|
||||
Comece pela <b>Agenda</b>. É ela que dá “tempo” ao prontuário: sessão marcada → sessão realizada → evolução.
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CONTEÚDO (seção selecionada) -->
|
||||
<div class="col-span-12 lg:col-span-8 xl:col-span-9">
|
||||
<!-- Aqui entra /configuracoes/agenda etc -->
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -21,6 +21,38 @@ const secoes = [
|
||||
to: '/configuracoes/agenda',
|
||||
tags: ['Horários', 'Exceções', 'Duração']
|
||||
},
|
||||
{
|
||||
key: 'bloqueios',
|
||||
label: 'Bloqueios',
|
||||
desc: 'Feriados nacionais, municipais e períodos bloqueados para pacientes.',
|
||||
icon: 'pi pi-ban',
|
||||
to: '/configuracoes/bloqueios',
|
||||
tags: ['Feriados', 'Períodos', 'Recorrentes']
|
||||
},
|
||||
{
|
||||
key: 'agendador',
|
||||
label: 'Agendador Online',
|
||||
desc: 'Link público para pacientes solicitarem horários. Aprovação, identidade visual e pagamento.',
|
||||
icon: 'pi pi-calendar-clock',
|
||||
to: '/configuracoes/agendador',
|
||||
tags: ['PRO', 'Link', 'Pix', 'LGPD']
|
||||
},
|
||||
{
|
||||
key: 'pagamento',
|
||||
label: 'Pagamento',
|
||||
desc: 'Formas de pagamento aceitas: Pix, depósito bancário, dinheiro, cartão e convênio.',
|
||||
icon: 'pi pi-wallet',
|
||||
to: '/configuracoes/pagamento',
|
||||
tags: ['Pix', 'TED', 'Cartão', 'Convênio']
|
||||
},
|
||||
{
|
||||
key: 'precificacao',
|
||||
label: 'Precificação',
|
||||
desc: 'Valor padrão da sessão e preços específicos por tipo de compromisso.',
|
||||
icon: 'pi pi-tag',
|
||||
to: '/configuracoes/precificacao',
|
||||
tags: ['Valores', 'Sessão', 'Compromisso']
|
||||
},
|
||||
|
||||
// Ative quando criar as rotas/páginas
|
||||
// {
|
||||
@@ -51,7 +83,9 @@ const secoes = [
|
||||
|
||||
const activeTo = computed(() => {
|
||||
const p = route.path || ''
|
||||
const hit = secoes.find(s => p.startsWith(s.to))
|
||||
const hit = [...secoes]
|
||||
.sort((a, b) => b.to.length - a.to.length)
|
||||
.find(s => p === s.to || p.startsWith(s.to + '/'))
|
||||
return hit?.to || '/configuracoes/agenda'
|
||||
})
|
||||
|
||||
|
||||
617
src/layout/configuracoes/BloqueiosPage.vue
Normal file
617
src/layout/configuracoes/BloqueiosPage.vue
Normal file
@@ -0,0 +1,617 @@
|
||||
<!-- src/layout/configuracoes/BloqueiosPage.vue -->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useFeriados } from '@/composables/useFeriados'
|
||||
import DatePicker from 'primevue/datepicker'
|
||||
|
||||
const toast = useToast()
|
||||
const tenantStore = useTenantStore()
|
||||
|
||||
// ── Feriados (nacionais + municipais) ───────────────────────
|
||||
const { nacionais, municipais, todos, loading: loadingF, load: loadFeriados, criar: criarFeriado, remover: removerFeriado, isDuplicata } = useFeriados()
|
||||
|
||||
// ── Estado bloqueios ────────────────────────────────────────
|
||||
const loadingB = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const ownerId = ref(null)
|
||||
const tenantId = ref(null)
|
||||
const ano = ref(new Date().getFullYear())
|
||||
const bloqueios = ref([])
|
||||
|
||||
// ── Dialog bloqueio ─────────────────────────────────────────
|
||||
const dlgOpen = ref(false)
|
||||
const dlgMode = ref('add')
|
||||
const form = ref(emptyForm())
|
||||
|
||||
// ── Dialog feriado municipal ────────────────────────────────
|
||||
const fdlgOpen = ref(false)
|
||||
const fsaving = ref(false)
|
||||
const fform = ref(emptyFForm())
|
||||
|
||||
function emptyForm () {
|
||||
return {
|
||||
id: null,
|
||||
titulo: '',
|
||||
data_inicio: null,
|
||||
data_fim: null,
|
||||
hora_inicio: null,
|
||||
hora_fim: null,
|
||||
recorrente: false,
|
||||
dia_semana: null,
|
||||
observacao: ''
|
||||
}
|
||||
}
|
||||
|
||||
function emptyFForm () {
|
||||
return { nome: '', data: null, observacao: '', bloqueia_sessoes: false }
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────
|
||||
function dateToISO (d) {
|
||||
if (!d) return null
|
||||
const dt = d instanceof Date ? d : new Date(d)
|
||||
return `${dt.getFullYear()}-${String(dt.getMonth()+1).padStart(2,'0')}-${String(dt.getDate()).padStart(2,'0')}`
|
||||
}
|
||||
function isoToDate (s) {
|
||||
if (!s) return null
|
||||
const [y, m, d] = String(s).split('-').map(Number)
|
||||
return new Date(y, m-1, d)
|
||||
}
|
||||
function dateToHHMM (d) {
|
||||
if (!d || !(d instanceof Date)) return null
|
||||
return String(d.getHours()).padStart(2,'0') + ':' + String(d.getMinutes()).padStart(2,'0')
|
||||
}
|
||||
function hhmmToDate (hhmm) {
|
||||
if (!hhmm) return null
|
||||
const [h, m] = String(hhmm).split(':').map(Number)
|
||||
const d = new Date(); d.setHours(h, m, 0, 0); return d
|
||||
}
|
||||
function fmtDate (iso) {
|
||||
if (!iso) return '—'
|
||||
const [y, m, d] = String(iso).split('-')
|
||||
return `${d}/${m}/${y}`
|
||||
}
|
||||
function fmtDateShort (iso) {
|
||||
if (!iso) return ''
|
||||
const [, m, d] = String(iso).split('-')
|
||||
return `${d}/${m}`
|
||||
}
|
||||
function fmtHora (t) {
|
||||
if (!t) return null
|
||||
return String(t).slice(0, 5)
|
||||
}
|
||||
|
||||
// ── Auth + tenant ────────────────────────────────────────────
|
||||
async function boot () {
|
||||
const { data } = await supabase.auth.getUser()
|
||||
ownerId.value = data?.user?.id || null
|
||||
tenantId.value = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null
|
||||
if (tenantId.value) await loadFeriados(tenantId.value)
|
||||
await loadBloqueios()
|
||||
}
|
||||
|
||||
onMounted(boot)
|
||||
|
||||
// ── Load bloqueios ────────────────────────────────────────────
|
||||
async function loadBloqueios () {
|
||||
if (!ownerId.value) return
|
||||
loadingB.value = true
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_bloqueios')
|
||||
.select('*')
|
||||
.eq('owner_id', ownerId.value)
|
||||
.gte('data_inicio', `${ano.value}-01-01`)
|
||||
.lte('data_inicio', `${ano.value}-12-31`)
|
||||
.order('data_inicio')
|
||||
if (error) throw error
|
||||
bloqueios.value = data || []
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3500 })
|
||||
} finally {
|
||||
loadingB.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Navegação de ano ─────────────────────────────────────────
|
||||
async function anoAnterior () {
|
||||
ano.value--
|
||||
await loadFeriados(tenantId.value, ano.value)
|
||||
await loadBloqueios()
|
||||
}
|
||||
async function anoProximo () {
|
||||
ano.value++
|
||||
await loadFeriados(tenantId.value, ano.value)
|
||||
await loadBloqueios()
|
||||
}
|
||||
|
||||
// ── Feriado municipal CRUD ────────────────────────────────────
|
||||
function abrirFeriadoMunicipal () {
|
||||
fform.value = emptyFForm()
|
||||
fdlgOpen.value = true
|
||||
}
|
||||
|
||||
const fformValid = computed(() => !!fform.value.nome.trim() && !!fform.value.data)
|
||||
|
||||
async function salvarFeriado () {
|
||||
if (!fformValid.value) return
|
||||
const iso = dateToISO(fform.value.data)
|
||||
if (isDuplicata(iso, fform.value.nome)) {
|
||||
toast.add({ severity: 'warn', summary: 'Duplicado', detail: 'Já existe um feriado com esse nome nessa data.', life: 3000 })
|
||||
return
|
||||
}
|
||||
fsaving.value = true
|
||||
try {
|
||||
await criarFeriado({
|
||||
tenant_id: tenantId.value,
|
||||
owner_id: ownerId.value,
|
||||
tipo: 'municipal',
|
||||
nome: fform.value.nome.trim(),
|
||||
data: iso,
|
||||
observacao: fform.value.observacao || null,
|
||||
bloqueia_sessoes: fform.value.bloqueia_sessoes
|
||||
})
|
||||
toast.add({ severity: 'success', summary: 'Feriado cadastrado', life: 1800 })
|
||||
fdlgOpen.value = false
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3500 })
|
||||
} finally {
|
||||
fsaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function excluirFeriado (id) {
|
||||
try {
|
||||
await removerFeriado(id)
|
||||
toast.add({ severity: 'success', summary: 'Removido', life: 1500 })
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3000 })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bloqueio CRUD ─────────────────────────────────────────────
|
||||
function abrirAddBloqueio () {
|
||||
form.value = emptyForm()
|
||||
dlgMode.value = 'add'
|
||||
dlgOpen.value = true
|
||||
}
|
||||
|
||||
function abrirEditBloqueio (b) {
|
||||
form.value = {
|
||||
id: b.id,
|
||||
titulo: b.titulo,
|
||||
data_inicio: isoToDate(b.data_inicio),
|
||||
data_fim: isoToDate(b.data_fim),
|
||||
hora_inicio: hhmmToDate(fmtHora(b.hora_inicio)),
|
||||
hora_fim: hhmmToDate(fmtHora(b.hora_fim)),
|
||||
recorrente: !!b.recorrente,
|
||||
dia_semana: b.dia_semana ?? null,
|
||||
observacao: b.observacao || ''
|
||||
}
|
||||
dlgMode.value = 'edit'
|
||||
dlgOpen.value = true
|
||||
}
|
||||
|
||||
const formValid = computed(() => !!form.value.titulo.trim() && !!form.value.data_inicio)
|
||||
|
||||
async function salvarBloqueio () {
|
||||
if (!formValid.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
const payload = {
|
||||
owner_id: ownerId.value,
|
||||
tenant_id: tenantId.value,
|
||||
tipo: 'bloqueio',
|
||||
titulo: form.value.titulo.trim(),
|
||||
data_inicio: dateToISO(form.value.data_inicio),
|
||||
data_fim: dateToISO(form.value.data_fim) || null,
|
||||
hora_inicio: dateToHHMM(form.value.hora_inicio) || null,
|
||||
hora_fim: dateToHHMM(form.value.hora_fim) || null,
|
||||
recorrente: form.value.recorrente,
|
||||
dia_semana: form.value.dia_semana ?? null,
|
||||
observacao: form.value.observacao || null,
|
||||
origem: 'manual'
|
||||
}
|
||||
|
||||
if (dlgMode.value === 'edit') {
|
||||
const { error } = await supabase.from('agenda_bloqueios').update(payload).eq('id', form.value.id)
|
||||
if (error) throw error
|
||||
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Bloqueio atualizado.', life: 1800 })
|
||||
} else {
|
||||
const { error } = await supabase.from('agenda_bloqueios').insert(payload)
|
||||
if (error) throw error
|
||||
toast.add({ severity: 'success', summary: 'Criado', detail: 'Bloqueio adicionado.', life: 1800 })
|
||||
}
|
||||
|
||||
dlgOpen.value = false
|
||||
await loadBloqueios()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3500 })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function excluirBloqueio (id) {
|
||||
try {
|
||||
const { error } = await supabase.from('agenda_bloqueios').delete().eq('id', id)
|
||||
if (error) throw error
|
||||
bloqueios.value = bloqueios.value.filter(b => b.id !== id)
|
||||
toast.add({ severity: 'success', summary: 'Removido', life: 1500 })
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3000 })
|
||||
}
|
||||
}
|
||||
|
||||
// ── fmtPeriodo ────────────────────────────────────────────────
|
||||
function fmtPeriodo (b) {
|
||||
if (b.recorrente && b.dia_semana != null) {
|
||||
const dias = ['Dom','Seg','Ter','Qua','Qui','Sex','Sáb']
|
||||
return `Toda ${dias[b.dia_semana]}`
|
||||
}
|
||||
const ini = fmtDate(b.data_inicio)
|
||||
if (!b.data_fim || b.data_fim === b.data_inicio) {
|
||||
const hora = b.hora_inicio ? ` · ${fmtHora(b.hora_inicio)}–${fmtHora(b.hora_fim) || '?'}` : ' · Dia inteiro'
|
||||
return ini + hora
|
||||
}
|
||||
return `${ini} até ${fmtDate(b.data_fim)}`
|
||||
}
|
||||
|
||||
const loading = computed(() => loadingF.value || loadingB.value)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Toast />
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<!-- ── 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>
|
||||
</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" />
|
||||
</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>
|
||||
</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>
|
||||
<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>
|
||||
</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 ────────────────────────────────────────────── -->
|
||||
<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) ─────────────── -->
|
||||
<div class="blk-group">
|
||||
<div class="blk-group__head">
|
||||
<i class="pi pi-flag text-blue-500" />
|
||||
<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>
|
||||
</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" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Feriados Municipais ──────────────────────────────── -->
|
||||
<div class="blk-group">
|
||||
<div class="blk-group__head">
|
||||
<i class="pi pi-map-marker text-orange-500" />
|
||||
<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>
|
||||
<div class="blk-item__title">{{ f.nome }}</div>
|
||||
<div v-if="f.observacao" class="blk-item__obs">{{ f.observacao }}</div>
|
||||
<div class="blk-item__actions">
|
||||
<Button icon="pi pi-trash" text rounded size="small" severity="danger" @click="excluirFeriado(f.id)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Bloqueios ─────────────────────────────────────────── -->
|
||||
<div class="blk-group">
|
||||
<div class="blk-group__head">
|
||||
<i class="pi pi-ban text-red-500" />
|
||||
<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" />
|
||||
<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)" />
|
||||
<Button icon="pi pi-trash" text rounded size="small" severity="danger" @click="excluirBloqueio(b.id)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ══ Dialog feriado municipal ══════════════════════════════ -->
|
||||
<Dialog
|
||||
v-model:visible="fdlgOpen"
|
||||
modal
|
||||
:draggable="false"
|
||||
header="Cadastrar feriado municipal"
|
||||
:style="{ width: '420px' }"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Cancelar" severity="secondary" outlined @click="fdlgOpen = false" />
|
||||
<Button
|
||||
label="Cadastrar"
|
||||
icon="pi pi-check"
|
||||
:disabled="!fformValid || (fform.data && fform.nome && isDuplicata(dateToISO(fform.data), fform.nome))"
|
||||
:loading="fsaving"
|
||||
@click="salvarFeriado"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- ══ Dialog bloqueio add/edit ══════════════════════════════ -->
|
||||
<Dialog
|
||||
v-model:visible="dlgOpen"
|
||||
modal
|
||||
:draggable="false"
|
||||
:header="dlgMode === 'edit' ? 'Editar bloqueio' : 'Novo bloqueio'"
|
||||
:style="{ width: '480px' }"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<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…" />
|
||||
</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"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── Grupos ──────────────────────────────────────────────── */
|
||||
.blk-group {
|
||||
border-radius: 1.25rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
.blk-group__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.875rem 1.25rem;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.blk-group__count {
|
||||
font-size: 0.75rem;
|
||||
background: var(--surface-ground);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 999px;
|
||||
padding: 1px 8px;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
/* ── Itens ───────────────────────────────────────────────── */
|
||||
.blk-empty {
|
||||
padding: 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
.blk-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.blk-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.blk-item:last-child { border-bottom: none; }
|
||||
.blk-item:hover { background: var(--surface-hover); }
|
||||
|
||||
.blk-item__date {
|
||||
font-size: 0.8rem;
|
||||
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__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;
|
||||
}
|
||||
|
||||
/* ── Dialog ──────────────────────────────────────────────── */
|
||||
.blk-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-color-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -34,7 +34,6 @@ const cfg = ref({
|
||||
agenda_custom_end: null,
|
||||
session_duration_min: 40,
|
||||
session_break_min: 10,
|
||||
session_start_offset_min: 0,
|
||||
pausas_semanais: [],
|
||||
online_ativo: false,
|
||||
setup_clinica_concluido: false,
|
||||
@@ -136,6 +135,17 @@ function dateForDayOfWeek (dayValue) {
|
||||
d.setDate(d.getDate() + delta)
|
||||
return d
|
||||
}
|
||||
function floorTo30 (hhmm) {
|
||||
const [h, m] = String(hhmm || '00:00').slice(0, 5).split(':').map(Number)
|
||||
return String(h).padStart(2,'0') + ':' + (m < 30 ? '00' : '30')
|
||||
}
|
||||
function ceilTo30 (hhmm) {
|
||||
const [h, m] = String(hhmm || '00:00').slice(0, 5).split(':').map(Number)
|
||||
if (m === 0 || m === 30) return String(h).padStart(2,'0') + ':' + String(m).padStart(2,'0')
|
||||
if (m < 30) return String(h).padStart(2,'0') + ':30'
|
||||
return String(h + 1).padStart(2,'0') + ':00'
|
||||
}
|
||||
|
||||
function toLocalIsoAt (dateBase, hhmm) {
|
||||
const [h, m] = String(hhmm).split(':').map(Number)
|
||||
const d = new Date(dateBase); d.setHours(h, m, 0, 0)
|
||||
@@ -159,8 +169,7 @@ const jornadaOk = computed(() => selectedDays.value.length > 0 && isValidHHMM(jo
|
||||
const resumoRitmo = computed(() => {
|
||||
const d = cfg.value.session_duration_min || 50
|
||||
const i = cfg.value.session_break_min || 0
|
||||
const off = cfg.value.session_start_offset_min || 0
|
||||
return `${d} min sessão · ${i > 0 ? `${i} min intervalo` : 'sem intervalo'} · início :${String(off).padStart(2,'0')}`
|
||||
return `${d} min sessão · ${i > 0 ? `${i} min intervalo` : 'sem intervalo'}`
|
||||
})
|
||||
|
||||
const resumoOnline = computed(() => {
|
||||
@@ -175,6 +184,14 @@ const resumoOnline = computed(() => {
|
||||
return `Ativo · ${total} slot${total !== 1 ? 's' : ''} configurado${total !== 1 ? 's' : ''} em ${dias} dia${dias !== 1 ? 's' : ''}`
|
||||
})
|
||||
|
||||
// Dias que têm slots no banco mas não estão mais na jornada (órfãos)
|
||||
const orphanSlotDays = computed(() => {
|
||||
const active = new Set(selectedDays.value.map(d => d.value))
|
||||
return diasSemana
|
||||
.filter(d => !active.has(d.value) && (onlineSlotsByDay.value[d.value]?.size || 0) > 0)
|
||||
.map(d => d.short)
|
||||
})
|
||||
|
||||
// ══ SYNC / HYDRATE ════════════════════════════════════════════
|
||||
watch([selectedDays, jornadaStart, jornadaEnd], () => {
|
||||
if (!isValidHHMM(jornadaStart.value) || !isValidHHMM(jornadaEnd.value)) return
|
||||
@@ -189,6 +206,32 @@ function getPausasForDay (dayValue) {
|
||||
return pausasPorDia.value[dayValue] || []
|
||||
}
|
||||
|
||||
// ── Toggle igual/diferente ─────────────────────────────────────
|
||||
function switchToIgual () {
|
||||
// Copia global para todos os dias (zera divergências por dia)
|
||||
if (isValidHHMM(jornadaStart.value) && isValidHHMM(jornadaEnd.value)) {
|
||||
selectedDays.value.forEach(d => {
|
||||
jornadaPorDia.value[d.value] = { inicio: jornadaStart.value, fim: jornadaEnd.value }
|
||||
})
|
||||
}
|
||||
// Copia pausas globais para todos os dias e usa apenas pausasGlobais
|
||||
selectedDays.value.forEach(d => { pausasPorDia.value[d.value] = [] })
|
||||
jornadaIgualTodos.value = true
|
||||
}
|
||||
|
||||
function switchToDiferente () {
|
||||
// Inicializa cada dia com o horário global atual e as pausas globais
|
||||
if (isValidHHMM(jornadaStart.value) && isValidHHMM(jornadaEnd.value)) {
|
||||
selectedDays.value.forEach(d => {
|
||||
jornadaPorDia.value[d.value] = { inicio: jornadaStart.value, fim: jornadaEnd.value }
|
||||
})
|
||||
}
|
||||
selectedDays.value.forEach(d => {
|
||||
pausasPorDia.value[d.value] = pausasGlobais.value.map(p => ({ ...p, id: newId() }))
|
||||
})
|
||||
jornadaIgualTodos.value = false
|
||||
}
|
||||
|
||||
function hydratePausasFromCfg () {
|
||||
const byDay = { 1: [], 2: [], 3: [], 4: [], 5: [], 6: [], 0: [] }
|
||||
for (const p of cfg.value.pausas_semanais || []) {
|
||||
@@ -401,6 +444,19 @@ async function saveJornada () {
|
||||
if (insErr) throw insErr
|
||||
}
|
||||
|
||||
// Limpar slots online de dias removidos da jornada
|
||||
const activeDays = new Set(selectedDays.value.map(d => d.value))
|
||||
const orphanDays = [0,1,2,3,4,5,6].filter(d => !activeDays.has(d))
|
||||
if (orphanDays.length) {
|
||||
const { error: orphanErr } = await supabase
|
||||
.from('agenda_online_slots')
|
||||
.delete()
|
||||
.eq('owner_id', uid)
|
||||
.in('weekday', orphanDays)
|
||||
if (orphanErr) console.warn('[CFG] limpeza órfãos:', orphanErr)
|
||||
else for (const d of orphanDays) _setDay(d, new Set())
|
||||
}
|
||||
|
||||
cfg.value.setup_clinica_concluido = true
|
||||
cfg.value.jornada_igual_todos = igualTodos
|
||||
toast.add({ severity: 'success', summary: 'Jornada salva', detail: 'Horários de trabalho atualizados.', life: 1800 })
|
||||
@@ -414,11 +470,9 @@ async function saveJornada () {
|
||||
async function saveRitmo () {
|
||||
const dur = Number(cfg.value.session_duration_min || 0)
|
||||
const gap = Number(cfg.value.session_break_min || 0)
|
||||
const off = Number(cfg.value.session_start_offset_min ?? 0)
|
||||
|
||||
if (dur < 10 || dur > 240) { toast.add({ severity: 'warn', summary: 'Ritmo', detail: 'Duração deve ser entre 10 e 240 min.', life: 3500 }); return }
|
||||
if (gap < 0 || gap > 60) { toast.add({ severity: 'warn', summary: 'Ritmo', detail: 'Intervalo deve ser entre 0 e 60 min.', life: 3500 }); return }
|
||||
if (![0,15,30,45].includes(off)) { toast.add({ severity: 'warn', summary: 'Ritmo', detail: 'Início deve ser :00, :15, :30 ou :45.', life: 3500 }); return }
|
||||
|
||||
savingCard.value = 'ritmo'
|
||||
try {
|
||||
@@ -427,9 +481,8 @@ async function saveRitmo () {
|
||||
|
||||
const { error } = await supabase.from('agenda_configuracoes').upsert({
|
||||
owner_id: uid,
|
||||
session_duration_min: dur,
|
||||
session_break_min: gap,
|
||||
session_start_offset_min: off
|
||||
session_duration_min: dur,
|
||||
session_break_min: gap,
|
||||
}, { onConflict: 'owner_id' })
|
||||
if (error) throw error
|
||||
|
||||
@@ -498,7 +551,6 @@ function generateSlotsForDay (dayValue) {
|
||||
|
||||
const duration = Number(cfg.value.session_duration_min || 50)
|
||||
const gap = Number(cfg.value.session_break_min || 10)
|
||||
const offset = Number(cfg.value.session_start_offset_min || 0)
|
||||
const cycle = Math.max(1, duration + gap)
|
||||
const out = []
|
||||
|
||||
@@ -506,14 +558,12 @@ function generateSlotsForDay (dayValue) {
|
||||
const wStart = hhmmToMin(w.start)
|
||||
const wEnd = hhmmToMin(w.end)
|
||||
let t = wStart
|
||||
const rem = t % 60
|
||||
if (rem !== offset) t = t + ((offset - rem + 60) % 60)
|
||||
|
||||
while (t < wEnd) {
|
||||
const aEnd = t + duration
|
||||
if (aEnd > wEnd) break
|
||||
const conflict = breaks.find(b => { const bS = hhmmToMin(b.start), bE = hhmmToMin(b.end); return !(aEnd <= bS || t >= bE) })
|
||||
if (conflict) { t = hhmmToMin(conflict.end); const r2 = t % 60; if (r2 !== offset) t += (offset - r2 + 60) % 60; continue }
|
||||
if (conflict) { t = hhmmToMin(conflict.end); continue }
|
||||
out.push({ hhmm: minToHHMM(t), endHHMM: minToHHMM(aEnd) })
|
||||
t += cycle
|
||||
}
|
||||
@@ -624,15 +674,23 @@ const previewFcEvents = computed(() => {
|
||||
})
|
||||
|
||||
const previewBounds = computed(() => {
|
||||
const active = liveRegras.value.filter(r => r.ativo)
|
||||
const day = previewDay.value
|
||||
const active = liveRegras.value.filter(r => r.ativo && (day == null || r.dia_semana === day))
|
||||
if (!active.length) return { start: '06:00', end: '22:00' }
|
||||
const start = active.reduce((acc, r) => r.hora_inicio < acc ? r.hora_inicio : acc, active[0].hora_inicio)
|
||||
const end = active.reduce((acc, r) => r.hora_fim > acc ? r.hora_fim : acc, active[0].hora_fim)
|
||||
const padded_start = minToHHMM(Math.max(0, hhmmToMin(String(start).slice(0,5)) - 60))
|
||||
const padded_end = minToHHMM(Math.min(24*60, hhmmToMin(String(end).slice(0,5)) + 60))
|
||||
return { start: padded_start, end: padded_end }
|
||||
return { start: floorTo30(String(start).slice(0,5)), end: ceilTo30(String(end).slice(0,5)) }
|
||||
})
|
||||
|
||||
function previewSlotLabelContent (arg) {
|
||||
const min = arg.date.getMinutes()
|
||||
if (min === 0) {
|
||||
const h = String(arg.date.getHours()).padStart(2, '0')
|
||||
return { html: `<span class="fc-slot-label-hour">${h}:00</span>` }
|
||||
}
|
||||
return { html: '<span class="fc-slot-label-half">:30</span>' }
|
||||
}
|
||||
|
||||
const previewFcOptions = computed(() => {
|
||||
const day = previewDay.value
|
||||
const base = day != null ? dateForDayOfWeek(day) : new Date()
|
||||
@@ -646,8 +704,10 @@ const previewFcOptions = computed(() => {
|
||||
allDaySlot: false,
|
||||
slotMinTime: previewBounds.value.start + ':00',
|
||||
slotMaxTime: previewBounds.value.end + ':00',
|
||||
slotDuration: '00:30:00',
|
||||
slotLabelInterval: '01:00',
|
||||
slotDuration: '00:15:00',
|
||||
snapDuration: '00:15:00',
|
||||
slotLabelInterval: '00:30',
|
||||
slotLabelContent: previewSlotLabelContent,
|
||||
expandRows: true,
|
||||
height: 'auto',
|
||||
editable: false,
|
||||
@@ -662,6 +722,7 @@ watch(previewFcEvents, async () => { await nextTick(); fcRef.value?.getApi?.()?.
|
||||
|
||||
// presets de duração
|
||||
const durationPresets = [
|
||||
{ label: '30 min', dur: 30, gap: 0, off: 0 },
|
||||
{ label: '45 min', dur: 45, gap: 15, off: 0 },
|
||||
{ label: '50 min', dur: 50, gap: 10, off: 0 },
|
||||
{ label: '60 min', dur: 60, gap: 0, off: 0 },
|
||||
@@ -736,6 +797,7 @@ const jornadaEndDate = computed({
|
||||
<div v-show="expandedCard === 'jornada'" class="cfg-card__body">
|
||||
<div class="border-t border-[var(--surface-border)] pt-4">
|
||||
|
||||
<!-- Início das sessões (alinhamento de horário) -->
|
||||
<!-- Dias da semana -->
|
||||
<div class="mb-5">
|
||||
<div class="cfg-label mb-2">Quais dias você trabalha?</div>
|
||||
@@ -761,14 +823,14 @@ const jornadaEndDate = computed({
|
||||
<button
|
||||
class="toggle-opt"
|
||||
:class="jornadaIgualTodos !== false ? 'toggle-opt--active' : ''"
|
||||
@click="jornadaIgualTodos = true"
|
||||
@click="switchToIgual"
|
||||
>
|
||||
Igual para todos os dias
|
||||
</button>
|
||||
<button
|
||||
class="toggle-opt"
|
||||
:class="jornadaIgualTodos === false ? 'toggle-opt--active' : ''"
|
||||
@click="jornadaIgualTodos = false"
|
||||
@click="switchToDiferente"
|
||||
>
|
||||
Diferente por dia
|
||||
</button>
|
||||
@@ -779,7 +841,7 @@ const jornadaEndDate = computed({
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-[var(--text-color-secondary)]">Das</span>
|
||||
<div class="w-32">
|
||||
<DatePicker v-model="jornadaStartDate" showIcon fluid iconDisplay="input" timeOnly hourFormat="24">
|
||||
<DatePicker v-model="jornadaStartDate" showIcon fluid iconDisplay="input" timeOnly hourFormat="24" :stepMinute="15" :manualInput="false">
|
||||
<template #inputicon="slotProps">
|
||||
<i class="pi pi-clock" @click="slotProps.clickCallback" />
|
||||
</template>
|
||||
@@ -787,7 +849,7 @@ const jornadaEndDate = computed({
|
||||
</div>
|
||||
<span class="text-sm text-[var(--text-color-secondary)]">até</span>
|
||||
<div class="w-32">
|
||||
<DatePicker v-model="jornadaEndDate" showIcon fluid iconDisplay="input" timeOnly hourFormat="24">
|
||||
<DatePicker v-model="jornadaEndDate" showIcon fluid iconDisplay="input" timeOnly hourFormat="24" :stepMinute="15" :manualInput="false">
|
||||
<template #inputicon="slotProps">
|
||||
<i class="pi pi-clock" @click="slotProps.clickCallback" />
|
||||
</template>
|
||||
@@ -808,7 +870,7 @@ const jornadaEndDate = computed({
|
||||
<DatePicker
|
||||
:modelValue="hhmmToDate(jornadaPorDia[d.value]?.inicio)"
|
||||
@update:modelValue="v => { const h = dateToHHMM(v); if (h) { jornadaPorDia[d.value] = { ...jornadaPorDia[d.value], inicio: h }; previewDay = d.value } }"
|
||||
showIcon fluid iconDisplay="input" timeOnly hourFormat="24"
|
||||
showIcon fluid iconDisplay="input" timeOnly hourFormat="24" :stepMinute="15" :manualInput="false"
|
||||
>
|
||||
<template #inputicon="slotProps">
|
||||
<i class="pi pi-clock" @click="slotProps.clickCallback" />
|
||||
@@ -820,7 +882,7 @@ const jornadaEndDate = computed({
|
||||
<DatePicker
|
||||
:modelValue="hhmmToDate(jornadaPorDia[d.value]?.fim)"
|
||||
@update:modelValue="v => { const h = dateToHHMM(v); if (h) { jornadaPorDia[d.value] = { ...jornadaPorDia[d.value], fim: h }; previewDay = d.value } }"
|
||||
showIcon fluid iconDisplay="input" timeOnly hourFormat="24"
|
||||
showIcon fluid iconDisplay="input" timeOnly hourFormat="24" :stepMinute="15" :manualInput="false"
|
||||
>
|
||||
<template #inputicon="slotProps">
|
||||
<i class="pi pi-clock" @click="slotProps.clickCallback" />
|
||||
@@ -895,7 +957,8 @@ const jornadaEndDate = computed({
|
||||
@click="applyDurationPreset(p)"
|
||||
>
|
||||
{{ p.label }}
|
||||
<span v-if="p.gap" class="text-xs opacity-70 ml-1">· {{ p.gap }}min intervalo</span>
|
||||
<span v-if="p.gap > 0" class="text-xs opacity-70 ml-1">· {{ p.gap }}min intervalo</span>
|
||||
<span v-else class="text-xs opacity-70 ml-1">· sem pausa</span>
|
||||
</button>
|
||||
<button
|
||||
class="preset-chip"
|
||||
@@ -913,7 +976,7 @@ const jornadaEndDate = computed({
|
||||
<div class="flex flex-row gap-6">
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">Duração</label>
|
||||
<DatePicker v-model="durationDate" showIcon fluid iconDisplay="input" timeOnly hourFormat="24">
|
||||
<DatePicker v-model="durationDate" showIcon fluid iconDisplay="input" timeOnly hourFormat="24" :stepMinute="5" :manualInput="false">
|
||||
<template #inputicon="slotProps">
|
||||
<i class="pi pi-clock" @click="slotProps.clickCallback" />
|
||||
</template>
|
||||
@@ -921,7 +984,7 @@ const jornadaEndDate = computed({
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">Intervalo</label>
|
||||
<DatePicker v-model="breakDate" showIcon fluid iconDisplay="input" timeOnly hourFormat="24">
|
||||
<DatePicker v-model="breakDate" showIcon fluid iconDisplay="input" timeOnly hourFormat="24" :stepMinute="5" :manualInput="false">
|
||||
<template #inputicon="slotProps">
|
||||
<i class="pi pi-clock" @click="slotProps.clickCallback" />
|
||||
</template>
|
||||
@@ -930,25 +993,6 @@ const jornadaEndDate = computed({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alinhamento de início (sempre visível) -->
|
||||
<div class="mb-5">
|
||||
<div class="cfg-label mb-2">Início das sessões na jornada de trabalho</div>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<button
|
||||
v-for="off in [0, 15, 30, 45]"
|
||||
:key="off"
|
||||
class="preset-chip"
|
||||
:class="cfg.session_start_offset_min === off ? 'preset-chip--active' : ''"
|
||||
@click="cfg.session_start_offset_min = off"
|
||||
>
|
||||
:{{ String(off).padStart(2,'0') }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-[var(--text-color-secondary)] mt-2">
|
||||
Ex.: :00 = sessões em 08:00, 09:00… · :30 = em 08:30, 09:30…
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
label="Salvar ritmo"
|
||||
@@ -987,6 +1031,15 @@ const jornadaEndDate = computed({
|
||||
<div v-show="expandedCard === 'online'" class="cfg-card__body">
|
||||
<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)]">
|
||||
<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.
|
||||
Eles serão removidos automaticamente ao salvar a jornada.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Toggle ativo -->
|
||||
<div class="flex items-center justify-between mb-5 p-4 rounded-2xl bg-[var(--surface-ground)]">
|
||||
<div>
|
||||
@@ -1131,7 +1184,7 @@ const jornadaEndDate = computed({
|
||||
|
||||
<!-- FullCalendar -->
|
||||
<div v-if="previewDay != null && !loading" class="p-2">
|
||||
<FullCalendar ref="fcRef" :key="`preview-${previewDay}`" :options="previewFcOptions" />
|
||||
<FullCalendar ref="fcRef" :key="`preview-${previewDay}-${previewBounds.start}-${previewBounds.end}`" :options="previewFcOptions" />
|
||||
</div>
|
||||
<div v-else class="flex items-center justify-center py-16 text-[var(--text-color-secondary)] text-sm">
|
||||
Selecione um dia de trabalho para ver o preview.
|
||||
@@ -1142,6 +1195,26 @@ const jornadaEndDate = computed({
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.fc-slot-label-hour {
|
||||
display: inline-block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-color);
|
||||
letter-spacing: -0.01em;
|
||||
line-height: 1;
|
||||
}
|
||||
.fc-slot-label-half {
|
||||
display: inline-block;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 400;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.5;
|
||||
line-height: 1;
|
||||
padding-left: 2px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
/* ── Cards ─────────────────────────────────────────────────── */
|
||||
.cfg-card {
|
||||
|
||||
1316
src/layout/configuracoes/ConfiguracoesAgendadorPage.vue
Normal file
1316
src/layout/configuracoes/ConfiguracoesAgendadorPage.vue
Normal file
File diff suppressed because it is too large
Load Diff
553
src/layout/configuracoes/ConfiguracoesPagamentoPage.vue
Normal file
553
src/layout/configuracoes/ConfiguracoesPagamentoPage.vue
Normal file
@@ -0,0 +1,553 @@
|
||||
<!-- src/layout/configuracoes/ConfiguracoesPagamentoPage.vue -->
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
const toast = useToast()
|
||||
const tenantStore = useTenantStore()
|
||||
|
||||
const loading = ref(true)
|
||||
const ownerId = ref(null)
|
||||
const expandedCard = ref(null)
|
||||
const savingCard = ref(null)
|
||||
|
||||
// ── Defaults ────────────────────────────────────────────────────
|
||||
const DEFAULT = {
|
||||
pix_ativo: false,
|
||||
pix_tipo: 'cpf',
|
||||
pix_chave: '',
|
||||
pix_nome_titular: '',
|
||||
|
||||
deposito_ativo: false,
|
||||
deposito_banco: '',
|
||||
deposito_agencia: '',
|
||||
deposito_conta: '',
|
||||
deposito_tipo_conta: 'corrente',
|
||||
deposito_titular: '',
|
||||
deposito_cpf_cnpj: '',
|
||||
|
||||
dinheiro_ativo: false,
|
||||
|
||||
cartao_ativo: false,
|
||||
cartao_instrucao: '',
|
||||
|
||||
convenio_ativo: false,
|
||||
convenio_lista: '',
|
||||
|
||||
observacoes_pagamento: '',
|
||||
}
|
||||
|
||||
const cfg = ref({ ...DEFAULT })
|
||||
|
||||
// ── Opções ───────────────────────────────────────────────────────
|
||||
const pixTipoOptions = [
|
||||
{ label: 'CPF', value: 'cpf' },
|
||||
{ label: 'CNPJ', value: 'cnpj' },
|
||||
{ label: 'E-mail', value: 'email' },
|
||||
{ label: 'Celular', value: 'celular' },
|
||||
{ label: 'Chave aleatória', value: 'aleatoria' },
|
||||
]
|
||||
|
||||
const pixTipoLabel = {
|
||||
cpf: 'CPF (somente números)',
|
||||
cnpj: 'CNPJ (somente números)',
|
||||
email: 'E-mail',
|
||||
celular: 'Celular (+55DDD9XXXXXXXX)',
|
||||
aleatoria: 'Chave aleatória (UUID)',
|
||||
}
|
||||
|
||||
const tipoConta = [
|
||||
{ label: 'Conta-corrente', value: 'corrente' },
|
||||
{ label: 'Poupança', value: 'poupanca' },
|
||||
]
|
||||
|
||||
// Principais bancos BR
|
||||
const bancos = [
|
||||
{ label: 'Banco do Brasil', value: '001' },
|
||||
{ label: 'Bradesco', value: '237' },
|
||||
{ label: 'Caixa Econômica', value: '104' },
|
||||
{ label: 'Itaú', value: '341' },
|
||||
{ label: 'Nubank', value: '260' },
|
||||
{ label: 'Santander', value: '033' },
|
||||
{ label: 'Sicoob', value: '756' },
|
||||
{ label: 'Sicredi', value: '748' },
|
||||
{ label: 'Inter', value: '077' },
|
||||
{ label: 'C6 Bank', value: '336' },
|
||||
{ label: 'PicPay', value: '380' },
|
||||
{ label: 'Mercado Pago', value: '323' },
|
||||
{ label: 'PagBank', value: '290' },
|
||||
{ label: 'Neon', value: '655' },
|
||||
{ label: 'Next', value: '237' },
|
||||
{ label: 'Outro', value: 'outro' },
|
||||
]
|
||||
|
||||
// ── Toggle cards ─────────────────────────────────────────────────
|
||||
function toggleCard (key) {
|
||||
expandedCard.value = expandedCard.value === key ? null : key
|
||||
}
|
||||
|
||||
// ── Load ─────────────────────────────────────────────────────────
|
||||
async function load () {
|
||||
loading.value = true
|
||||
try {
|
||||
const uid = tenantStore.user?.id || null
|
||||
if (!uid) return
|
||||
ownerId.value = uid
|
||||
|
||||
const { data } = await supabase
|
||||
.from('payment_settings')
|
||||
.select('*')
|
||||
.eq('owner_id', uid)
|
||||
.maybeSingle()
|
||||
|
||||
if (data) {
|
||||
cfg.value = { ...DEFAULT, ...data }
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Save card ────────────────────────────────────────────────────
|
||||
async function saveCard (cardKey) {
|
||||
if (!ownerId.value) return
|
||||
savingCard.value = cardKey
|
||||
|
||||
const payload = {
|
||||
owner_id: ownerId.value,
|
||||
tenant_id: tenantStore.activeTenantId || null,
|
||||
}
|
||||
|
||||
if (cardKey === 'pix') {
|
||||
Object.assign(payload, {
|
||||
pix_ativo: cfg.value.pix_ativo,
|
||||
pix_tipo: cfg.value.pix_tipo,
|
||||
pix_chave: cfg.value.pix_chave?.trim() ?? '',
|
||||
pix_nome_titular: cfg.value.pix_nome_titular?.trim() ?? '',
|
||||
})
|
||||
} else if (cardKey === 'deposito') {
|
||||
Object.assign(payload, {
|
||||
deposito_ativo: cfg.value.deposito_ativo,
|
||||
deposito_banco: cfg.value.deposito_banco,
|
||||
deposito_agencia: cfg.value.deposito_agencia?.trim() ?? '',
|
||||
deposito_conta: cfg.value.deposito_conta?.trim() ?? '',
|
||||
deposito_tipo_conta: cfg.value.deposito_tipo_conta,
|
||||
deposito_titular: cfg.value.deposito_titular?.trim() ?? '',
|
||||
deposito_cpf_cnpj: cfg.value.deposito_cpf_cnpj?.trim() ?? '',
|
||||
})
|
||||
} else if (cardKey === 'dinheiro') {
|
||||
payload.dinheiro_ativo = cfg.value.dinheiro_ativo
|
||||
} else if (cardKey === 'cartao') {
|
||||
Object.assign(payload, {
|
||||
cartao_ativo: cfg.value.cartao_ativo,
|
||||
cartao_instrucao: cfg.value.cartao_instrucao?.trim() ?? '',
|
||||
})
|
||||
} else if (cardKey === 'convenio') {
|
||||
Object.assign(payload, {
|
||||
convenio_ativo: cfg.value.convenio_ativo,
|
||||
convenio_lista: cfg.value.convenio_lista?.trim() ?? '',
|
||||
})
|
||||
} else if (cardKey === 'observacoes') {
|
||||
payload.observacoes_pagamento = cfg.value.observacoes_pagamento?.trim() ?? ''
|
||||
}
|
||||
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('payment_settings')
|
||||
.upsert(payload, { onConflict: 'owner_id' })
|
||||
|
||||
if (error) throw error
|
||||
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Configurações de pagamento atualizadas.', life: 2500 })
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e?.message, life: 4000 })
|
||||
} finally {
|
||||
savingCard.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Toast />
|
||||
|
||||
<div v-if="loading" class="flex items-center gap-2 p-6 text-slate-500">
|
||||
<i class="pi pi-spin pi-spinner" /> Carregando…
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
|
||||
<!-- Pix ──────────────────────────────────────────────────── -->
|
||||
<div class="rounded-2xl 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')"
|
||||
>
|
||||
<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'">
|
||||
<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">
|
||||
{{ cfg.pix_ativo && cfg.pix_chave ? `Chave: ${cfg.pix_chave}` : 'Pagamento instantâneo via chave Pix' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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'" />
|
||||
</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 class="flex items-center justify-between">
|
||||
<span class="font-medium text-slate-700">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>
|
||||
<Select
|
||||
v-model="cfg.pix_tipo"
|
||||
:options="pixTipoOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-600 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>
|
||||
<InputText v-model="cfg.pix_nome_titular" class="w-full" placeholder="Nome que aparece na chave" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
label="Salvar"
|
||||
icon="pi pi-check"
|
||||
:loading="savingCard === 'pix'"
|
||||
@click="saveCard('pix')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Depósito bancário ───────────────────────────────────── -->
|
||||
<div class="rounded-2xl 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')"
|
||||
>
|
||||
<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'">
|
||||
<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">
|
||||
{{ cfg.deposito_ativo && cfg.deposito_banco ? `${cfg.deposito_banco} · Ag. ${cfg.deposito_agencia || '—'} · Conta ${cfg.deposito_conta || '—'}` : 'Transferência bancária ou depósito' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div 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'" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div v-if="expandedCard === 'deposito'" class="border-t border-[var(--surface-border)] px-5 py-5 flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium text-slate-700">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>
|
||||
<Select
|
||||
v-model="cfg.deposito_banco"
|
||||
:options="bancos"
|
||||
option-label="label"
|
||||
option-value="label"
|
||||
class="w-full"
|
||||
placeholder="Selecione o banco"
|
||||
filter
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-600 mb-1">Tipo de conta</label>
|
||||
<Select
|
||||
v-model="cfg.deposito_tipo_conta"
|
||||
:options="tipoConta"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-600 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>
|
||||
<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>
|
||||
<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>
|
||||
<InputText v-model="cfg.deposito_cpf_cnpj" class="w-full" placeholder="000.000.000-00" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
label="Salvar"
|
||||
icon="pi pi-check"
|
||||
:loading="savingCard === 'deposito'"
|
||||
@click="saveCard('deposito')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dinheiro ─────────────────────────────────────────────── -->
|
||||
<div class="rounded-2xl 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')"
|
||||
>
|
||||
<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'">
|
||||
<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>
|
||||
</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'" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div v-if="expandedCard === 'dinheiro'" class="border-t border-[var(--surface-border)] px-5 py-5 flex flex-col gap-4">
|
||||
<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">
|
||||
Aceitar pagamento em espécie nas sessões presenciais.
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch v-model="cfg.dinheiro_ativo" />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
label="Salvar"
|
||||
icon="pi pi-check"
|
||||
:loading="savingCard === 'dinheiro'"
|
||||
@click="saveCard('dinheiro')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cartão ───────────────────────────────────────────────── -->
|
||||
<div class="rounded-2xl 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')"
|
||||
>
|
||||
<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'">
|
||||
<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>
|
||||
</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'" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div v-if="expandedCard === 'cartao'" class="border-t border-[var(--surface-border)] px-5 py-5 flex flex-col gap-4">
|
||||
<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">
|
||||
Aceitar cartão de crédito e débito via maquininha nas sessões presenciais.
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch v-model="cfg.cartao_ativo" />
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<InputText
|
||||
v-model="cfg.cartao_instrucao"
|
||||
class="w-full"
|
||||
placeholder="Ex: Aceito todas as bandeiras. Parcelo em até 3x."
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
label="Salvar"
|
||||
icon="pi pi-check"
|
||||
:loading="savingCard === 'cartao'"
|
||||
@click="saveCard('cartao')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plano de Saúde / Convênio ────────────────────────────── -->
|
||||
<div class="rounded-2xl 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')"
|
||||
>
|
||||
<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'">
|
||||
<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">
|
||||
{{ cfg.convenio_ativo && cfg.convenio_lista ? cfg.convenio_lista.slice(0, 60) + (cfg.convenio_lista.length > 60 ? '…' : '') : 'Atendimento por convênio' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div 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'" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div v-if="expandedCard === 'convenio'" class="border-t border-[var(--surface-border)] px-5 py-5 flex flex-col gap-4">
|
||||
<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">
|
||||
Habilite para informar quais convênios são aceitos.
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch v-model="cfg.convenio_ativo" />
|
||||
</div>
|
||||
|
||||
<template v-if="cfg.convenio_ativo">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-600 mb-1">Convênios aceitos</label>
|
||||
<Textarea
|
||||
v-model="cfg.convenio_lista"
|
||||
rows="3"
|
||||
class="w-full"
|
||||
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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
label="Salvar"
|
||||
icon="pi pi-check"
|
||||
:loading="savingCard === 'convenio'"
|
||||
@click="saveCard('convenio')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Observações gerais ───────────────────────────────────── -->
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
|
||||
|
||||
<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')"
|
||||
>
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
<i class="pi text-slate-400" :class="expandedCard === '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">
|
||||
<Textarea
|
||||
v-model="cfg.observacoes_pagamento"
|
||||
rows="4"
|
||||
class="w-full"
|
||||
placeholder="Ex: O pagamento deve ser realizado no dia da sessão. Em caso de cancelamento com menos de 24h, a sessão será cobrada."
|
||||
autoResize
|
||||
/>
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
label="Salvar"
|
||||
icon="pi pi-check"
|
||||
:loading="savingCard === 'observacoes'"
|
||||
@click="saveCard('observacoes')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
332
src/layout/configuracoes/ConfiguracoesPrecificacaoPage.vue
Normal file
332
src/layout/configuracoes/ConfiguracoesPrecificacaoPage.vue
Normal file
@@ -0,0 +1,332 @@
|
||||
<!-- src/layout/configuracoes/ConfiguracoesPrecificacaoPage.vue -->
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
const toast = useToast()
|
||||
const tenantStore = useTenantStore()
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
|
||||
const ownerId = ref(null)
|
||||
const tenantId = ref(null)
|
||||
|
||||
// ── Tipos de compromisso do tenant ─────────────────────────────────
|
||||
const commitments = ref([]) // [{ id, label, native_key }]
|
||||
|
||||
// ── Preços: Map<commitmentId | '__default__', { price, notes }> ────
|
||||
// '__default__' = linha com determined_commitment_id IS NULL
|
||||
const prices = ref({}) // { [key]: { price: number|null, notes: '' } }
|
||||
|
||||
// ── Carregar commitments do tenant ─────────────────────────────────
|
||||
async function loadCommitments () {
|
||||
if (!tenantId.value) return
|
||||
const { data, error } = await supabase
|
||||
.from('determined_commitments')
|
||||
.select('id, name, native_key, active')
|
||||
.eq('tenant_id', tenantId.value)
|
||||
.eq('active', true)
|
||||
.order('name')
|
||||
|
||||
if (error) throw error
|
||||
|
||||
commitments.value = data || []
|
||||
}
|
||||
|
||||
// ── Carregar preços existentes ──────────────────────────────────────
|
||||
async function loadPrices (uid) {
|
||||
const { data, error } = await supabase
|
||||
.from('professional_pricing')
|
||||
.select('id, determined_commitment_id, price, notes')
|
||||
.eq('owner_id', uid)
|
||||
|
||||
if (error) throw error
|
||||
|
||||
const map = {}
|
||||
for (const row of (data || [])) {
|
||||
const key = row.determined_commitment_id ?? '__default__'
|
||||
map[key] = { price: row.price != null ? Number(row.price) : null, notes: row.notes ?? '' }
|
||||
}
|
||||
prices.value = map
|
||||
}
|
||||
|
||||
// ── Garantir que todos os commitments + default têm entrada no mapa
|
||||
function ensureDefaults () {
|
||||
if (!prices.value['__default__']) {
|
||||
prices.value['__default__'] = { price: null, notes: '' }
|
||||
}
|
||||
for (const c of commitments.value) {
|
||||
if (!prices.value[c.id]) {
|
||||
prices.value[c.id] = { price: null, notes: '' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mount ───────────────────────────────────────────────────────────
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const uid = tenantStore.user?.id || (await supabase.auth.getUser()).data?.user?.id
|
||||
if (!uid) return
|
||||
|
||||
ownerId.value = uid
|
||||
tenantId.value = tenantStore.activeTenantId || null
|
||||
|
||||
await Promise.all([
|
||||
loadCommitments(),
|
||||
loadPrices(uid),
|
||||
])
|
||||
|
||||
ensureDefaults()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar.', life: 4000 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// ── Salvar todos os preços configurados ────────────────────────────
|
||||
async function save () {
|
||||
if (!ownerId.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
const uid = ownerId.value
|
||||
const tid = tenantId.value
|
||||
|
||||
const rows = []
|
||||
|
||||
// Linha padrão (NULL commitment)
|
||||
const def = prices.value['__default__']
|
||||
if (def?.price != null && def.price !== '') {
|
||||
rows.push({
|
||||
owner_id: uid,
|
||||
tenant_id: tid,
|
||||
determined_commitment_id: null,
|
||||
price: Number(def.price),
|
||||
notes: def.notes?.trim() || null,
|
||||
})
|
||||
}
|
||||
|
||||
// Linhas por tipo de compromisso
|
||||
for (const c of commitments.value) {
|
||||
const entry = prices.value[c.id]
|
||||
if (entry?.price != null && entry.price !== '') {
|
||||
rows.push({
|
||||
owner_id: uid,
|
||||
tenant_id: tid,
|
||||
determined_commitment_id: c.id,
|
||||
price: Number(entry.price),
|
||||
notes: entry.notes?.trim() || null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!rows.length) {
|
||||
toast.add({ severity: 'warn', summary: 'Aviso', detail: 'Nenhum preço configurado para salvar.', life: 3000 })
|
||||
return
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('professional_pricing')
|
||||
.upsert(rows, { onConflict: 'owner_id,determined_commitment_id' })
|
||||
|
||||
if (error) throw error
|
||||
|
||||
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Precificação atualizada!', life: 3000 })
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao salvar.', life: 4000 })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function fmtBRL (v) {
|
||||
if (v == null || v === '') return '—'
|
||||
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Toast />
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<!-- Header card -->
|
||||
<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">Precificação</div>
|
||||
<div class="text-600 text-sm">Defina o valor padrão da sessão e valores específicos por tipo de compromisso.</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
label="Salvar preços"
|
||||
icon="pi pi-check"
|
||||
:loading="saving"
|
||||
:disabled="loading"
|
||||
@click="save"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex justify-center py-10">
|
||||
<ProgressSpinner style="width:40px;height:40px" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
|
||||
<!-- Preço padrão -->
|
||||
<Card>
|
||||
<template #content>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-star text-primary-500" />
|
||||
<span class="font-semibold text-900">Preço padrão (fallback)</span>
|
||||
</div>
|
||||
<div class="text-600 text-sm">
|
||||
Aplicado quando o tipo de compromisso da sessão não tem um preço específico cadastrado.
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-12 gap-3 mt-1">
|
||||
<div class="col-span-12 sm:col-span-5">
|
||||
<FloatLabel variant="on">
|
||||
<InputNumber
|
||||
v-model="prices['__default__'].price"
|
||||
inputId="price-default"
|
||||
mode="currency"
|
||||
currency="BRL"
|
||||
locale="pt-BR"
|
||||
:min="0"
|
||||
:max="99999"
|
||||
:minFractionDigits="2"
|
||||
fluid
|
||||
placeholder="R$ 0,00"
|
||||
/>
|
||||
<label for="price-default">Valor da sessão (R$)</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<div class="col-span-12 sm:col-span-7">
|
||||
<FloatLabel variant="on">
|
||||
<InputText
|
||||
v-model="prices['__default__'].notes"
|
||||
inputId="notes-default"
|
||||
class="w-full"
|
||||
placeholder="Ex: Particular, valor padrão"
|
||||
/>
|
||||
<label for="notes-default">Observação (opcional)</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Preços por tipo de compromisso -->
|
||||
<Card v-if="commitments.length">
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-list" />
|
||||
<span>Por tipo de compromisso</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="text-600 text-sm mb-4">
|
||||
Valores específicos sobrepõem o preço padrão quando o tipo de compromisso coincide.
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div
|
||||
v-for="c in commitments"
|
||||
:key="c.id"
|
||||
class="commitment-row"
|
||||
>
|
||||
<div class="commitment-label">
|
||||
<div class="font-medium text-900">{{ c.name }}</div>
|
||||
<div v-if="prices[c.id]?.price != null" class="text-xs text-color-secondary mt-0.5">
|
||||
{{ fmtBRL(prices[c.id]?.price) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-12 gap-3 flex-1">
|
||||
<div class="col-span-12 sm:col-span-5">
|
||||
<FloatLabel variant="on">
|
||||
<InputNumber
|
||||
v-model="prices[c.id].price"
|
||||
:inputId="`price-${c.id}`"
|
||||
mode="currency"
|
||||
currency="BRL"
|
||||
locale="pt-BR"
|
||||
:min="0"
|
||||
:max="99999"
|
||||
:minFractionDigits="2"
|
||||
fluid
|
||||
placeholder="R$ 0,00"
|
||||
/>
|
||||
<label :for="`price-${c.id}`">Valor (R$)</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<div class="col-span-12 sm:col-span-7">
|
||||
<FloatLabel variant="on">
|
||||
<InputText
|
||||
v-model="prices[c.id].notes"
|
||||
:id="`notes-${c.id}`"
|
||||
class="w-full"
|
||||
placeholder="Ex: Convênio, valor reduzido..."
|
||||
/>
|
||||
<label :for="`notes-${c.id}`">Observação (opcional)</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Dica -->
|
||||
<Message severity="info" :closable="false">
|
||||
<span class="text-sm">
|
||||
O preço configurado aqui é preenchido automaticamente ao criar uma sessão na agenda.
|
||||
Você ainda pode ajustá-lo manualmente no diálogo de cada evento.
|
||||
</span>
|
||||
</Message>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
</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;
|
||||
}
|
||||
|
||||
.commitment-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-ground);
|
||||
}
|
||||
|
||||
.commitment-label {
|
||||
min-width: 9rem;
|
||||
flex-shrink: 0;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
@@ -9,12 +9,12 @@ import therapistMenu from './menus/therapist.menu'
|
||||
import supervisorMenu from './menus/supervisor.menu'
|
||||
import editorMenu from './menus/editor.menu'
|
||||
import portalMenu from './menus/portal.menu'
|
||||
import sakaiDemoMenu from './menus/sakai.demo.menu'
|
||||
import saasMenu from './menus/saas.menu'
|
||||
|
||||
import { useSaasHealthStore } from '@/stores/saasHealthStore'
|
||||
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore'
|
||||
import { useEntitlementsStore } from '@/stores/entitlementsStore'
|
||||
import { countAtencao } from '@/composables/useDocsHealth'
|
||||
|
||||
// ======================================================
|
||||
// 🎭 Mapeamento de role → menu base
|
||||
@@ -225,17 +225,13 @@ export function getMenuByRole (role, sessionCtx = {}) {
|
||||
const base = decorateMenu(baseRaw, hasFeature)
|
||||
|
||||
const saasRaw = typeof saasMenu === 'function'
|
||||
? saasMenu(ctx, { mismatchCount })
|
||||
? saasMenu(ctx, { mismatchCount, docsAtencaoCount: countAtencao.value })
|
||||
: saasMenu
|
||||
const saas = decorateMenu(saasRaw, hasFeature)
|
||||
|
||||
// 🔒 SaaS master: somente área SaaS
|
||||
if (isSaas) {
|
||||
const out = [
|
||||
...(saas.length ? saas : coreMenu()),
|
||||
...(import.meta.env.DEV ? [{ separator: true }, ...sakaiDemoMenu] : [])
|
||||
]
|
||||
return out
|
||||
return saas.length ? saas : coreMenu()
|
||||
}
|
||||
|
||||
// ✅ fallback: nunca retorna vazio
|
||||
|
||||
@@ -73,6 +73,13 @@ export default function adminMenu (ctx = {}) {
|
||||
to: { name: 'admin-online-scheduling' },
|
||||
feature: 'online_scheduling.manage',
|
||||
proBadge: true
|
||||
},
|
||||
{
|
||||
label: 'Agendamentos Recebidos',
|
||||
icon: 'pi pi-fw pi-inbox',
|
||||
to: { name: 'admin-agendamentos-recebidos' },
|
||||
feature: 'online_scheduling.manage',
|
||||
proBadge: true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -3,12 +3,17 @@
|
||||
export default function saasMenu (sessionCtx, opts = {}) {
|
||||
if (!sessionCtx?.isSaasAdmin) return []
|
||||
|
||||
const mismatchCount = Number(opts?.mismatchCount || 0)
|
||||
const mismatchCount = Number(opts?.mismatchCount || 0)
|
||||
const docsAtencaoCount = Number(opts?.docsAtencaoCount || 0)
|
||||
|
||||
const mismatchBadge = mismatchCount > 0
|
||||
? { badge: String(mismatchCount), badgeClass: 'p-badge p-badge-danger' }
|
||||
: {}
|
||||
|
||||
const docsBadge = docsAtencaoCount > 0
|
||||
? { badge: String(docsAtencaoCount), badgeClass: 'p-badge p-badge-danger' }
|
||||
: {}
|
||||
|
||||
return [
|
||||
{
|
||||
label: 'SaaS',
|
||||
@@ -47,7 +52,25 @@ export default function saasMenu (sessionCtx, opts = {}) {
|
||||
]
|
||||
},
|
||||
|
||||
{ label: 'Clínicas (Tenants)', icon: 'pi pi-users', to: '/saas/tenants' }
|
||||
{ label: 'Clínicas (Tenants)', icon: 'pi pi-users', to: '/saas/tenants' },
|
||||
{ label: 'Feriados', icon: 'pi pi-star', to: '/saas/feriados' },
|
||||
{ label: 'Suporte Técnico', icon: 'pi pi-headphones', to: '/saas/support' },
|
||||
|
||||
{
|
||||
label: 'Conteúdo',
|
||||
icon: 'pi pi-book',
|
||||
path: '/saas/content',
|
||||
...(docsAtencaoCount > 0 ? { badge: String(docsAtencaoCount), badgeClass: 'p-badge p-badge-danger' } : {}),
|
||||
items: [
|
||||
{
|
||||
label: 'Documentação',
|
||||
icon: 'pi pi-question-circle',
|
||||
to: '/saas/docs',
|
||||
...docsBadge
|
||||
},
|
||||
{ label: 'FAQ', icon: 'pi pi-comments', to: '/saas/faq' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
export default [
|
||||
{
|
||||
label: 'UI Components',
|
||||
path: '/uikit',
|
||||
items: [
|
||||
{ label: 'Form Layout', icon: 'pi pi-fw pi-id-card', to: '/demo/uikit/formlayout' },
|
||||
{ label: 'Input', icon: 'pi pi-fw pi-check-square', to: '/demo/uikit/input' },
|
||||
{ label: 'Button', icon: 'pi pi-fw pi-mobile', to: '/demo/uikit/button', class: 'rotated-icon' },
|
||||
{ label: 'Table', icon: 'pi pi-fw pi-table', to: '/demo/uikit/table' },
|
||||
{ label: 'List', icon: 'pi pi-fw pi-list', to: '/demo/uikit/list' },
|
||||
{ label: 'Tree', icon: 'pi pi-fw pi-share-alt', to: '/demo/uikit/tree' },
|
||||
{ label: 'Panel', icon: 'pi pi-fw pi-tablet', to: '/demo/uikit/panel' },
|
||||
{ label: 'Overlay', icon: 'pi pi-fw pi-clone', to: '/demo/uikit/overlay' },
|
||||
{ label: 'Media', icon: 'pi pi-fw pi-image', to: '/demo/uikit/media' },
|
||||
{ label: 'Menu', icon: 'pi pi-fw pi-bars', to: '/demo/uikit/menu' },
|
||||
{ label: 'Message', icon: 'pi pi-fw pi-comment', to: '/demo/uikit/message' },
|
||||
{ label: 'File', icon: 'pi pi-fw pi-file', to: '/demo/uikit/file' },
|
||||
{ label: 'Chart', icon: 'pi pi-fw pi-chart-bar', to: '/demo/uikit/charts' },
|
||||
{ label: 'Timeline', icon: 'pi pi-fw pi-calendar', to: '/demo/uikit/timeline' },
|
||||
{ label: 'Misc', icon: 'pi pi-fw pi-circle', to: '/demo/uikit/misc' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Prime Blocks',
|
||||
icon: 'pi pi-fw pi-prime',
|
||||
path: '/blocks',
|
||||
items: [
|
||||
{ label: 'All Blocks', icon: 'pi pi-fw pi-globe', url: 'https://blocks.primevue.org/', target: '_blank' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Pages',
|
||||
icon: 'pi pi-fw pi-briefcase',
|
||||
path: '/pages',
|
||||
items: [
|
||||
{ label: 'Landing', icon: 'pi pi-fw pi-globe', to: '/landing' },
|
||||
{
|
||||
label: 'Auth',
|
||||
icon: 'pi pi-fw pi-user',
|
||||
path: '/auth',
|
||||
items: [
|
||||
{ label: 'Login', icon: 'pi pi-fw pi-sign-in', to: '/auth/login' },
|
||||
{ label: 'Error', icon: 'pi pi-fw pi-times-circle', to: '/auth/error' },
|
||||
{ label: 'Access Denied', icon: 'pi pi-fw pi-lock', to: '/auth/access' }
|
||||
]
|
||||
},
|
||||
{ label: 'Not Found', icon: 'pi pi-fw pi-exclamation-circle', to: '/pages/notfound' },
|
||||
{ label: 'Empty', icon: 'pi pi-fw pi-circle-off', to: '/pages/empty' }
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Get Started',
|
||||
path: '/start',
|
||||
items: [
|
||||
{ label: 'Documentation', icon: 'pi pi-fw pi-book', url: 'https://sakai.primevue.org/documentation', target: '_blank' },
|
||||
{ label: 'View Source', icon: 'pi pi-fw pi-github', url: 'https://github.com/primefaces/sakai-vue', target: '_blank' }
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -37,9 +37,21 @@ export default [
|
||||
label: 'Online Scheduling',
|
||||
icon: 'pi pi-fw pi-globe',
|
||||
to: '/therapist/online-scheduling',
|
||||
feature: 'online_scheduling',
|
||||
feature: 'online_scheduling.manage',
|
||||
proBadge: true
|
||||
},
|
||||
{
|
||||
label: 'Agendamentos Recebidos',
|
||||
icon: 'pi pi-fw pi-inbox',
|
||||
to: '/therapist/agendamentos-recebidos',
|
||||
feature: 'online_scheduling.manage',
|
||||
proBadge: true
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 📈 RELATÓRIOS
|
||||
// ======================================================
|
||||
{ label: 'Relatórios', icon: 'pi pi-fw pi-chart-bar', to: '/therapist/relatorios', feature: 'agenda.view' },
|
||||
|
||||
// ======================================================
|
||||
// 👤 ACCOUNT
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useTenantStore } from '@/stores/tenantStore'
|
||||
import { useEntitlementsStore } from '@/stores/entitlementsStore'
|
||||
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore'
|
||||
import { buildUpgradeUrl } from '@/utils/upgradeContext'
|
||||
import { logGuard, logError, logPerf } from '@/support/supportLogger'
|
||||
|
||||
import { useMenuStore } from '@/stores/menuStore'
|
||||
import { getMenuByRole } from '@/navigation'
|
||||
@@ -24,6 +25,10 @@ let sessionUidCache = null
|
||||
let saasAdminCacheUid = null
|
||||
let saasAdminCacheIsAdmin = null
|
||||
|
||||
// cache de globalRole por uid (evita query ao banco em cada navegação)
|
||||
let globalRoleCacheUid = null
|
||||
let globalRoleCache = null
|
||||
|
||||
// -----------------------------------------
|
||||
// Pending invite (Modelo B) — retomada pós-login
|
||||
// -----------------------------------------
|
||||
@@ -94,7 +99,7 @@ function sleep (ms) {
|
||||
async function waitSessionIfRefreshing () {
|
||||
if (!sessionReady.value) {
|
||||
try { await initSession({ initial: true }) } catch (e) {
|
||||
console.warn('[guards] initSession falhou:', e)
|
||||
logGuard('[guards] initSession falhou', { error: e?.message })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +151,7 @@ async function loadEntitlementsSafe (ent, tenantId, force) {
|
||||
} catch (e) {
|
||||
// se quebrou tentando force false (store não suporta), tenta força true uma vez
|
||||
if (!force) {
|
||||
console.warn('[guards] ent.loadForTenant(force:false) falhou, tentando force:true', e)
|
||||
logGuard('[guards] ent.loadForTenant force fallback', { error: e?.message })
|
||||
await ent.loadForTenant(tenantId, { force: true })
|
||||
return
|
||||
}
|
||||
@@ -163,7 +168,7 @@ async function fetchTenantFeaturesSafe (tf, tenantId) {
|
||||
try {
|
||||
await tf.fetchForTenant(tenantId, { force: false })
|
||||
} catch (e) {
|
||||
console.warn('[guards] tf.fetchForTenant(force:false) falhou, tentando force:true', e)
|
||||
logGuard('[guards] tf.fetchForTenant force fallback', { error: e?.message })
|
||||
await tf.fetchForTenant(tenantId, { force: true })
|
||||
}
|
||||
}
|
||||
@@ -234,9 +239,7 @@ async function ensureMenuBuilt ({ uid, tenantId, tenantRole, globalRole }) {
|
||||
}
|
||||
|
||||
// cache com key igual mas menu errado: força rebuild
|
||||
console.warn('[ensureMenuBuilt] key match mas menu incompatível com role, forçando rebuild:', {
|
||||
key, safeRole, firstLabel
|
||||
})
|
||||
logGuard('[ensureMenuBuilt] menu incompatível com role, forçando rebuild', { key, safeRole, firstLabel })
|
||||
menuStore.reset()
|
||||
}
|
||||
|
||||
@@ -267,7 +270,7 @@ async function ensureMenuBuilt ({ uid, tenantId, tenantRole, globalRole }) {
|
||||
const model = getMenuByRole(roleForMenu, ctx) || []
|
||||
menuStore.setMenu(key, model)
|
||||
} catch (e) {
|
||||
console.warn('[guards] ensureMenuBuilt failed:', e)
|
||||
logGuard('[guards] ensureMenuBuilt failed', { error: e?.message })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,7 +280,7 @@ export function applyGuards (router) {
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const tlabel = `[guard] ${to.fullPath}`
|
||||
console.time(tlabel)
|
||||
const _perfEnd = logPerf('router.guard', tlabel)
|
||||
|
||||
try {
|
||||
// ==========================================
|
||||
@@ -285,7 +288,7 @@ export function applyGuards (router) {
|
||||
// (ordem importa: /auth antes de meta.public)
|
||||
// ==========================================
|
||||
if (to.path.startsWith('/auth')) {
|
||||
console.timeEnd(tlabel)
|
||||
_perfEnd()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -293,25 +296,25 @@ export function applyGuards (router) {
|
||||
// ✅ Rotas públicas
|
||||
// ==========================================
|
||||
if (to.meta?.public) {
|
||||
console.timeEnd(tlabel)
|
||||
_perfEnd()
|
||||
return true
|
||||
}
|
||||
|
||||
// se rota não exige auth, libera
|
||||
if (!to.meta?.requiresAuth) {
|
||||
console.timeEnd(tlabel)
|
||||
_perfEnd()
|
||||
return true
|
||||
}
|
||||
|
||||
// não decide nada no meio do refresh do session.js
|
||||
console.timeLog(tlabel, 'waitSessionIfRefreshing')
|
||||
logGuard('waitSessionIfRefreshing')
|
||||
await waitSessionIfRefreshing()
|
||||
|
||||
// precisa estar logado (fonte estável do session.js)
|
||||
const uid = sessionUser.value?.id || null
|
||||
if (!uid) {
|
||||
sessionStorage.setItem('redirect_after_login', to.fullPath)
|
||||
console.timeEnd(tlabel)
|
||||
_perfEnd()
|
||||
return { path: '/auth/login' }
|
||||
}
|
||||
|
||||
@@ -321,21 +324,32 @@ export function applyGuards (router) {
|
||||
to.path.startsWith('/supervisor')
|
||||
|
||||
// ======================================
|
||||
// ✅ IDENTIDADE GLOBAL (1x por navegação)
|
||||
// ✅ IDENTIDADE GLOBAL (cached por uid — sem query a cada navegação)
|
||||
// - se falhar, NÃO nega por engano: volta pro login (seguro)
|
||||
// ======================================
|
||||
const { data: prof, error: profErr } = await supabase
|
||||
.from('profiles')
|
||||
.select('role')
|
||||
.eq('id', uid)
|
||||
.single()
|
||||
let globalRole = null
|
||||
|
||||
const globalRole = !profErr ? prof?.role : null
|
||||
console.timeLog(tlabel, 'profiles.role =', globalRole)
|
||||
if (globalRoleCacheUid === uid && globalRoleCache) {
|
||||
globalRole = globalRoleCache
|
||||
logGuard('profiles.role (cache) =', globalRole)
|
||||
} else {
|
||||
const { data: prof, error: profErr } = await supabase
|
||||
.from('profiles')
|
||||
.select('role')
|
||||
.eq('id', uid)
|
||||
.single()
|
||||
|
||||
globalRole = !profErr ? prof?.role : null
|
||||
if (globalRole) {
|
||||
globalRoleCacheUid = uid
|
||||
globalRoleCache = globalRole
|
||||
}
|
||||
logGuard('profiles.role (db) =', globalRole)
|
||||
}
|
||||
|
||||
if (!globalRole) {
|
||||
sessionStorage.setItem('redirect_after_login', to.fullPath)
|
||||
console.timeEnd(tlabel)
|
||||
_perfEnd()
|
||||
return { path: '/auth/login' }
|
||||
}
|
||||
|
||||
@@ -350,7 +364,7 @@ export function applyGuards (router) {
|
||||
localStorage.removeItem('currentTenantId')
|
||||
} catch (_) {}
|
||||
|
||||
console.timeEnd(tlabel)
|
||||
_perfEnd()
|
||||
return { path: '/portal' }
|
||||
}
|
||||
|
||||
@@ -359,7 +373,7 @@ export function applyGuards (router) {
|
||||
// ======================================
|
||||
if (to.meta?.profileRole) {
|
||||
if (globalRole !== to.meta.profileRole) {
|
||||
console.timeEnd(tlabel)
|
||||
_perfEnd()
|
||||
return { path: '/pages/access' }
|
||||
}
|
||||
|
||||
@@ -371,7 +385,7 @@ export function applyGuards (router) {
|
||||
globalRole
|
||||
})
|
||||
|
||||
console.timeEnd(tlabel)
|
||||
_perfEnd()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -382,7 +396,7 @@ export function applyGuards (router) {
|
||||
// ======================================
|
||||
const isAccountArea = (to.path === '/account' || to.path.startsWith('/account/'))
|
||||
if (isAccountArea) {
|
||||
console.timeEnd(tlabel)
|
||||
_perfEnd()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -410,7 +424,7 @@ export function applyGuards (router) {
|
||||
isUuid(pendingInviteToken) &&
|
||||
!to.path.startsWith('/accept-invite')
|
||||
) {
|
||||
console.timeEnd(tlabel)
|
||||
_perfEnd()
|
||||
return { path: '/accept-invite', query: { token: pendingInviteToken } }
|
||||
}
|
||||
|
||||
@@ -419,6 +433,8 @@ export function applyGuards (router) {
|
||||
sessionUidCache = uid
|
||||
saasAdminCacheUid = null
|
||||
saasAdminCacheIsAdmin = null
|
||||
globalRoleCacheUid = null
|
||||
globalRoleCache = null
|
||||
|
||||
const ent0 = useEntitlementsStore()
|
||||
if (typeof ent0.invalidate === 'function') ent0.invalidate()
|
||||
@@ -436,10 +452,10 @@ export function applyGuards (router) {
|
||||
// ✅ SAAS MASTER: não depende de tenant
|
||||
// ================================
|
||||
if (to.meta?.saasAdmin) {
|
||||
console.timeLog(tlabel, 'isSaasAdmin')
|
||||
logGuard('isSaasAdmin')
|
||||
// usa identidade global primeiro (evita cache fantasma)
|
||||
const ok = (globalRole === 'saas_admin') ? true : await isSaasAdmin(uid)
|
||||
if (!ok) { console.timeEnd(tlabel); return { path: '/pages/access' } }
|
||||
if (!ok) { _perfEnd(); return { path: '/pages/access' } }
|
||||
|
||||
// ✅ monta menu SaaS 1x (AppMenu lê do menuStore)
|
||||
await ensureMenuBuilt({
|
||||
@@ -449,7 +465,7 @@ export function applyGuards (router) {
|
||||
globalRole
|
||||
})
|
||||
|
||||
console.timeEnd(tlabel)
|
||||
_perfEnd()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -472,7 +488,7 @@ export function applyGuards (router) {
|
||||
}
|
||||
|
||||
if (!platformRoles.includes('editor')) {
|
||||
console.timeEnd(tlabel)
|
||||
_perfEnd()
|
||||
return { path: '/pages/access' }
|
||||
}
|
||||
|
||||
@@ -483,7 +499,7 @@ export function applyGuards (router) {
|
||||
globalRole
|
||||
})
|
||||
|
||||
console.timeEnd(tlabel)
|
||||
_perfEnd()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -491,7 +507,7 @@ export function applyGuards (router) {
|
||||
// 🚫 SaaS master: bloqueia tenant-app por padrão
|
||||
// ✅ Mas libera rotas de DEMO em DEV (Sakai)
|
||||
// ================================
|
||||
console.timeLog(tlabel, 'saas.lockdown?')
|
||||
logGuard('saas.lockdown?')
|
||||
|
||||
// 🔥 PATCH: aqui NÃO consulta isSaasAdmin — usa só identidade global
|
||||
const isSaas = (globalRole === 'saas_admin')
|
||||
@@ -515,13 +531,13 @@ export function applyGuards (router) {
|
||||
globalRole
|
||||
})
|
||||
|
||||
console.timeEnd(tlabel)
|
||||
_perfEnd()
|
||||
return true
|
||||
}
|
||||
|
||||
// Fora de /saas (e não-demo), não pode
|
||||
if (!isSaasArea) {
|
||||
console.timeEnd(tlabel)
|
||||
_perfEnd()
|
||||
return { path: '/saas' }
|
||||
}
|
||||
|
||||
@@ -540,7 +556,7 @@ export function applyGuards (router) {
|
||||
|
||||
// carrega tenant + role
|
||||
const tenant = useTenantStore()
|
||||
console.timeLog(tlabel, 'tenant.loadSessionAndTenant?')
|
||||
logGuard('tenant.loadSessionAndTenant?')
|
||||
if (!tenant.loaded && !tenant.loading) {
|
||||
await tenant.loadSessionAndTenant()
|
||||
}
|
||||
@@ -548,7 +564,7 @@ export function applyGuards (router) {
|
||||
// se não tem user no store, trata como não logado
|
||||
if (!tenant.user) {
|
||||
sessionStorage.setItem('redirect_after_login', to.fullPath)
|
||||
console.timeEnd(tlabel)
|
||||
_perfEnd()
|
||||
return { path: '/auth/login' }
|
||||
}
|
||||
|
||||
@@ -576,12 +592,12 @@ export function applyGuards (router) {
|
||||
// 🔥 race/sem vínculo: em área tenant, trate como login necessário (não AccessDenied)
|
||||
if (isTenantArea) {
|
||||
sessionStorage.setItem('redirect_after_login', to.fullPath)
|
||||
console.timeEnd(tlabel)
|
||||
_perfEnd()
|
||||
return { path: '/auth/login' }
|
||||
}
|
||||
|
||||
if (to.path === '/pages/access') { console.timeEnd(tlabel); return true }
|
||||
console.timeEnd(tlabel)
|
||||
if (to.path === '/pages/access') { _perfEnd(); return true }
|
||||
_perfEnd()
|
||||
return { path: '/pages/access' }
|
||||
}
|
||||
|
||||
@@ -596,14 +612,14 @@ export function applyGuards (router) {
|
||||
// 🔥 FIX: se ainda assim não resolveu tenant/role e estamos em tenant area, não negue “por engano”
|
||||
if (isTenantArea && (!tenant.activeTenantId || !tenant.activeRole)) {
|
||||
sessionStorage.setItem('redirect_after_login', to.fullPath)
|
||||
console.timeEnd(tlabel)
|
||||
_perfEnd()
|
||||
return { path: '/auth/login' }
|
||||
}
|
||||
|
||||
let tenantId = tenant.activeTenantId
|
||||
if (!tenantId) {
|
||||
if (to.path === '/pages/access') { console.timeEnd(tlabel); return true }
|
||||
console.timeEnd(tlabel)
|
||||
if (to.path === '/pages/access') { _perfEnd(); return true }
|
||||
_perfEnd()
|
||||
return { path: '/pages/access' }
|
||||
}
|
||||
|
||||
@@ -630,7 +646,7 @@ export function applyGuards (router) {
|
||||
const desiredTenantId = desired?.tenant_id || null
|
||||
|
||||
if (desiredTenantId && tenant.activeTenantId !== desiredTenantId) {
|
||||
console.timeLog(tlabel, `tenantScope.switch(${scope})`)
|
||||
logGuard(`tenantScope.switch(${scope})`)
|
||||
|
||||
// ✅ guarda o tenant antigo para invalidar APENAS ele
|
||||
const oldTenantId = tenant.activeTenantId
|
||||
@@ -661,13 +677,9 @@ export function applyGuards (router) {
|
||||
if (typeof menuStore.reset === 'function') menuStore.reset()
|
||||
} catch {}
|
||||
} else if (!desiredTenantId) {
|
||||
console.warn('[guards] tenantScope sem match:', scope, {
|
||||
memberships: mem.map(x => ({
|
||||
tenant_id: x?.tenant_id,
|
||||
role: x?.role,
|
||||
kind: x?.kind,
|
||||
status: x?.status
|
||||
}))
|
||||
logGuard('[guards] tenantScope sem match', {
|
||||
scope,
|
||||
memberships: mem.map(x => ({ tenant_id: x?.tenant_id, role: x?.role, kind: x?.kind, status: x?.status }))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -682,7 +694,7 @@ export function applyGuards (router) {
|
||||
// entitlements (✅ carrega só quando precisa)
|
||||
const ent = useEntitlementsStore()
|
||||
if (shouldLoadEntitlements(ent, tenantId)) {
|
||||
console.timeLog(tlabel, 'ent.loadForTenant')
|
||||
logGuard('ent.loadForTenant')
|
||||
await loadEntitlementsSafe(ent, tenantId, true)
|
||||
}
|
||||
|
||||
@@ -691,9 +703,9 @@ export function applyGuards (router) {
|
||||
// user entitlements: therapist e supervisor têm assinatura pessoal (v_user_entitlements)
|
||||
const activeRoleNormForEnt = normalizeRole(tenant.activeRole)
|
||||
if (['therapist', 'supervisor'].includes(activeRoleNormForEnt) && uid && ent.loadedForUser !== uid) {
|
||||
console.timeLog(tlabel, 'ent.loadForUser')
|
||||
logGuard('ent.loadForUser')
|
||||
try { await ent.loadForUser(uid) } catch (e) {
|
||||
console.warn('[guards] ent.loadForUser failed:', e)
|
||||
logGuard('[guards] ent.loadForUser failed', { error: e?.message })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -704,7 +716,7 @@ export function applyGuards (router) {
|
||||
const requiredTenantFeature = to.meta?.tenantFeature
|
||||
if (requiredTenantFeature) {
|
||||
const tf = useTenantFeaturesStore()
|
||||
console.timeLog(tlabel, 'tenantFeatures.fetchForTenant')
|
||||
logGuard('tenantFeatures.fetchForTenant')
|
||||
await fetchTenantFeaturesSafe(tf, tenantId)
|
||||
|
||||
// ✅ IMPORTANTÍSSIMO: passa tenantId
|
||||
@@ -713,9 +725,9 @@ export function applyGuards (router) {
|
||||
: false
|
||||
|
||||
if (!enabled) {
|
||||
if (to.path === '/admin/clinic/features') { console.timeEnd(tlabel); return true }
|
||||
if (to.path === '/admin/clinic/features') { _perfEnd(); return true }
|
||||
|
||||
console.timeEnd(tlabel)
|
||||
_perfEnd()
|
||||
return {
|
||||
path: '/admin/clinic/features',
|
||||
query: { missing: requiredTenantFeature, redirectTo: to.fullPath }
|
||||
@@ -746,7 +758,7 @@ export function applyGuards (router) {
|
||||
if (compatible) {
|
||||
tenant.activeRole = normalizeRole(compatible.role, compatible.kind)
|
||||
} else {
|
||||
console.timeEnd(tlabel)
|
||||
_perfEnd()
|
||||
return denyByRole({ to, currentRole: tenant.activeRole })
|
||||
}
|
||||
}
|
||||
@@ -756,7 +768,7 @@ export function applyGuards (router) {
|
||||
const requiredRole = requiredRoleRaw ? normalizeRole(requiredRoleRaw) : null
|
||||
|
||||
if (requiredRole && normalizeRole(tenant.activeRole) !== requiredRole) {
|
||||
console.timeEnd(tlabel)
|
||||
_perfEnd()
|
||||
return denyByRole({ to, currentRole: tenant.activeRole })
|
||||
}
|
||||
|
||||
@@ -765,7 +777,7 @@ export function applyGuards (router) {
|
||||
// ------------------------------------------------
|
||||
const requiredFeature = to.meta?.feature
|
||||
if (requiredFeature && ent?.can && !ent.can(requiredFeature)) {
|
||||
if (to.name === 'upgrade') { console.timeEnd(tlabel); return true }
|
||||
if (to.name === 'upgrade') { _perfEnd(); return true }
|
||||
|
||||
const url = buildUpgradeUrl({
|
||||
missingKeys: [requiredFeature],
|
||||
@@ -773,7 +785,7 @@ export function applyGuards (router) {
|
||||
role: normalizeRole(tenant.activeRole) // ✅ passa o role para a UpgradePage detectar
|
||||
})
|
||||
|
||||
console.timeEnd(tlabel)
|
||||
_perfEnd()
|
||||
return url
|
||||
}
|
||||
|
||||
@@ -787,10 +799,10 @@ export function applyGuards (router) {
|
||||
globalRole
|
||||
})
|
||||
|
||||
console.timeEnd(tlabel)
|
||||
_perfEnd()
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('[guards] erro no beforeEach:', e)
|
||||
logError('router.guard', 'erro no beforeEach', e)
|
||||
|
||||
if (to.path.startsWith('/auth')) return true
|
||||
if (to.meta?.public) return true
|
||||
@@ -814,6 +826,8 @@ export function applyGuards (router) {
|
||||
sessionUidCache = null
|
||||
saasAdminCacheUid = null
|
||||
saasAdminCacheIsAdmin = null
|
||||
globalRoleCacheUid = null
|
||||
globalRoleCache = null
|
||||
|
||||
// ✅ FIX: limpa o localStorage de tenant na saída
|
||||
// Sem isso, o próximo login restaura o tenant do usuário anterior.
|
||||
@@ -861,6 +875,8 @@ export function applyGuards (router) {
|
||||
sessionUidCache = uid || null
|
||||
saasAdminCacheUid = null
|
||||
saasAdminCacheIsAdmin = null
|
||||
globalRoleCacheUid = null
|
||||
globalRoleCache = null
|
||||
|
||||
try {
|
||||
const tf = useTenantFeaturesStore()
|
||||
|
||||
@@ -5,7 +5,6 @@ import meRoutes from './routes.account';
|
||||
import adminRoutes from './routes.clinic';
|
||||
import authRoutes from './routes.auth';
|
||||
import billingRoutes from './routes.billing';
|
||||
import demoRoutes from './routes.demo';
|
||||
import miscRoutes from './routes.misc';
|
||||
import portalRoutes from './routes.portal';
|
||||
import publicRoutes from './routes.public';
|
||||
@@ -29,7 +28,6 @@ const routes = [
|
||||
...(Array.isArray(supervisorRoutes) ? supervisorRoutes : [supervisorRoutes]),
|
||||
...(Array.isArray(editorRoutes) ? editorRoutes : [editorRoutes]),
|
||||
...(Array.isArray(portalRoutes) ? portalRoutes : [portalRoutes]),
|
||||
...(Array.isArray(demoRoutes) ? demoRoutes : [demoRoutes]),
|
||||
...(Array.isArray(configuracoesRoutes) ? configuracoesRoutes : [configuracoesRoutes]),
|
||||
...(Array.isArray(featuresRoutes) ? featuresRoutes : [featuresRoutes]),
|
||||
|
||||
|
||||
@@ -65,6 +65,14 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
// Recorrências
|
||||
{
|
||||
path: 'agenda/recorrencias',
|
||||
name: 'admin-agenda-recorrencias',
|
||||
component: () => import('@/features/agenda/pages/AgendaRecorrenciasPage.vue'),
|
||||
meta: { feature: 'agenda.view', roles: ['clinic_admin', 'tenant_admin'], mode: 'clinic' }
|
||||
},
|
||||
|
||||
// ✅ NOVO: Compromissos determinísticos (tipos)
|
||||
{
|
||||
path: 'agenda/compromissos',
|
||||
@@ -172,6 +180,18 @@ export default {
|
||||
meta: {
|
||||
feature: 'online_scheduling.manage'
|
||||
}
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 🔒 PRO — Agendamentos Recebidos
|
||||
// ======================================================
|
||||
{
|
||||
path: 'agendamentos-recebidos',
|
||||
name: 'admin-agendamentos-recebidos',
|
||||
component: () => import('@/features/agenda/pages/AgendamentosRecebidosPage.vue'),
|
||||
meta: {
|
||||
feature: 'online_scheduling.manage'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -21,6 +21,26 @@ const configuracoesRoutes = {
|
||||
path: 'agenda',
|
||||
name: 'ConfiguracoesAgenda',
|
||||
component: () => import('@/layout/configuracoes/ConfiguracoesAgendaPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'bloqueios',
|
||||
name: 'ConfiguracoesBloqueios',
|
||||
component: () => import('@/layout/configuracoes/BloqueiosPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'agendador',
|
||||
name: 'ConfiguracoesAgendador',
|
||||
component: () => import('@/layout/configuracoes/ConfiguracoesAgendadorPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'pagamento',
|
||||
name: 'ConfiguracoesPagamento',
|
||||
component: () => import('@/layout/configuracoes/ConfiguracoesPagamentoPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'precificacao',
|
||||
name: 'ConfiguracoesPrecificacao',
|
||||
component: () => import('@/layout/configuracoes/ConfiguracoesPrecificacaoPage.vue')
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import AppLayout from '@/layout/AppLayout.vue'
|
||||
|
||||
export default {
|
||||
// ✅ não use '/' aqui (conflita com HomeCards)
|
||||
path: '/demo',
|
||||
component: AppLayout,
|
||||
|
||||
// ✅ DEMO pertence ao backoffice SaaS (somente DEV)
|
||||
// - assim o guard trata como área SaaS e não cai no tenant-app
|
||||
// - remove dependência de role tenant_admin / tenant ativo
|
||||
meta: { requiresAuth: true, saasAdmin: true },
|
||||
|
||||
children: [
|
||||
{ path: 'uikit/formlayout', name: 'uikit-formlayout', component: () => import('@/views/uikit/FormLayout.vue') },
|
||||
{ path: 'uikit/input', name: 'uikit-input', component: () => import('@/views/uikit/InputDoc.vue') },
|
||||
{ path: 'uikit/button', name: 'uikit-button', component: () => import('@/views/uikit/ButtonDoc.vue') },
|
||||
{ path: 'uikit/table', name: 'uikit-table', component: () => import('@/views/uikit/TableDoc.vue') },
|
||||
{ path: 'uikit/list', name: 'uikit-list', component: () => import('@/views/uikit/ListDoc.vue') },
|
||||
{ path: 'uikit/tree', name: 'uikit-tree', component: () => import('@/views/uikit/TreeDoc.vue') },
|
||||
{ path: 'uikit/panel', name: 'uikit-panel', component: () => import('@/views/uikit/PanelsDoc.vue') },
|
||||
{ path: 'uikit/overlay', name: 'uikit-overlay', component: () => import('@/views/uikit/OverlayDoc.vue') },
|
||||
{ path: 'uikit/media', name: 'uikit-media', component: () => import('@/views/uikit/MediaDoc.vue') },
|
||||
{ path: 'uikit/menu', name: 'uikit-menu', component: () => import('@/views/uikit/MenuDoc.vue') },
|
||||
{ path: 'uikit/message', name: 'uikit-message', component: () => import('@/views/uikit/MessagesDoc.vue') },
|
||||
{ path: 'uikit/file', name: 'uikit-file', component: () => import('@/views/uikit/FileDoc.vue') },
|
||||
{ path: 'uikit/charts', name: 'uikit-charts', component: () => import('@/views/uikit/ChartDoc.vue') },
|
||||
{ path: 'uikit/timeline', name: 'uikit-timeline', component: () => import('@/views/uikit/TimelineDoc.vue') },
|
||||
{ path: 'uikit/misc', name: 'uikit-misc', component: () => import('@/views/uikit/MiscDoc.vue') },
|
||||
{ path: 'utilities', name: 'blocks', component: () => import('@/views/utilities/Blocks.vue') },
|
||||
{ path: 'pages', name: 'start-documentation', component: () => import('@/views/pages/Documentation.vue') },
|
||||
{ path: 'pages/empty', name: 'pages-empty', component: () => import('@/views/pages/Empty.vue') },
|
||||
{ path: 'pages/crud', name: 'pages-crud', component: () => import('@/views/pages/Crud.vue') }
|
||||
]
|
||||
}
|
||||
@@ -28,6 +28,13 @@ export default {
|
||||
name: 'accept-invite',
|
||||
component: () => import('@/views/pages/public/AcceptInvitePage.vue'),
|
||||
meta: { public: true }
|
||||
}
|
||||
},
|
||||
// ✅ agendador online público
|
||||
{
|
||||
path: '/agendar/:slug',
|
||||
name: 'agendador.publico',
|
||||
component: () => import('@/views/pages/public/AgendadorPublicoPage.vue'),
|
||||
meta: { public: true }
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@@ -60,6 +60,27 @@ export default {
|
||||
path: 'tenants',
|
||||
name: 'saas-tenants',
|
||||
component: () => import('@/views/pages/saas/SaasPlaceholder.vue')
|
||||
},
|
||||
{
|
||||
path: 'feriados',
|
||||
name: 'saas-feriados',
|
||||
component: () => import('@/views/pages/saas/SaasFeriadosPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'docs',
|
||||
name: 'saas-docs',
|
||||
component: () => import('@/views/pages/saas/SaasDocsPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'faq',
|
||||
name: 'saas-faq',
|
||||
component: () => import('@/views/pages/saas/SaasFaqPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'support',
|
||||
name: 'saas-support',
|
||||
component: () => import('@/views/pages/saas/SaasSupportPage.vue'),
|
||||
meta: { requiresAuth: true, saasAdmin: true }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -25,6 +25,14 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
// Recorrências
|
||||
{
|
||||
path: 'agenda/recorrencias',
|
||||
name: 'therapist-agenda-recorrencias',
|
||||
component: () => import('@/features/agenda/pages/AgendaRecorrenciasPage.vue'),
|
||||
meta: { feature: 'agenda.view', mode: 'therapist' }
|
||||
},
|
||||
|
||||
// ✅ Compromissos determinísticos
|
||||
{
|
||||
path: 'agenda/compromissos',
|
||||
@@ -103,6 +111,28 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 🔒 PRO — Agendamentos Recebidos
|
||||
// ======================================================
|
||||
{
|
||||
path: 'agendamentos-recebidos',
|
||||
name: 'therapist-agendamentos-recebidos',
|
||||
component: () => import('@/features/agenda/pages/AgendamentosRecebidosPage.vue'),
|
||||
meta: {
|
||||
feature: 'online_scheduling.manage'
|
||||
}
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 📈 RELATÓRIOS
|
||||
// ======================================================
|
||||
{
|
||||
path: 'relatorios',
|
||||
name: 'therapist-relatorios',
|
||||
component: () => import('@/views/pages/therapist/RelatoriosPage.vue'),
|
||||
meta: { feature: 'agenda.view' }
|
||||
},
|
||||
|
||||
// ======================================================
|
||||
// 🔐 SECURITY
|
||||
// ======================================================
|
||||
|
||||
335
src/support/components/SupportDebugBanner.vue
Normal file
335
src/support/components/SupportDebugBanner.vue
Normal file
@@ -0,0 +1,335 @@
|
||||
<template>
|
||||
<!-- Renderiza apenas se modo suporte estiver ativo -->
|
||||
<Teleport to="body">
|
||||
<Transition name="support-slide">
|
||||
<div v-if="store.isActive" class="support-banner">
|
||||
<!-- Barra superior fixa -->
|
||||
<div class="support-banner__bar">
|
||||
<div class="support-banner__bar-left">
|
||||
<span class="support-banner__pulse" />
|
||||
<strong>MODO SUPORTE ATIVO</strong>
|
||||
<span class="support-banner__tenant">tenant: {{ store.tenantId }}</span>
|
||||
</div>
|
||||
<div class="support-banner__bar-right">
|
||||
<button class="support-banner__toggle" @click="panelOpen = !panelOpen">
|
||||
<i :class="panelOpen ? 'pi pi-chevron-up' : 'pi pi-chevron-down'" />
|
||||
{{ panelOpen ? 'Ocultar Logs' : 'Ver Logs' }}
|
||||
<span v-if="store.errorLogs.length" class="support-banner__err-badge">
|
||||
{{ store.errorLogs.length }} erro(s)
|
||||
</span>
|
||||
</button>
|
||||
<button class="support-banner__clear" title="Limpar logs" @click="store.clearLogs()">
|
||||
<i class="pi pi-trash" />
|
||||
</button>
|
||||
<button class="support-banner__close" title="Desativar suporte" @click="store.deactivate()">
|
||||
<i class="pi pi-times" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Painel expansível de logs -->
|
||||
<div v-if="panelOpen" class="support-banner__panel">
|
||||
<!-- Filtros de nível -->
|
||||
<div class="support-banner__filters">
|
||||
<button
|
||||
v-for="lvl in levels"
|
||||
:key="lvl.value"
|
||||
class="support-banner__filter-btn"
|
||||
:class="{ 'support-banner__filter-btn--active': activeLevel === lvl.value }"
|
||||
@click="activeLevel = activeLevel === lvl.value ? null : lvl.value"
|
||||
>
|
||||
{{ lvl.label }}
|
||||
<span class="support-banner__filter-count">
|
||||
{{ countByLevel(lvl.value) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Lista de logs -->
|
||||
<div ref="logListRef" class="support-banner__logs">
|
||||
<div v-if="filteredLogs.length === 0" class="support-banner__empty">
|
||||
Nenhum log capturado ainda. Os eventos da agenda aparecerão aqui.
|
||||
</div>
|
||||
<div
|
||||
v-for="log in filteredLogs"
|
||||
:key="log.id"
|
||||
class="support-banner__log-entry"
|
||||
:class="`support-banner__log-entry--${log.level}`"
|
||||
>
|
||||
<span class="support-banner__log-time">{{ formatTime(log.timestamp) }}</span>
|
||||
<span class="support-banner__log-level">{{ log.level }}</span>
|
||||
<span class="support-banner__log-source">[{{ log.source }}]</span>
|
||||
<span class="support-banner__log-msg">{{ log.message }}</span>
|
||||
<button
|
||||
v-if="log.data"
|
||||
class="support-banner__log-expand"
|
||||
@click="toggleData(log.id)"
|
||||
>
|
||||
{ }
|
||||
</button>
|
||||
<pre
|
||||
v-if="log.data && expandedIds.has(log.id)"
|
||||
class="support-banner__log-data"
|
||||
>{{ JSON.stringify(log.data, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rodapé do painel -->
|
||||
<div class="support-banner__footer">
|
||||
<span>{{ store.logs.length }} entrada(s) total</span>
|
||||
<span>·</span>
|
||||
<span>{{ store.errorLogs.length }} erro(s)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useSupportDebugStore } from '@/support/supportDebugStore'
|
||||
|
||||
const store = useSupportDebugStore()
|
||||
|
||||
const panelOpen = ref(false)
|
||||
const activeLevel = ref(null)
|
||||
const expandedIds = ref(new Set())
|
||||
const logListRef = ref(null)
|
||||
|
||||
const levels = [
|
||||
{ label: 'Eventos', value: 'event' },
|
||||
{ label: 'API', value: 'api' },
|
||||
{ label: 'Recorrência', value: 'recurrence' },
|
||||
{ label: 'Guard', value: 'guard' },
|
||||
{ label: 'Perf', value: 'perf' },
|
||||
{ label: 'Erros', value: 'error' },
|
||||
]
|
||||
|
||||
const filteredLogs = computed(() => {
|
||||
const all = store.recentLogs
|
||||
if (!activeLevel.value) return all
|
||||
return all.filter(l => l.level === activeLevel.value)
|
||||
})
|
||||
|
||||
function countByLevel (level) {
|
||||
return store.logs.filter(l => l.level === level).length
|
||||
}
|
||||
|
||||
function toggleData (id) {
|
||||
const s = new Set(expandedIds.value)
|
||||
if (s.has(id)) s.delete(id)
|
||||
else s.add(id)
|
||||
expandedIds.value = s
|
||||
}
|
||||
|
||||
function formatTime (iso) {
|
||||
return new Date(iso).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.support-banner {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 9999;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace, sans-serif;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.support-banner__bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #b45309;
|
||||
color: #fff;
|
||||
padding: 6px 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.support-banner__bar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.support-banner__bar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.support-banner__pulse {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #fcd34d;
|
||||
animation: pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.5; transform: scale(1.3); }
|
||||
}
|
||||
|
||||
.support-banner__tenant {
|
||||
font-weight: 400;
|
||||
opacity: 0.75;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.support-banner__toggle,
|
||||
.support-banner__clear,
|
||||
.support-banner__close {
|
||||
background: rgba(255,255,255,0.15);
|
||||
border: none;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
padding: 3px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.support-banner__toggle:hover,
|
||||
.support-banner__clear:hover,
|
||||
.support-banner__close:hover {
|
||||
background: rgba(255,255,255,0.28);
|
||||
}
|
||||
|
||||
.support-banner__err-badge {
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
padding: 0 7px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Painel */
|
||||
.support-banner__panel {
|
||||
background: #0f172a;
|
||||
border-top: 2px solid #b45309;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 360px;
|
||||
}
|
||||
|
||||
.support-banner__filters {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.support-banner__filter-btn {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
color: #94a3b8;
|
||||
border-radius: 4px;
|
||||
padding: 2px 10px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.support-banner__filter-btn--active {
|
||||
background: #b45309;
|
||||
border-color: #b45309;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.support-banner__filter-count {
|
||||
background: rgba(255,255,255,0.15);
|
||||
border-radius: 8px;
|
||||
padding: 0 5px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.support-banner__logs {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.support-banner__empty {
|
||||
color: #475569;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.support-banner__log-entry {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
padding: 3px 12px;
|
||||
border-bottom: 1px solid #1e293b;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.support-banner__log-entry:hover { background: #1e293b; }
|
||||
|
||||
.support-banner__log-entry--error { border-left: 3px solid #ef4444; }
|
||||
.support-banner__log-entry--api { border-left: 3px solid #3b82f6; }
|
||||
.support-banner__log-entry--recurrence { border-left: 3px solid #8b5cf6; }
|
||||
.support-banner__log-entry--guard { border-left: 3px solid #10b981; }
|
||||
.support-banner__log-entry--perf { border-left: 3px solid #f59e0b; }
|
||||
.support-banner__log-entry--event { border-left: 3px solid #64748b; }
|
||||
|
||||
.support-banner__log-time { color: #475569; font-size: 10px; flex-shrink: 0; }
|
||||
.support-banner__log-level { color: #f59e0b; font-size: 10px; font-weight: 700; text-transform: uppercase; flex-shrink: 0; }
|
||||
.support-banner__log-source { color: #7c3aed; font-size: 10px; flex-shrink: 0; }
|
||||
.support-banner__log-msg { color: #e2e8f0; flex: 1; }
|
||||
|
||||
.support-banner__log-expand {
|
||||
background: #1e293b;
|
||||
border: 1px solid #334155;
|
||||
color: #64748b;
|
||||
border-radius: 3px;
|
||||
padding: 0 5px;
|
||||
cursor: pointer;
|
||||
font-size: 10px;
|
||||
font-family: monospace;
|
||||
}
|
||||
.support-banner__log-expand:hover { color: #e2e8f0; }
|
||||
|
||||
.support-banner__log-data {
|
||||
width: 100%;
|
||||
margin: 4px 0 0;
|
||||
background: #0f172a;
|
||||
border: 1px solid #1e293b;
|
||||
color: #94a3b8;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.support-banner__footer {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 4px 12px;
|
||||
color: #475569;
|
||||
font-size: 10px;
|
||||
border-top: 1px solid #1e293b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Transition */
|
||||
.support-slide-enter-active,
|
||||
.support-slide-leave-active { transition: transform 0.25s ease; }
|
||||
.support-slide-enter-from,
|
||||
.support-slide-leave-to { transform: translateY(100%); }
|
||||
</style>
|
||||
144
src/support/supportDebugStore.js
Normal file
144
src/support/supportDebugStore.js
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* supportDebugStore.js
|
||||
* src/support/supportDebugStore.js
|
||||
*
|
||||
* Store Pinia para o estado global do modo de suporte técnico.
|
||||
*
|
||||
* Estado mantido aqui:
|
||||
* - isActive: bool — se o modo debug está ativo
|
||||
* - tenantId: uuid — tenant sendo depurado
|
||||
* - token: string — token de suporte validado
|
||||
* - logs: array — entradas do painel de debug
|
||||
*
|
||||
* Ativação: somente via validateAndActivate(), que chama o RPC de validação.
|
||||
* Nunca ativa via flag local sem validação no banco.
|
||||
*/
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
const MAX_LOGS = 500 // máximo de entradas no painel
|
||||
|
||||
export const useSupportDebugStore = defineStore('supportDebug', () => {
|
||||
// ── Estado ────────────────────────────────────────────────────────────────
|
||||
|
||||
const isActive = ref(false)
|
||||
const tenantId = ref(null)
|
||||
const token = ref(null)
|
||||
const logs = ref([])
|
||||
const validating = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
// ── Computed ──────────────────────────────────────────────────────────────
|
||||
|
||||
const logsByLevel = computed(() => {
|
||||
const out = {}
|
||||
for (const log of logs.value) {
|
||||
if (!out[log.level]) out[log.level] = []
|
||||
out[log.level].push(log)
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const errorLogs = computed(() =>
|
||||
logs.value.filter(l => l.level === 'error')
|
||||
)
|
||||
|
||||
const recentLogs = computed(() =>
|
||||
[...logs.value].reverse().slice(0, 100)
|
||||
)
|
||||
|
||||
// ── Ações ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Valida o token no banco e ativa o debug se válido.
|
||||
* Único ponto de ativação — sem bypass possível pelo frontend.
|
||||
*
|
||||
* @param {string} supportToken - valor do query param ?support=TOKEN
|
||||
* @returns {boolean} true se ativado
|
||||
*/
|
||||
async function validateAndActivate (supportToken) {
|
||||
if (!supportToken || typeof supportToken !== 'string') return false
|
||||
if (supportToken.length < 32) return false
|
||||
|
||||
validating.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const { data, error: rpcError } = await supabase
|
||||
.rpc('validate_support_session', { p_token: supportToken })
|
||||
|
||||
if (rpcError) throw rpcError
|
||||
|
||||
if (data?.valid === true && data?.tenant_id) {
|
||||
isActive.value = true
|
||||
tenantId.value = data.tenant_id
|
||||
token.value = supportToken
|
||||
logs.value = []
|
||||
return true
|
||||
}
|
||||
|
||||
// Token inválido ou expirado — não ativa, não loga erro visível
|
||||
deactivate()
|
||||
return false
|
||||
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao validar sessão de suporte'
|
||||
deactivate()
|
||||
return false
|
||||
} finally {
|
||||
validating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adiciona uma entrada de log ao painel.
|
||||
* Chamado pelo supportLogger — não chamar diretamente.
|
||||
*/
|
||||
function addLog (entry) {
|
||||
if (!isActive.value) return
|
||||
logs.value.push(entry)
|
||||
// Limita o tamanho do painel
|
||||
if (logs.value.length > MAX_LOGS) {
|
||||
logs.value = logs.value.slice(-MAX_LOGS)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpa todos os logs do painel
|
||||
*/
|
||||
function clearLogs () {
|
||||
logs.value = []
|
||||
}
|
||||
|
||||
/**
|
||||
* Desativa o modo debug e limpa estado
|
||||
*/
|
||||
function deactivate () {
|
||||
isActive.value = false
|
||||
tenantId.value = null
|
||||
token.value = null
|
||||
logs.value = []
|
||||
error.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
// state
|
||||
isActive,
|
||||
tenantId,
|
||||
token,
|
||||
logs,
|
||||
validating,
|
||||
error,
|
||||
// computed
|
||||
logsByLevel,
|
||||
errorLogs,
|
||||
recentLogs,
|
||||
// actions
|
||||
validateAndActivate,
|
||||
addLog,
|
||||
clearLogs,
|
||||
deactivate,
|
||||
}
|
||||
})
|
||||
116
src/support/supportLogger.js
Normal file
116
src/support/supportLogger.js
Normal file
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* supportLogger.js
|
||||
* src/support/supportLogger.js
|
||||
*
|
||||
* Logger centralizado para o sistema de suporte técnico SaaS.
|
||||
*
|
||||
* - Quando debug ATIVO: registra no painel de suporte + console agrupado
|
||||
* - Quando debug INATIVO: silencia completamente (zero custo em produção)
|
||||
*
|
||||
* IMPORTANTE: nunca chamar console.log diretamente nos composables.
|
||||
* Usar sempre este módulo para logs de diagnóstico.
|
||||
*
|
||||
* Uso:
|
||||
* import { logEvent, logAPI, logError, logRecurrence } from '@/support/supportLogger'
|
||||
* logEvent('useRecurrence', 'loadRules', { ownerId, startISO })
|
||||
*/
|
||||
|
||||
import { useSupportDebugStore } from './supportDebugStore'
|
||||
|
||||
// ─── Níveis ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const LOG_LEVEL = {
|
||||
EVENT: 'event',
|
||||
API: 'api',
|
||||
ERROR: 'error',
|
||||
RECURRENCE: 'recurrence',
|
||||
GUARD: 'guard',
|
||||
PERF: 'perf',
|
||||
}
|
||||
|
||||
// ─── Função base ─────────────────────────────────────────────────────────────
|
||||
|
||||
function _log (level, source, message, data = null) {
|
||||
let store
|
||||
try { store = useSupportDebugStore() } catch { return }
|
||||
|
||||
if (!store.isActive) return
|
||||
|
||||
const entry = {
|
||||
id: crypto.randomUUID(),
|
||||
level,
|
||||
source,
|
||||
message,
|
||||
data: data ? JSON.parse(JSON.stringify(data)) : null,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
store.addLog(entry)
|
||||
|
||||
// Agrupa no console para não poluir — só visível quando debug ativo
|
||||
const prefix = `[${level.toUpperCase()}][${source}]`
|
||||
if (level === LOG_LEVEL.ERROR) {
|
||||
console.error(prefix, message, data ?? '')
|
||||
} else {
|
||||
console.log(prefix, message, data ?? '')
|
||||
}
|
||||
}
|
||||
|
||||
// ─── API pública ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Log de evento geral (lifecycle, state changes)
|
||||
* Substitui console.log genérico dos composables
|
||||
*/
|
||||
export function logEvent (source, message, data = null) {
|
||||
_log(LOG_LEVEL.EVENT, source, message, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Log de chamada de API (Supabase queries)
|
||||
*/
|
||||
export function logAPI (source, message, data = null) {
|
||||
_log(LOG_LEVEL.API, source, message, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Log de erro capturado
|
||||
*/
|
||||
export function logError (source, message, error = null) {
|
||||
const data = error
|
||||
? { message: error?.message, code: error?.code, details: error?.details }
|
||||
: null
|
||||
_log(LOG_LEVEL.ERROR, source, message, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Log específico do sistema de recorrência
|
||||
* Substitui os console.log de useRecurrence
|
||||
*/
|
||||
export function logRecurrence (message, data = null) {
|
||||
_log(LOG_LEVEL.RECURRENCE, 'useRecurrence', message, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Log de navegação/guard do router
|
||||
* Substitui console.time/timeLog/timeEnd de guards.js
|
||||
*/
|
||||
export function logGuard (message, data = null) {
|
||||
_log(LOG_LEVEL.GUARD, 'router.guard', message, data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Log de performance (substitui console.time)
|
||||
* Retorna uma função que finaliza a medição
|
||||
*/
|
||||
export function logPerf (source, label) {
|
||||
let store
|
||||
try { store = useSupportDebugStore() } catch { return () => {} }
|
||||
if (!store.isActive) return () => {}
|
||||
|
||||
const start = performance.now()
|
||||
return function end (extraData = null) {
|
||||
const ms = (performance.now() - start).toFixed(2)
|
||||
_log(LOG_LEVEL.PERF, source, `${label} — ${ms}ms`, extraData)
|
||||
}
|
||||
}
|
||||
83
src/support/supportSessionService.js
Normal file
83
src/support/supportSessionService.js
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* supportSessionService.js
|
||||
* src/support/supportSessionService.js
|
||||
*
|
||||
* Serviço para o painel SaaS gerenciar sessões de suporte.
|
||||
* Usado apenas pelo painel do admin — nunca pelo terapeuta/paciente.
|
||||
*
|
||||
* Fluxo:
|
||||
* 1. Admin seleciona tenant
|
||||
* 2. createSession(tenantId) → { token, expires_at }
|
||||
* 3. Admin recebe URL pronta para copiar
|
||||
* 4. Admin pode listar sessões ativas e revogar
|
||||
*/
|
||||
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
/**
|
||||
* Cria uma sessão de suporte para um tenant.
|
||||
* Requer: usuário autenticado com role saas_admin (validado no RPC).
|
||||
*
|
||||
* @param {string} tenantId - UUID do tenant a ser depurado
|
||||
* @param {number} ttlMinutes - TTL em minutos (1–120, default 60)
|
||||
* @returns {{ token: string, expires_at: string, session_id: string }}
|
||||
*/
|
||||
export async function createSupportSession (tenantId, ttlMinutes = 60) {
|
||||
if (!tenantId) throw new Error('tenant_id é obrigatório.')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.rpc('create_support_session', {
|
||||
p_tenant_id: tenantId,
|
||||
p_ttl_minutes: ttlMinutes,
|
||||
})
|
||||
|
||||
if (error) throw error
|
||||
if (!data?.token) throw new Error('Resposta inválida do servidor.')
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista sessões de suporte ativas do admin logado.
|
||||
* Retorna somente sessões não expiradas.
|
||||
*
|
||||
* @returns {Array}
|
||||
*/
|
||||
export async function listActiveSupportSessions () {
|
||||
const { data, error } = await supabase
|
||||
.from('support_sessions')
|
||||
.select('id, tenant_id, token, expires_at, created_at')
|
||||
.gt('expires_at', new Date().toISOString())
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
return data || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoga um token de suporte imediatamente.
|
||||
*
|
||||
* @param {string} token
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export async function revokeSupportSession (token) {
|
||||
if (!token) throw new Error('Token é obrigatório.')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.rpc('revoke_support_session', { p_token: token })
|
||||
|
||||
if (error) throw error
|
||||
return !!data
|
||||
}
|
||||
|
||||
/**
|
||||
* Monta a URL de suporte completa para copiar.
|
||||
*
|
||||
* @param {string} token
|
||||
* @param {string} basePath - rota onde debug será ativado (default: /therapist/agenda)
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildSupportUrl (token, basePath = '/therapist/agenda') {
|
||||
const origin = window.location.origin
|
||||
return `${origin}${basePath}?support=${token}`
|
||||
}
|
||||
82
src/utils/feriadosBR.js
Normal file
82
src/utils/feriadosBR.js
Normal file
@@ -0,0 +1,82 @@
|
||||
// src/utils/feriadosBR.js
|
||||
// Gera feriados nacionais brasileiros por algoritmo — sem API externa.
|
||||
|
||||
/**
|
||||
* Calcula a data da Páscoa pelo algoritmo de Meeus/Jones/Butcher.
|
||||
* @param {number} ano
|
||||
* @returns {Date}
|
||||
*/
|
||||
function calcularPascoa (ano) {
|
||||
const a = ano % 19
|
||||
const b = Math.floor(ano / 100)
|
||||
const c = ano % 100
|
||||
const d = Math.floor(b / 4)
|
||||
const e = b % 4
|
||||
const f = Math.floor((b + 8) / 25)
|
||||
const g = Math.floor((b - f + 1) / 3)
|
||||
const h = (19 * a + b - d - g + 15) % 30
|
||||
const i = Math.floor(c / 4)
|
||||
const k = c % 4
|
||||
const l = (32 + 2 * e + 2 * i - h - k) % 7
|
||||
const m = Math.floor((a + 11 * h + 22 * l) / 451)
|
||||
const mes = Math.floor((h + l - 7 * m + 114) / 31)
|
||||
const dia = ((h + l - 7 * m + 114) % 31) + 1
|
||||
return new Date(ano, mes - 1, dia)
|
||||
}
|
||||
|
||||
function somarDias (data, dias) {
|
||||
const d = new Date(data)
|
||||
d.setDate(d.getDate() + dias)
|
||||
return d
|
||||
}
|
||||
|
||||
function toISO (data) {
|
||||
const y = data.getFullYear()
|
||||
const m = String(data.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(data.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
function feriado (data, nome, movel = false) {
|
||||
return { data: toISO(data), nome, movel }
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna todos os feriados nacionais do ano informado.
|
||||
* @param {number} ano
|
||||
* @returns {{ data: string, nome: string, movel: boolean }[]}
|
||||
*/
|
||||
export function getFeriadosNacionais (ano) {
|
||||
const pascoa = calcularPascoa(ano)
|
||||
|
||||
const lista = [
|
||||
// ── Fixos ───────────────────────────────────────────────
|
||||
feriado(new Date(ano, 0, 1), 'Confraternização Universal'),
|
||||
feriado(new Date(ano, 3, 21), 'Tiradentes'),
|
||||
feriado(new Date(ano, 4, 1), 'Dia do Trabalho'),
|
||||
feriado(new Date(ano, 8, 7), 'Independência do Brasil'),
|
||||
feriado(new Date(ano, 9, 12), 'Nossa Senhora Aparecida'),
|
||||
feriado(new Date(ano, 10, 2), 'Finados'),
|
||||
feriado(new Date(ano, 10, 15), 'Proclamação da República'),
|
||||
feriado(new Date(ano, 11, 25), 'Natal'),
|
||||
|
||||
// ── Móveis ──────────────────────────────────────────────
|
||||
feriado(somarDias(pascoa, -48), 'Segunda de Carnaval', true),
|
||||
feriado(somarDias(pascoa, -47), 'Carnaval', true),
|
||||
feriado(somarDias(pascoa, -2), 'Sexta-feira Santa', true),
|
||||
feriado(pascoa, 'Páscoa', true),
|
||||
feriado(somarDias(pascoa, 60), 'Corpus Christi', true)
|
||||
]
|
||||
|
||||
return lista.sort((a, b) => a.data.localeCompare(b.data))
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna feriados nacionais de um mês específico.
|
||||
* @param {number} ano
|
||||
* @param {number} mes 1–12
|
||||
*/
|
||||
export function getFeriadosNacionaisDoMes (ano, mes) {
|
||||
const m = String(mes).padStart(2, '0')
|
||||
return getFeriadosNacionais(ano).filter(f => f.data.slice(5, 7) === m)
|
||||
}
|
||||
69
src/utils/menuPageOptions.js
Normal file
69
src/utils/menuPageOptions.js
Normal file
@@ -0,0 +1,69 @@
|
||||
// src/utils/menuPageOptions.js
|
||||
// Catálogo plano de páginas do sistema para o select de documentação SaaS.
|
||||
// Menus com `to` em string são extraídos automaticamente.
|
||||
// O menu da Clínica usa route names, então suas páginas são listadas manualmente.
|
||||
|
||||
import therapistMenuRaw from '@/navigation/menus/therapist.menu.js'
|
||||
import saasMenuFn from '@/navigation/menus/saas.menu.js'
|
||||
|
||||
function flattenItems (items = [], groupLabel = '') {
|
||||
const result = []
|
||||
for (const item of items) {
|
||||
const to = item.to
|
||||
if (to && typeof to === 'string' && item.label) {
|
||||
const prefix = groupLabel ? `${groupLabel} · ` : ''
|
||||
result.push({ label: `${prefix}${item.label}`, path: to })
|
||||
}
|
||||
if (item.items?.length) {
|
||||
result.push(...flattenItems(item.items, item.label || groupLabel))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function flattenMenu (menu = []) {
|
||||
const result = []
|
||||
for (const group of menu) {
|
||||
if (group.items?.length) {
|
||||
result.push(...flattenItems(group.items, group.label))
|
||||
} else if (group.to && typeof group.to === 'string' && group.label) {
|
||||
result.push({ label: group.label, path: group.to })
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const therapistPages = flattenMenu(therapistMenuRaw)
|
||||
.map(p => ({ ...p, label: `Terapeuta · ${p.label}` }))
|
||||
|
||||
const saasPages = flattenMenu(saasMenuFn({ isSaasAdmin: true }))
|
||||
.map(p => ({ ...p, label: `SaaS · ${p.label}` }))
|
||||
|
||||
// Clínica usa route names → listagem manual com os paths reais das rotas
|
||||
const clinicPages = [
|
||||
{ label: 'Clínica · Dashboard', path: '/admin' },
|
||||
{ label: 'Clínica · Agenda da Clínica', path: '/admin/agenda/clinica' },
|
||||
{ label: 'Clínica · Compromissos', path: '/admin/agenda/compromissos' },
|
||||
{ label: 'Clínica · Lista de Pacientes', path: '/admin/pacientes' },
|
||||
{ label: 'Clínica · Grupos de Pacientes', path: '/admin/pacientes/grupos' },
|
||||
{ label: 'Clínica · Tags de Pacientes', path: '/admin/pacientes/tags' },
|
||||
{ label: 'Clínica · Link Externo', path: '/admin/pacientes/link-externo' },
|
||||
{ label: 'Clínica · Cadastros Recebidos', path: '/admin/pacientes/cadastro/recebidos' },
|
||||
{ label: 'Clínica · Profissionais', path: '/admin/clinic/professionals' },
|
||||
{ label: 'Clínica · Tipos de Clínica', path: '/admin/clinic/features' },
|
||||
{ label: 'Clínica · Meu Plano', path: '/admin/meu-plano' },
|
||||
{ label: 'Clínica · Segurança', path: '/admin/settings/security' },
|
||||
{ label: 'Clínica · Agendamento Online', path: '/admin/online-scheduling' },
|
||||
]
|
||||
|
||||
const portalPages = [
|
||||
{ label: 'Portal · Dashboard', path: '/portal' },
|
||||
{ label: 'Portal · Minhas Sessões', path: '/portal/sessoes' },
|
||||
]
|
||||
|
||||
export const PAGE_OPTIONS = [
|
||||
...therapistPages,
|
||||
...clinicPages,
|
||||
...saasPages,
|
||||
...portalPages,
|
||||
]
|
||||
@@ -1,149 +0,0 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="font-semibold text-2xl mb-4">Documentation</div>
|
||||
<div class="font-semibold text-xl mb-4">Get Started</div>
|
||||
<p class="text-lg mb-4">
|
||||
Sakai is an application template for Vue based on the <a href="https://github.com/vuejs/create-vue" class="font-medium text-primary hover:underline">create-vue</a>, the recommended way to start a <strong>Vite-powered</strong> Vue
|
||||
projects. To get started, clone the <a href="https://github.com/primefaces/sakai-vue" class="font-medium text-primary hover:underline">repository</a> from GitHub and install the dependencies with npm or yarn.
|
||||
</p>
|
||||
<pre class="app-code">
|
||||
<code>git clone https://github.com/primefaces/sakai-vue
|
||||
npm install
|
||||
npm run dev</code></pre>
|
||||
|
||||
<p class="text-lg mb-4">Navigate to <i class="bg-highlight px-2 py-1 rounded-border not-italic text-base">http://localhost:5173/</i> to view the application in your local environment.</p>
|
||||
|
||||
<pre class="app-code"><code>npm run dev</code></pre>
|
||||
|
||||
<div class="font-semibold text-xl mb-4">Structure</div>
|
||||
<p class="text-lg mb-4">Templates consists of a couple folders, demos and layout have been separated so that you can easily remove what is not necessary for your application.</p>
|
||||
<ul class="leading-normal list-disc pl-8 text-lg mb-4">
|
||||
<li><span class="text-primary font-medium">src/layout</span>: Main layout files, needs to be present.</li>
|
||||
<li><span class="text-primary font-medium">src/views</span>: Demo pages like Dashboard.</li>
|
||||
<li><span class="text-primary font-medium">public/demo</span>: Assets used in demos</li>
|
||||
<li><span class="text-primary font-medium">src/assets/demo</span>: Styles used in demos</li>
|
||||
<li><span class="text-primary font-medium">src/assets/layout</span>: SCSS files of the main layout</li>
|
||||
</ul>
|
||||
|
||||
<div class="font-semibold text-xl mb-4">Menu</div>
|
||||
<p class="text-lg mb-4">
|
||||
Main menu is defined at <span class="bg-highlight px-2 py-1 rounded-border not-italic text-base">src/layout/AppMenu.vue</span> file. Update the <i class="bg-highlight px-2 py-1 rounded-border not-italic text-base">model</i> property to
|
||||
define your own menu items.
|
||||
</p>
|
||||
|
||||
<div class="font-semibold text-xl mb-4">Layout Composable</div>
|
||||
<p class="text-lg mb-4">
|
||||
The <span class="bg-highlight px-2 py-1 rounded-border not-italic text-base">src/layout/composables/layout.js</span> is a composable that manages the layout state changes including dark mode, PrimeVue theme, menu modes and states. If you
|
||||
change the initial values like the preset or colors, make sure to apply them at PrimeVue config at main.js as well.
|
||||
</p>
|
||||
|
||||
<div class="font-semibold text-xl mb-4">Tailwind CSS</div>
|
||||
<p class="text-lg mb-4">The demo pages are developed with Tailwind CSS however the core application shell mainly uses custom CSS.</p>
|
||||
|
||||
<div class="font-semibold text-xl mb-4">Variables</div>
|
||||
<p class="text-lg mb-4">
|
||||
CSS variables used in the template derive their values from the PrimeVue styled mode presets, use the files under <span class="bg-highlight px-2 py-1 rounded-border not-italic text-base">assets/layout/_variables.scss</span> to customize
|
||||
according to your requirements.
|
||||
</p>
|
||||
|
||||
<div class="font-semibold text-xl mb-4">Add Sakai-Vue to a Nuxt Project</div>
|
||||
<p class="text-lg mb-4">To get started, create a Nuxt project.</p>
|
||||
<pre class="app-code">
|
||||
<code>npx nuxi@latest init sakai-nuxt</code></pre>
|
||||
|
||||
<p class="text-lg mb-4">Add Prime related libraries to the project.</p>
|
||||
<pre class="app-code">
|
||||
<code>npm install primevue @primevue/themes tailwindcss-primeui primeicons
|
||||
npm install --save-dev @primevue/nuxt-module</code></pre>
|
||||
|
||||
<p class="text-lg mb-4">Add PrimeVue-Nuxt module to <span class="bg-highlight px-2 py-1 rounded-border not-italic text-base">nuxt.config.js</span></p>
|
||||
<pre class="app-code">
|
||||
<code>modules: [
|
||||
'@primevue/nuxt-module',
|
||||
]</code></pre>
|
||||
|
||||
<p class="text-lg mb-4">Install <a href="https://tailwindcss.com/docs/guides/nuxtjs" class="font-medium text-primary hover:underline">Tailwind CSS</a> with Nuxt using official documentation.</p>
|
||||
|
||||
<p class="text-lg mb-4">
|
||||
Add <span class="bg-highlight px-2 py-1 rounded-border not-italic text-base">tailwindcss-primeui</span> package as a plugin to <span class="bg-highlight px-2 py-1 rounded-border not-italic text-base">tailwind.config.js</span>
|
||||
</p>
|
||||
<pre class="app-code">
|
||||
<code>plugins: [require('tailwindcss-primeui')]</code></pre>
|
||||
|
||||
<p class="text-lg mb-4">Add PrimeVue to in <span class="bg-highlight px-2 py-1 rounded-border not-italic text-base">nuxt.config.js</span></p>
|
||||
<pre class="app-code">
|
||||
<code>import Aura from '@primevue/themes/aura';
|
||||
|
||||
primevue: {
|
||||
options: {
|
||||
theme: {
|
||||
preset: Aura,
|
||||
options: {
|
||||
darkModeSelector: '.app-dark'
|
||||
}
|
||||
}
|
||||
}
|
||||
}</code></pre>
|
||||
|
||||
<p class="text-lg mb-4">
|
||||
Copy <span class="bg-highlight px-2 py-1 rounded-border not-italic text-base">src/assets</span> folder and paste them to <span class="bg-highlight px-2 py-1 rounded-border not-italic text-base">assets</span> folder to your Nuxt project.
|
||||
And add to <span class="bg-highlight px-2 py-1 rounded-border not-italic text-base">nuxt.config.js</span>
|
||||
</p>
|
||||
<pre class="app-code">
|
||||
<code>css: ['~/assets/tailwind.css', '~/assets/styles.scss']</code></pre>
|
||||
|
||||
<p class="text-lg mb-4">Change <span class="bg-highlight px-2 py-1 rounded-border not-italic text-base">app.vue</span></p>
|
||||
<pre class="app-code">
|
||||
<code><template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template></code></pre>
|
||||
|
||||
<p class="text-lg mb-4">Create <span class="bg-highlight px-2 py-1 rounded-border not-italic text-base">layouts/default.vue</span> and paste this code:</p>
|
||||
<pre class="app-code">
|
||||
<code><script setup>
|
||||
import AppLayout from './AppLayout.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout />
|
||||
</template></code></pre>
|
||||
|
||||
<p class="text-lg mb-4">
|
||||
Create <span class="bg-highlight px-2 py-1 rounded-border not-italic text-base">layouts</span> folder and copy <span class="bg-highlight px-2 py-1 rounded-border not-italic text-base">src/layout</span> folder and paste them. And then
|
||||
create <span class="bg-highlight px-2 py-1 rounded-border not-italic text-base">composables/use-layout.vue</span> and replace it with
|
||||
<span class="bg-highlight px-2 py-1 rounded-border not-italic text-base">src/layout/composables/layout.js</span>. Then remove this line:
|
||||
</p>
|
||||
<pre class="app-code">
|
||||
<code>import { useLayout } from '@/layout/composables/layout';</code></pre>
|
||||
|
||||
<p class="text-lg mb-4">As a final step, copy the following folders:</p>
|
||||
<ul class="leading-normal list-disc pl-8 text-lg mb-4">
|
||||
<li><span class="text-primary font-medium">public/demo</span> <i class="pi pi-arrow-right text-sm! mr-1"></i> <span class="text-primary font-medium">public</span></li>
|
||||
<li><span class="text-primary font-medium">src/components</span> <i class="pi pi-arrow-right text-sm! mr-1"></i> <span class="text-primary font-medium">components</span></li>
|
||||
<li><span class="text-primary font-medium">src/service</span> <i class="pi pi-arrow-right text-sm! mr-1"></i> <span class="text-primary font-medium">service</span></li>
|
||||
<li><span class="text-primary font-medium">src/views/uikit</span> <i class="pi pi-arrow-right text-sm! mr-1"></i> <span class="text-primary font-medium">pages/uikit</span></li>
|
||||
<li><span class="text-primary font-medium">src/views/pages</span> <i class="pi pi-arrow-right text-sm! mr-1"></i> <span class="text-primary font-medium">pages</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@media screen and (max-width: 991px) {
|
||||
.video-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
padding-bottom: 56.25%;
|
||||
|
||||
iframe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -91,7 +91,7 @@ const PROFILE_CARDS = [
|
||||
{
|
||||
key: 'saas',
|
||||
index: '06',
|
||||
label: 'Master',
|
||||
label: 'SaaS',
|
||||
description: 'Visão global da plataforma: tenants, assinaturas e saúde.',
|
||||
icon: 'pi-shield',
|
||||
color: '#F43F5E',
|
||||
|
||||
@@ -3,10 +3,6 @@ import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
|
||||
// Se você ainda usa o FloatingConfigurator no template de páginas públicas,
|
||||
// pode manter. Se não usa, pode remover tranquilamente.
|
||||
import FloatingConfigurator from '@/components/FloatingConfigurator.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
@@ -19,8 +15,6 @@ function goDashboard () {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FloatingConfigurator />
|
||||
|
||||
<div class="relative min-h-screen overflow-hidden bg-[var(--surface-ground)] text-[var(--text-color)]">
|
||||
<!-- fundo conceitual: grid + halos -->
|
||||
<div class="pointer-events-none absolute inset-0 opacity-80">
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
<script setup>
|
||||
import FloatingConfigurator from '@/components/FloatingConfigurator.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FloatingConfigurator />
|
||||
<div class="bg-surface-50 dark:bg-surface-950 flex items-center justify-center min-h-screen min-w-[100vw] overflow-hidden">
|
||||
<div class="flex flex-col items-center justify-center">
|
||||
<div style="border-radius: 56px; padding: 0.3rem; background: linear-gradient(180deg, rgba(247, 149, 48, 0.4) 10%, rgba(247, 149, 48, 0) 30%)">
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
<script setup>
|
||||
import FloatingConfigurator from '@/components/FloatingConfigurator.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FloatingConfigurator />
|
||||
<div class="bg-surface-50 dark:bg-surface-950 flex items-center justify-center min-h-screen min-w-[100vw] overflow-hidden">
|
||||
<div class="flex flex-col items-center justify-center">
|
||||
<div style="border-radius: 56px; padding: 0.3rem; background: linear-gradient(180deg, rgba(233, 30, 99, 0.4) 10%, rgba(33, 150, 243, 0) 30%)">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup>
|
||||
import FloatingConfigurator from '@/components/FloatingConfigurator.vue'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
|
||||
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
|
||||
@@ -295,8 +294,6 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FloatingConfigurator />
|
||||
|
||||
<div class="min-h-screen w-full flex">
|
||||
|
||||
<!-- ===== ESQUERDA: CARROSSEL ===== -->
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup>
|
||||
import FloatingConfigurator from '@/components/FloatingConfigurator.vue'
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
@@ -124,8 +123,6 @@ async function submit () {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FloatingConfigurator />
|
||||
|
||||
<div class="min-h-screen w-full flex">
|
||||
|
||||
<!-- ===== ESQUERDA: Painel de segurança ===== -->
|
||||
|
||||
1619
src/views/pages/public/AgendadorPublicoPage.vue
Normal file
1619
src/views/pages/public/AgendadorPublicoPage.vue
Normal file
File diff suppressed because it is too large
Load Diff
1200
src/views/pages/saas/SaasDocsPage.vue
Normal file
1200
src/views/pages/saas/SaasDocsPage.vue
Normal file
File diff suppressed because it is too large
Load Diff
550
src/views/pages/saas/SaasFaqPage.vue
Normal file
550
src/views/pages/saas/SaasFaqPage.vue
Normal file
@@ -0,0 +1,550 @@
|
||||
<!-- src/views/pages/saas/SaasFaqPage.vue -->
|
||||
<!-- Portal de FAQ — consulta de perguntas frequentes por usuários logados -->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useDocsAdmin } from '@/composables/useDocsAdmin'
|
||||
|
||||
const router = useRouter()
|
||||
const { requestEditDoc } = useDocsAdmin()
|
||||
|
||||
function editarDoc (docId) {
|
||||
requestEditDoc(docId)
|
||||
router.push('/saas/docs')
|
||||
}
|
||||
|
||||
// ── Estado ────────────────────────────────────────────────────
|
||||
const loading = ref(false)
|
||||
const docs = ref([]) // docs com exibir_no_faq = true
|
||||
const faqItens = ref([]) // todos os itens FAQ dos docs acima
|
||||
|
||||
const busca = ref('')
|
||||
const catAtiva = ref(null) // categoria selecionada no sidebar
|
||||
|
||||
// Controla quais perguntas estão abertas { [itemId]: boolean }
|
||||
const abertos = ref({})
|
||||
|
||||
// ── Load ──────────────────────────────────────────────────────
|
||||
async function load () {
|
||||
loading.value = true
|
||||
try {
|
||||
// Busca docs habilitados no FAQ
|
||||
const { data: docsData, error: docsErr } = await supabase
|
||||
.from('saas_docs')
|
||||
.select('id, titulo, categoria, ordem, pagina_path')
|
||||
.eq('ativo', true)
|
||||
.eq('exibir_no_faq', true)
|
||||
.order('categoria')
|
||||
.order('ordem')
|
||||
if (docsErr) throw docsErr
|
||||
|
||||
docs.value = docsData || []
|
||||
|
||||
if (!docs.value.length) return
|
||||
|
||||
// Busca todos os itens FAQ desses docs
|
||||
const docIds = docs.value.map(d => d.id)
|
||||
const { data: itensData, error: itensErr } = await supabase
|
||||
.from('saas_faq_itens')
|
||||
.select('id, doc_id, pergunta, resposta, ordem')
|
||||
.in('doc_id', docIds)
|
||||
.eq('ativo', true)
|
||||
.order('ordem')
|
||||
if (itensErr) throw itensErr
|
||||
|
||||
faqItens.value = itensData || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
// ── Categorias disponíveis ────────────────────────────────────
|
||||
const categorias = computed(() => {
|
||||
const set = new Set(docs.value.map(d => d.categoria).filter(Boolean))
|
||||
return [...set].sort()
|
||||
})
|
||||
|
||||
// ── Docs filtrados pela categoria ativa ───────────────────────
|
||||
const docsFiltrados = computed(() => {
|
||||
if (!catAtiva.value) return docs.value
|
||||
return docs.value.filter(d => d.categoria === catAtiva.value)
|
||||
})
|
||||
|
||||
// ── Itens de um doc, aplicando busca ─────────────────────────
|
||||
function itensDo (docId) {
|
||||
const q = busca.value.trim().toLowerCase()
|
||||
return faqItens.value.filter(f => {
|
||||
if (f.doc_id !== docId) return false
|
||||
if (!q) return true
|
||||
return (
|
||||
f.pergunta.toLowerCase().includes(q) ||
|
||||
(f.resposta || '').toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// ── Docs que têm resultado na busca ──────────────────────────
|
||||
const docsComResultado = computed(() => {
|
||||
return docsFiltrados.value.filter(d => itensDo(d.id).length > 0)
|
||||
})
|
||||
|
||||
// Total de resultados para feedback
|
||||
const totalResultados = computed(() => {
|
||||
if (!busca.value.trim()) return null
|
||||
return docsComResultado.value.reduce((acc, d) => acc + itensDo(d.id).length, 0)
|
||||
})
|
||||
|
||||
// ── Toggle pergunta ───────────────────────────────────────────
|
||||
function toggle (id) {
|
||||
abertos.value[id] = !abertos.value[id]
|
||||
}
|
||||
|
||||
// Abre todas as perguntas dos resultados quando há busca ativa
|
||||
function expandirResultados () {
|
||||
docsComResultado.value.forEach(d => {
|
||||
itensDo(d.id).forEach(item => {
|
||||
abertos.value[item.id] = true
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Observa busca: expande automaticamente quando tem busca
|
||||
watch(busca, (val) => {
|
||||
if (val.trim()) expandirResultados()
|
||||
})
|
||||
|
||||
// ── Selecionar categoria ──────────────────────────────────────
|
||||
function selecionarCat (cat) {
|
||||
catAtiva.value = catAtiva.value === cat ? null : cat
|
||||
busca.value = ''
|
||||
abertos.value = {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="faq-page">
|
||||
|
||||
<!-- ── Cabeçalho ─────────────────────────────────────────── -->
|
||||
<div class="faq-header">
|
||||
<div class="faq-header-inner">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="faq-icon-wrap">
|
||||
<i class="pi pi-comments text-xl" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="faq-title">Central de Ajuda</h1>
|
||||
<p class="faq-subtitle">Encontre respostas para as dúvidas mais comuns</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Busca -->
|
||||
<div class="faq-search-wrap">
|
||||
<IconField class="w-full">
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText
|
||||
v-model="busca"
|
||||
placeholder="Buscar pergunta…"
|
||||
class="faq-search-input"
|
||||
/>
|
||||
<InputIcon v-if="busca" class="pi pi-times cursor-pointer" @click="busca = ''" />
|
||||
</IconField>
|
||||
<div v-if="totalResultados !== null" class="faq-search-result">
|
||||
{{ totalResultados }} resultado{{ totalResultados !== 1 ? 's' : '' }} encontrado{{ totalResultados !== 1 ? 's' : '' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Corpo ─────────────────────────────────────────────── -->
|
||||
<div class="faq-body">
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex justify-center py-16">
|
||||
<i class="pi pi-spinner pi-spin text-2xl opacity-30" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
|
||||
<!-- Sidebar de categorias -->
|
||||
<aside v-if="categorias.length" class="faq-sidebar">
|
||||
<div class="faq-sidebar-title">Categorias</div>
|
||||
<button
|
||||
class="faq-cat-btn"
|
||||
:class="{ 'faq-cat-btn--active': !catAtiva }"
|
||||
@click="selecionarCat(null)"
|
||||
>
|
||||
<i class="pi pi-th-large text-xs mr-2" />
|
||||
Todas
|
||||
<span class="faq-cat-count">{{ faqItens.length }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-for="cat in categorias"
|
||||
:key="cat"
|
||||
class="faq-cat-btn"
|
||||
:class="{ 'faq-cat-btn--active': catAtiva === cat }"
|
||||
@click="selecionarCat(cat)"
|
||||
>
|
||||
<i class="pi pi-tag text-xs mr-2 opacity-60" />
|
||||
{{ cat }}
|
||||
<span class="faq-cat-count">
|
||||
{{ faqItens.filter(f => docs.find(d => d.id === f.doc_id && d.categoria === cat)).length }}
|
||||
</span>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<!-- Conteúdo principal -->
|
||||
<main class="faq-main">
|
||||
|
||||
<!-- Sem resultados -->
|
||||
<div v-if="docsComResultado.length === 0" class="faq-empty">
|
||||
<i class="pi pi-search text-3xl opacity-20 mb-3" />
|
||||
<p class="text-[var(--text-color-secondary)]">Nenhuma pergunta encontrada.</p>
|
||||
<button v-if="busca || catAtiva" class="text-[var(--primary-color)] text-sm mt-2 underline" @click="busca = ''; catAtiva = null; abertos = {}">
|
||||
Limpar filtros
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Grupos de docs -->
|
||||
<div
|
||||
v-for="doc in docsComResultado"
|
||||
:key="doc.id"
|
||||
class="faq-group"
|
||||
>
|
||||
<!-- Cabeçalho do grupo (doc) -->
|
||||
<div class="faq-group-header">
|
||||
<div class="faq-group-icon">
|
||||
<i class="pi pi-file-edit text-sm" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h2 class="faq-group-title">{{ doc.titulo }}</h2>
|
||||
<span v-if="doc.categoria" class="faq-group-cat">{{ doc.categoria }}</span>
|
||||
</div>
|
||||
<button
|
||||
class="edit-doc-btn"
|
||||
v-tooltip.top="'Editar documento'"
|
||||
@click="editarDoc(doc.id)"
|
||||
>
|
||||
<i class="pi pi-pencil text-xs" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Itens FAQ do grupo -->
|
||||
<div class="faq-items">
|
||||
<div
|
||||
v-for="item in itensDo(doc.id)"
|
||||
:key="item.id"
|
||||
class="faq-item"
|
||||
:class="{ 'faq-item--open': abertos[item.id] }"
|
||||
>
|
||||
<button class="faq-pergunta" @click="toggle(item.id)">
|
||||
<span class="faq-pergunta-text">{{ item.pergunta }}</span>
|
||||
<i
|
||||
class="pi shrink-0 text-sm opacity-40 transition-transform duration-200"
|
||||
:class="abertos[item.id] ? 'pi-chevron-up' : 'pi-chevron-down'"
|
||||
/>
|
||||
</button>
|
||||
<Transition name="faq-expand">
|
||||
<div
|
||||
v-if="abertos[item.id] && item.resposta"
|
||||
class="faq-resposta ql-content"
|
||||
v-html="item.resposta"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── Layout ──────────────────────────────────────────────────── */
|
||||
.faq-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────────── */
|
||||
.faq-header {
|
||||
background: var(--surface-card);
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
padding: 2rem 1.5rem 1.5rem;
|
||||
}
|
||||
.faq-header-inner {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.faq-icon-wrap {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
|
||||
color: var(--primary-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.faq-title {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.faq-subtitle {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color-secondary);
|
||||
margin: 2px 0 0;
|
||||
}
|
||||
|
||||
.faq-search-wrap {
|
||||
position: relative;
|
||||
}
|
||||
.faq-search-input {
|
||||
width: 100%;
|
||||
border-radius: 0.75rem !important;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.faq-search-result {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.7;
|
||||
margin-top: 0.375rem;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* ── Corpo ──────────────────────────────────────────────────── */
|
||||
.faq-body {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
padding: 1.5rem;
|
||||
flex: 1;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* ── Sidebar ─────────────────────────────────────────────────── */
|
||||
.faq-sidebar {
|
||||
width: 200px;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.faq-sidebar-title {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.6;
|
||||
padding: 0 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.faq-cat-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-color-secondary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.faq-cat-btn:hover {
|
||||
background: var(--surface-hover);
|
||||
color: var(--text-color);
|
||||
}
|
||||
.faq-cat-btn--active {
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
color: var(--primary-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
.faq-cat-count {
|
||||
margin-left: auto;
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.5;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── Main ─────────────────────────────────────────────────────── */
|
||||
.faq-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.faq-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 3rem 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Grupo (doc) ─────────────────────────────────────────────── */
|
||||
.faq-group {
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 1rem;
|
||||
overflow: hidden;
|
||||
background: var(--surface-card);
|
||||
}
|
||||
|
||||
.faq-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem 1.25rem;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
background: var(--surface-ground);
|
||||
}
|
||||
.faq-group-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
color: var(--primary-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.faq-group-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.faq-group-cat {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.6;
|
||||
display: block;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.edit-doc-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
color: var(--text-color-secondary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, background 0.15s, color 0.15s;
|
||||
}
|
||||
.faq-group-header:hover .edit-doc-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
.edit-doc-btn:hover {
|
||||
background: var(--surface-hover);
|
||||
color: var(--primary-color);
|
||||
border-color: color-mix(in srgb, var(--primary-color) 30%, transparent);
|
||||
}
|
||||
|
||||
/* ── Itens FAQ ───────────────────────────────────────────────── */
|
||||
.faq-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.faq-item {
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.faq-item:last-child { border-bottom: none; }
|
||||
.faq-item--open { background: color-mix(in srgb, var(--primary-color) 3%, transparent); }
|
||||
|
||||
.faq-pergunta {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.875rem 1.25rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.faq-pergunta:hover { background: var(--surface-hover); }
|
||||
|
||||
.faq-pergunta-text {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.faq-resposta {
|
||||
padding: 0 1.25rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color-secondary);
|
||||
line-height: 1.65;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Quill content */
|
||||
.faq-resposta.ql-content :deep(p) { margin: 0 0 0.5rem; }
|
||||
.faq-resposta.ql-content :deep(p:last-child) { margin-bottom: 0; }
|
||||
.faq-resposta.ql-content :deep(strong) { font-weight: 600; color: var(--text-color); }
|
||||
.faq-resposta.ql-content :deep(em) { font-style: italic; }
|
||||
.faq-resposta.ql-content :deep(ul),
|
||||
.faq-resposta.ql-content :deep(ol) { padding-left: 1.25rem; margin: 0.4rem 0; }
|
||||
.faq-resposta.ql-content :deep(li) { margin-bottom: 0.2rem; }
|
||||
.faq-resposta.ql-content :deep(a) { color: var(--primary-color); text-decoration: underline; }
|
||||
.faq-resposta.ql-content :deep(blockquote) {
|
||||
border-left: 3px solid var(--surface-border);
|
||||
margin: 0.5rem 0;
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Animação expand */
|
||||
.faq-expand-enter-active,
|
||||
.faq-expand-leave-active {
|
||||
transition: opacity 0.2s ease, max-height 0.25s ease;
|
||||
max-height: 800px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.faq-expand-enter-from,
|
||||
.faq-expand-leave-to {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
}
|
||||
|
||||
/* ── Responsivo ─────────────────────────────────────────────── */
|
||||
@media (max-width: 640px) {
|
||||
.faq-body { flex-direction: column; padding: 1rem; }
|
||||
.faq-sidebar { width: 100%; position: static; flex-direction: row; flex-wrap: wrap; gap: 0.375rem; }
|
||||
.faq-sidebar-title { display: none; }
|
||||
.faq-cat-btn { width: auto; padding: 0.3rem 0.625rem; font-size: 0.75rem; }
|
||||
}
|
||||
</style>
|
||||
425
src/views/pages/saas/SaasFeriadosPage.vue
Normal file
425
src/views/pages/saas/SaasFeriadosPage.vue
Normal file
@@ -0,0 +1,425 @@
|
||||
<!-- src/views/pages/saas/SaasFeriadosPage.vue -->
|
||||
<!-- SAAS admin: visualização centralizada de feriados municipais cadastrados pelos tenants -->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import DatePicker from 'primevue/datepicker'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
// ── Estado ───────────────────────────────────────────────────
|
||||
const loading = ref(false)
|
||||
const feriados = ref([])
|
||||
const tenants = ref([])
|
||||
const ano = ref(new Date().getFullYear())
|
||||
const search = ref('')
|
||||
|
||||
// ── Filtros ──────────────────────────────────────────────────
|
||||
const filtroEstado = ref(null)
|
||||
const filtroCidade = ref(null)
|
||||
|
||||
// ── Dialog ───────────────────────────────────────────────────
|
||||
const dlgOpen = ref(false)
|
||||
const saving = ref(false)
|
||||
const form = ref(emptyForm())
|
||||
|
||||
function emptyForm () {
|
||||
return {
|
||||
nome: '',
|
||||
data: null,
|
||||
cidade: '',
|
||||
estado: '',
|
||||
tenant_id: null,
|
||||
observacao: '',
|
||||
bloqueia_sessoes: false
|
||||
}
|
||||
}
|
||||
|
||||
const formValid = computed(() => !!form.value.nome.trim() && !!form.value.data)
|
||||
|
||||
function abrirDialog () {
|
||||
form.value = emptyForm()
|
||||
dlgOpen.value = true
|
||||
}
|
||||
|
||||
function dateToISO (d) {
|
||||
if (!d) return null
|
||||
const dt = d instanceof Date ? d : new Date(d)
|
||||
return `${dt.getFullYear()}-${String(dt.getMonth()+1).padStart(2,'0')}-${String(dt.getDate()).padStart(2,'0')}`
|
||||
}
|
||||
|
||||
async function salvar () {
|
||||
if (!formValid.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
const { data: me } = await supabase.auth.getUser()
|
||||
const payload = {
|
||||
owner_id: me?.user?.id || null,
|
||||
tenant_id: form.value.tenant_id || null,
|
||||
tipo: 'municipal',
|
||||
nome: form.value.nome.trim(),
|
||||
data: dateToISO(form.value.data),
|
||||
cidade: form.value.cidade.trim() || null,
|
||||
estado: form.value.estado.trim() || null,
|
||||
observacao: form.value.observacao.trim() || null,
|
||||
bloqueia_sessoes: form.value.bloqueia_sessoes
|
||||
}
|
||||
const { data, error } = await supabase.from('feriados').insert(payload).select('*, tenants(name)').single()
|
||||
if (error) throw error
|
||||
feriados.value = [...feriados.value, data].sort((a, b) => a.data.localeCompare(b.data))
|
||||
toast.add({ severity: 'success', summary: 'Feriado cadastrado', life: 1800 })
|
||||
dlgOpen.value = false
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3500 })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Load feriados ─────────────────────────────────────────────
|
||||
async function load () {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('feriados')
|
||||
.select('*, tenants(name)')
|
||||
.gte('data', `${ano.value}-01-01`)
|
||||
.lte('data', `${ano.value}-12-31`)
|
||||
.order('data')
|
||||
if (error) throw error
|
||||
feriados.value = data || []
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3500 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Load tenants (para o select do dialog) ────────────────────
|
||||
async function loadTenants () {
|
||||
const { data } = await supabase.from('tenants').select('id, name').order('name')
|
||||
tenants.value = data || []
|
||||
}
|
||||
|
||||
onMounted(() => { load(); loadTenants() })
|
||||
|
||||
// ── Navegação de ano ─────────────────────────────────────────
|
||||
async function anoAnterior () { ano.value--; await load() }
|
||||
async function anoProximo () { ano.value++; await load() }
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────
|
||||
function fmtDate (iso) {
|
||||
if (!iso) return '—'
|
||||
const [y, m, d] = String(iso).split('-')
|
||||
return `${d}/${m}/${y}`
|
||||
}
|
||||
|
||||
// ── Opções de filtro ─────────────────────────────────────────
|
||||
const estadoOptions = computed(() => {
|
||||
const set = new Set(feriados.value.map(f => f.estado).filter(Boolean))
|
||||
return [{ label: 'Todos os estados', value: null }, ...[...set].sort().map(e => ({ label: e, value: e }))]
|
||||
})
|
||||
|
||||
const cidadeOptions = computed(() => {
|
||||
const set = new Set(
|
||||
feriados.value
|
||||
.filter(f => !filtroEstado.value || f.estado === filtroEstado.value)
|
||||
.map(f => f.cidade)
|
||||
.filter(Boolean)
|
||||
)
|
||||
return [{ label: 'Todas as cidades', value: null }, ...[...set].sort().map(c => ({ label: c, value: c }))]
|
||||
})
|
||||
|
||||
const tenantOptions = computed(() => [
|
||||
{ label: 'Sem vínculo (global)', value: null },
|
||||
...tenants.value.map(t => ({ label: t.name, value: t.id }))
|
||||
])
|
||||
|
||||
// ── Lista filtrada ────────────────────────────────────────────
|
||||
const listaFiltrada = computed(() => {
|
||||
let list = feriados.value
|
||||
if (filtroEstado.value) list = list.filter(f => f.estado === filtroEstado.value)
|
||||
if (filtroCidade.value) list = list.filter(f => f.cidade === filtroCidade.value)
|
||||
const q = search.value.trim().toLowerCase()
|
||||
if (q) list = list.filter(f => f.nome.toLowerCase().includes(q) || (f.cidade || '').toLowerCase().includes(q))
|
||||
return list
|
||||
})
|
||||
|
||||
// ── Agrupamento por data ──────────────────────────────────────
|
||||
const agrupados = computed(() => {
|
||||
const map = new Map()
|
||||
for (const f of listaFiltrada.value) {
|
||||
if (!map.has(f.data)) map.set(f.data, [])
|
||||
map.get(f.data).push(f)
|
||||
}
|
||||
return [...map.entries()].sort(([a], [b]) => a.localeCompare(b))
|
||||
})
|
||||
|
||||
// ── Stats ─────────────────────────────────────────────────────
|
||||
const totalFeriados = computed(() => feriados.value.length)
|
||||
const totalTenants = computed(() => new Set(feriados.value.map(f => f.tenant_id).filter(Boolean)).size)
|
||||
const totalMunicipios = computed(() => new Set(feriados.value.map(f => f.cidade).filter(Boolean)).size)
|
||||
|
||||
// ── Excluir ───────────────────────────────────────────────────
|
||||
async function excluir (id) {
|
||||
try {
|
||||
const { error } = await supabase.from('feriados').delete().eq('id', id)
|
||||
if (error) throw error
|
||||
feriados.value = feriados.value.filter(f => f.id !== id)
|
||||
toast.add({ severity: 'success', summary: 'Removido', life: 1500 })
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3000 })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Toast />
|
||||
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
|
||||
<!-- ── Header ─────────────────────────────────────────── -->
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] px-5 py-4">
|
||||
<div>
|
||||
<div class="font-bold text-lg flex items-center gap-2">
|
||||
<i class="pi pi-star text-amber-500" />
|
||||
Feriados Municipais
|
||||
</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">
|
||||
Feriados cadastrados pelos tenants — alimentam o banco central de feriados do SAAS.
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button icon="pi pi-chevron-left" text rounded severity="secondary" @click="anoAnterior" />
|
||||
<span class="font-bold text-lg w-14 text-center">{{ ano }}</span>
|
||||
<Button icon="pi pi-chevron-right" text rounded severity="secondary" @click="anoProximo" />
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined rounded :loading="loading" @click="load" />
|
||||
<Button icon="pi pi-plus" label="Cadastrar feriado" class="rounded-full" @click="abrirDialog" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Stats ──────────────────────────────────────────── -->
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 text-center">
|
||||
<div class="text-2xl font-bold text-amber-500">{{ totalFeriados }}</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Total de feriados</div>
|
||||
</div>
|
||||
<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">{{ totalTenants }}</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Tenants contribuintes</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 text-center">
|
||||
<div class="text-2xl font-bold text-green-500">{{ totalMunicipios }}</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Municípios</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Filtros ─────────────────────────────────────────── -->
|
||||
<div class="flex flex-wrap gap-3 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] px-4 py-3">
|
||||
<div class="flex-1 min-w-[160px]">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText v-model="search" class="w-full" placeholder="Buscar feriado ou cidade…" />
|
||||
</IconField>
|
||||
</div>
|
||||
<Select
|
||||
v-model="filtroEstado"
|
||||
:options="estadoOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
class="min-w-[160px]"
|
||||
@change="filtroCidade = null"
|
||||
/>
|
||||
<Select
|
||||
v-model="filtroCidade"
|
||||
:options="cidadeOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
class="min-w-[180px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── 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>
|
||||
|
||||
<div v-if="!agrupados.length" class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-8 text-center text-[var(--text-color-secondary)]">
|
||||
Nenhum feriado municipal cadastrado para {{ ano }}.
|
||||
</div>
|
||||
|
||||
<!-- ── Lista agrupada por data ───────────────────────── -->
|
||||
<div v-for="[data, lista] in agrupados" :key="data" class="blk-group">
|
||||
<div class="blk-group__head">
|
||||
<span class="font-mono text-sm">{{ fmtDate(data) }}</span>
|
||||
<span class="blk-group__count">{{ lista.length }}</span>
|
||||
</div>
|
||||
|
||||
<div class="blk-list">
|
||||
<div v-for="f in lista" :key="f.id" class="blk-item">
|
||||
<div class="blk-item__name">{{ f.nome }}</div>
|
||||
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<Tag v-if="f.cidade" :value="f.cidade" severity="secondary" class="text-xs" />
|
||||
<Tag v-if="f.estado" :value="f.estado" severity="info" class="text-xs" />
|
||||
<Tag v-if="f.bloqueia_sessoes" value="Bloqueia" severity="danger" class="text-xs" />
|
||||
</div>
|
||||
|
||||
<div v-if="f.tenants?.name" class="blk-item__tenant">
|
||||
<i class="pi pi-building text-xs" /> {{ f.tenants.name }}
|
||||
</div>
|
||||
|
||||
<div v-if="f.observacao" class="blk-item__obs">{{ f.observacao }}</div>
|
||||
|
||||
<div class="blk-item__actions">
|
||||
<Button icon="pi pi-trash" text rounded size="small" severity="danger" @click="excluir(f.id)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ══ Dialog cadastro ════════════════════════════════════ -->
|
||||
<Dialog
|
||||
v-model:visible="dlgOpen"
|
||||
modal
|
||||
:draggable="false"
|
||||
header="Cadastrar feriado"
|
||||
:style="{ width: '460px' }"
|
||||
>
|
||||
<div class="flex flex-col gap-4 pt-1">
|
||||
|
||||
<div>
|
||||
<label class="dlg-label">Nome do feriado *</label>
|
||||
<InputText v-model="form.nome" class="w-full mt-1" placeholder="Ex.: Padroeiro Municipal, Aniversário da cidade…" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="dlg-label">Data *</label>
|
||||
<DatePicker
|
||||
v-model="form.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 class="flex gap-3">
|
||||
<div class="flex-1">
|
||||
<label class="dlg-label">Cidade</label>
|
||||
<InputText v-model="form.cidade" class="w-full mt-1" placeholder="Ex.: São Paulo" />
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<label class="dlg-label">Estado (UF)</label>
|
||||
<InputText v-model="form.estado" class="w-full mt-1" placeholder="SP" maxlength="2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="dlg-label">Vincular a um tenant <span class="opacity-60">(opcional)</span></label>
|
||||
<Select
|
||||
v-model="form.tenant_id"
|
||||
:options="tenantOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
class="w-full mt-1"
|
||||
placeholder="Sem vínculo (global)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="dlg-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…" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox v-model="form.bloqueia_sessoes" :binary="true" inputId="bloqueia" />
|
||||
<label for="bloqueia" class="text-sm cursor-pointer">Bloqueia sessões neste dia</label>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Cancelar" severity="secondary" outlined @click="dlgOpen = false" />
|
||||
<Button
|
||||
label="Cadastrar"
|
||||
icon="pi pi-check"
|
||||
:disabled="!formValid"
|
||||
:loading="saving"
|
||||
@click="salvar"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.blk-group {
|
||||
border-radius: 1.25rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
.blk-group__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
font-weight: 600;
|
||||
background: var(--surface-ground);
|
||||
}
|
||||
.blk-group__count {
|
||||
font-size: 0.75rem;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 999px;
|
||||
padding: 1px 8px;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
.blk-list { display: flex; flex-direction: column; }
|
||||
.blk-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.blk-item:last-child { border-bottom: none; }
|
||||
.blk-item:hover { background: var(--surface-hover); }
|
||||
.blk-item__name {
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
}
|
||||
.blk-item__tenant {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-color-secondary);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.blk-item__obs {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-color-secondary);
|
||||
width: 100%;
|
||||
font-style: italic;
|
||||
}
|
||||
.blk-item__actions { margin-left: auto; }
|
||||
|
||||
.dlg-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-color-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
364
src/views/pages/saas/SaasSupportPage.vue
Normal file
364
src/views/pages/saas/SaasSupportPage.vue
Normal file
@@ -0,0 +1,364 @@
|
||||
<template>
|
||||
<div class="saas-support p-4 md:p-6">
|
||||
<Toast />
|
||||
|
||||
<!-- Cabeçalho -->
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-xl bg-orange-100 dark:bg-orange-900/30">
|
||||
<i class="pi pi-headphones text-orange-600 dark:text-orange-400 text-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-surface-900 dark:text-surface-0 m-0">Suporte Técnico</h1>
|
||||
<p class="text-sm text-surface-500 m-0">Gere links seguros para acessar a agenda de um cliente em modo debug</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card: Gerar nova sessão -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="card">
|
||||
<h2 class="text-base font-semibold mb-4 flex items-center gap-2">
|
||||
<i class="pi pi-plus-circle text-primary" />
|
||||
Nova Sessão de Suporte
|
||||
</h2>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Seleção de tenant -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium text-surface-700 dark:text-surface-300">Selecionar Cliente (Tenant)</label>
|
||||
<Select
|
||||
v-model="selectedTenantId"
|
||||
:options="tenants"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
placeholder="Buscar tenant..."
|
||||
filter
|
||||
:loading="loadingTenants"
|
||||
class="w-full"
|
||||
empty-filter-message="Nenhum tenant encontrado"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- TTL -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium text-surface-700 dark:text-surface-300">Duração do Acesso</label>
|
||||
<Select
|
||||
v-model="ttlMinutes"
|
||||
:options="ttlOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Botão -->
|
||||
<Button
|
||||
label="Ativar Modo Suporte"
|
||||
icon="pi pi-shield"
|
||||
severity="warning"
|
||||
:loading="creating"
|
||||
:disabled="!selectedTenantId"
|
||||
class="w-full"
|
||||
@click="handleCreate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card: URL Gerada -->
|
||||
<div class="card">
|
||||
<h2 class="text-base font-semibold mb-4 flex items-center gap-2">
|
||||
<i class="pi pi-link text-primary" />
|
||||
URL de Suporte Gerada
|
||||
</h2>
|
||||
|
||||
<div v-if="generatedUrl" class="flex flex-col gap-3">
|
||||
<!-- URL -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium text-surface-700 dark:text-surface-300">Link de Acesso</label>
|
||||
<div class="flex gap-2">
|
||||
<InputText
|
||||
:value="generatedUrl"
|
||||
readonly
|
||||
class="flex-1 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-copy"
|
||||
severity="secondary"
|
||||
outlined
|
||||
v-tooltip.top="'Copiar URL'"
|
||||
@click="copyUrl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expira em -->
|
||||
<div class="flex items-center gap-2 text-sm text-surface-500">
|
||||
<i class="pi pi-clock text-orange-500" />
|
||||
<span>Expira em: <strong class="text-surface-700 dark:text-surface-300">{{ expiresLabel }}</strong></span>
|
||||
</div>
|
||||
|
||||
<!-- Token (reduzido) -->
|
||||
<div class="flex items-center gap-2 text-xs text-surface-400 font-mono">
|
||||
<i class="pi pi-key" />
|
||||
<span>{{ tokenPreview }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Instruções -->
|
||||
<Message severity="info" :closable="false" class="text-sm">
|
||||
Envie este link ao terapeuta ou acesse diretamente para ver os logs da agenda.
|
||||
O link expira automaticamente.
|
||||
</Message>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col items-center justify-center py-10 text-surface-400 gap-2">
|
||||
<i class="pi pi-shield text-4xl opacity-30" />
|
||||
<span class="text-sm">Nenhuma sessão gerada ainda</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sessões ativas -->
|
||||
<div class="card mt-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-base font-semibold flex items-center gap-2 m-0">
|
||||
<i class="pi pi-list text-primary" />
|
||||
Sessões Ativas
|
||||
</h2>
|
||||
<Button
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
outlined
|
||||
size="small"
|
||||
:loading="loadingSessions"
|
||||
@click="loadActiveSessions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:value="activeSessions"
|
||||
:loading="loadingSessions"
|
||||
empty-message="Nenhuma sessão ativa no momento"
|
||||
size="small"
|
||||
striped-rows
|
||||
>
|
||||
<Column field="tenant_id" header="Tenant ID">
|
||||
<template #body="{ data }">
|
||||
<span class="font-mono text-xs">{{ data.tenant_id }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Token">
|
||||
<template #body="{ data }">
|
||||
<span class="font-mono text-xs">{{ data.token.slice(0, 16) }}…</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Expira em">
|
||||
<template #body="{ data }">
|
||||
<span :class="isExpiringSoon(data.expires_at) ? 'text-orange-500 font-semibold' : ''">
|
||||
{{ formatExpires(data.expires_at) }}
|
||||
</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Criada">
|
||||
<template #body="{ data }">
|
||||
{{ formatDate(data.created_at) }}
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Ações">
|
||||
<template #body="{ data }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
icon="pi pi-copy"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
outlined
|
||||
v-tooltip.top="'Copiar URL'"
|
||||
@click="copySessionUrl(data.token)"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
size="small"
|
||||
severity="danger"
|
||||
outlined
|
||||
v-tooltip.top="'Revogar'"
|
||||
:loading="revokingToken === data.token"
|
||||
@click="handleRevoke(data.token)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import {
|
||||
createSupportSession,
|
||||
listActiveSupportSessions,
|
||||
revokeSupportSession,
|
||||
buildSupportUrl,
|
||||
} from '@/support/supportSessionService'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
// ── Estado ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const selectedTenantId = ref(null)
|
||||
const ttlMinutes = ref(60)
|
||||
const creating = ref(false)
|
||||
const loadingTenants = ref(false)
|
||||
const loadingSessions = ref(false)
|
||||
const revokingToken = ref(null)
|
||||
|
||||
const tenants = ref([])
|
||||
const activeSessions = ref([])
|
||||
|
||||
const generatedUrl = ref(null)
|
||||
const generatedData = ref(null) // { token, expires_at }
|
||||
|
||||
// ── Opções de TTL ──────────────────────────────────────────────────────────
|
||||
|
||||
const ttlOptions = [
|
||||
{ label: '30 minutos', value: 30 },
|
||||
{ label: '60 minutos', value: 60 },
|
||||
{ label: '2 horas', value: 120 },
|
||||
]
|
||||
|
||||
// ── Computed ───────────────────────────────────────────────────────────────
|
||||
|
||||
const expiresLabel = computed(() => {
|
||||
if (!generatedData.value?.expires_at) return ''
|
||||
return new Date(generatedData.value.expires_at).toLocaleString('pt-BR')
|
||||
})
|
||||
|
||||
const tokenPreview = computed(() => {
|
||||
if (!generatedData.value?.token) return ''
|
||||
const t = generatedData.value.token
|
||||
return `${t.slice(0, 8)}…${t.slice(-8)}`
|
||||
})
|
||||
|
||||
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
||||
|
||||
onMounted(() => {
|
||||
loadTenants()
|
||||
loadActiveSessions()
|
||||
})
|
||||
|
||||
// ── Métodos ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadTenants () {
|
||||
loadingTenants.value = true
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('tenants')
|
||||
.select('id, name, kind')
|
||||
.order('name', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
|
||||
tenants.value = (data || []).map(t => ({
|
||||
value: t.id,
|
||||
label: `${t.name} (${t.kind ?? 'tenant'})`,
|
||||
}))
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 })
|
||||
} finally {
|
||||
loadingTenants.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadActiveSessions () {
|
||||
loadingSessions.value = true
|
||||
try {
|
||||
activeSessions.value = await listActiveSupportSessions()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 })
|
||||
} finally {
|
||||
loadingSessions.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate () {
|
||||
if (!selectedTenantId.value) return
|
||||
creating.value = true
|
||||
generatedUrl.value = null
|
||||
generatedData.value = null
|
||||
|
||||
try {
|
||||
const result = await createSupportSession(selectedTenantId.value, ttlMinutes.value)
|
||||
generatedData.value = result
|
||||
generatedUrl.value = buildSupportUrl(result.token)
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Sessão criada',
|
||||
detail: 'URL de suporte gerada com sucesso.',
|
||||
life: 4000,
|
||||
})
|
||||
|
||||
await loadActiveSessions()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao criar sessão', detail: e?.message, life: 5000 })
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevoke (token) {
|
||||
revokingToken.value = token
|
||||
try {
|
||||
await revokeSupportSession(token)
|
||||
toast.add({ severity: 'success', summary: 'Sessão revogada', life: 3000 })
|
||||
if (generatedData.value?.token === token) {
|
||||
generatedUrl.value = null
|
||||
generatedData.value = null
|
||||
}
|
||||
await loadActiveSessions()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao revogar', detail: e?.message, life: 4000 })
|
||||
} finally {
|
||||
revokingToken.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function copyUrl () {
|
||||
if (!generatedUrl.value) return
|
||||
navigator.clipboard.writeText(generatedUrl.value)
|
||||
toast.add({ severity: 'info', summary: 'Copiado!', detail: 'URL copiada para a área de transferência.', life: 2000 })
|
||||
}
|
||||
|
||||
function copySessionUrl (token) {
|
||||
const url = buildSupportUrl(token)
|
||||
navigator.clipboard.writeText(url)
|
||||
toast.add({ severity: 'info', summary: 'Copiado!', life: 2000 })
|
||||
}
|
||||
|
||||
// ── Formatação ─────────────────────────────────────────────────────────────
|
||||
|
||||
function formatDate (iso) {
|
||||
if (!iso) return '-'
|
||||
return new Date(iso).toLocaleString('pt-BR')
|
||||
}
|
||||
|
||||
function formatExpires (iso) {
|
||||
if (!iso) return '-'
|
||||
const d = new Date(iso)
|
||||
const now = new Date()
|
||||
const diffMin = Math.round((d - now) / 60000)
|
||||
if (diffMin < 0) return 'Expirada'
|
||||
if (diffMin < 60) return `em ${diffMin} min`
|
||||
return new Date(iso).toLocaleString('pt-BR')
|
||||
}
|
||||
|
||||
function isExpiringSoon (iso) {
|
||||
if (!iso) return false
|
||||
const diffMin = (new Date(iso) - new Date()) / 60000
|
||||
return diffMin > 0 && diffMin < 15
|
||||
}
|
||||
</script>
|
||||
360
src/views/pages/therapist/RelatoriosPage.vue
Normal file
360
src/views/pages/therapist/RelatoriosPage.vue
Normal file
@@ -0,0 +1,360 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
import { useLayout } from '@/layout/composables/layout'
|
||||
|
||||
const { layoutConfig, isDarkTheme } = useLayout()
|
||||
const tenantStore = useTenantStore()
|
||||
|
||||
// ─── período ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const PERIODS = [
|
||||
{ label: 'Esta semana', value: 'week' },
|
||||
{ label: 'Este mês', value: 'month' },
|
||||
{ label: 'Últimos 3 meses', value: '3months' },
|
||||
{ label: 'Últimos 6 meses', value: '6months' },
|
||||
]
|
||||
|
||||
const selectedPeriod = ref('month')
|
||||
|
||||
function periodRange (period) {
|
||||
const now = new Date()
|
||||
let start, end
|
||||
|
||||
if (period === 'week') {
|
||||
const dow = now.getDay() // 0=Dom
|
||||
start = new Date(now)
|
||||
start.setDate(now.getDate() - dow)
|
||||
start.setHours(0, 0, 0, 0)
|
||||
end = new Date(now)
|
||||
end.setHours(23, 59, 59, 999)
|
||||
} else if (period === 'month') {
|
||||
start = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0)
|
||||
end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999)
|
||||
} else if (period === '3months') {
|
||||
start = new Date(now.getFullYear(), now.getMonth() - 2, 1, 0, 0, 0, 0)
|
||||
end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999)
|
||||
} else if (period === '6months') {
|
||||
start = new Date(now.getFullYear(), now.getMonth() - 5, 1, 0, 0, 0, 0)
|
||||
end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999)
|
||||
}
|
||||
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
// ─── dados ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const loading = ref(false)
|
||||
const sessions = ref([])
|
||||
const loadError = ref('')
|
||||
|
||||
async function loadSessions () {
|
||||
const uid = tenantStore.user?.id || null
|
||||
const tenantId = tenantStore.activeTenantId || null
|
||||
if (!uid || !tenantId) return
|
||||
|
||||
const { start, end } = periodRange(selectedPeriod.value)
|
||||
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
sessions.value = []
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('id, inicio_em, fim_em, status, modalidade, tipo, titulo, titulo_custom, patient_id, patients(nome_completo)')
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('owner_id', uid)
|
||||
.gte('inicio_em', start.toISOString())
|
||||
.lte('inicio_em', end.toISOString())
|
||||
.order('inicio_em', { ascending: false })
|
||||
.limit(500)
|
||||
|
||||
if (error) throw error
|
||||
sessions.value = data || []
|
||||
} catch (e) {
|
||||
loadError.value = e?.message || 'Falha ao carregar relatório.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── métricas ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const total = computed(() => sessions.value.length)
|
||||
const realizadas = computed(() => sessions.value.filter(s => s.status === 'realizado').length)
|
||||
const faltas = computed(() => sessions.value.filter(s => s.status === 'faltou').length)
|
||||
const canceladas = computed(() => sessions.value.filter(s => s.status === 'cancelado').length)
|
||||
const agendadas = computed(() => sessions.value.filter(s => !s.status || s.status === 'agendado').length)
|
||||
const remarcadas = computed(() => sessions.value.filter(s => s.status === 'remarcado').length)
|
||||
|
||||
// ─── gráfico (sessions por semana/mês) ───────────────────────────────────────
|
||||
|
||||
function isoWeek (d) {
|
||||
const dt = new Date(d)
|
||||
const day = dt.getDay() || 7
|
||||
dt.setDate(dt.getDate() + 4 - day)
|
||||
const yearStart = new Date(dt.getFullYear(), 0, 1)
|
||||
const wk = Math.ceil((((dt - yearStart) / 86400000) + 1) / 7)
|
||||
return `${dt.getFullYear()}-S${String(wk).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function isoMonth (d) {
|
||||
const dt = new Date(d)
|
||||
const yy = dt.getFullYear()
|
||||
const mm = String(dt.getMonth() + 1).padStart(2, '0')
|
||||
return `${yy}-${mm}`
|
||||
}
|
||||
|
||||
function monthLabel (key) {
|
||||
const [y, m] = key.split('-')
|
||||
const names = ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun', 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez']
|
||||
return `${names[Number(m) - 1]}/${y}`
|
||||
}
|
||||
|
||||
const chartData = computed(() => {
|
||||
const groupBy = selectedPeriod.value === 'week' ? isoWeek : isoMonth
|
||||
const labelFn = selectedPeriod.value === 'week'
|
||||
? k => k
|
||||
: monthLabel
|
||||
|
||||
const buckets = {}
|
||||
for (const s of sessions.value) {
|
||||
const key = groupBy(s.inicio_em)
|
||||
if (!buckets[key]) buckets[key] = { realizado: 0, faltou: 0, cancelado: 0, outros: 0 }
|
||||
const st = s.status || 'agendado'
|
||||
if (st === 'realizado') buckets[key].realizado++
|
||||
else if (st === 'faltou') buckets[key].faltou++
|
||||
else if (st === 'cancelado') buckets[key].cancelado++
|
||||
else buckets[key].outros++
|
||||
}
|
||||
|
||||
const keys = Object.keys(buckets).sort()
|
||||
const labels = keys.map(labelFn)
|
||||
const ds = getComputedStyle(document.documentElement)
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Realizadas',
|
||||
backgroundColor: '#22c55e',
|
||||
data: keys.map(k => buckets[k].realizado),
|
||||
barThickness: 20,
|
||||
},
|
||||
{
|
||||
label: 'Faltas',
|
||||
backgroundColor: '#ef4444',
|
||||
data: keys.map(k => buckets[k].faltou),
|
||||
barThickness: 20,
|
||||
},
|
||||
{
|
||||
label: 'Canceladas',
|
||||
backgroundColor: '#f97316',
|
||||
data: keys.map(k => buckets[k].cancelado),
|
||||
barThickness: 20,
|
||||
},
|
||||
{
|
||||
label: 'Outros',
|
||||
backgroundColor: ds.getPropertyValue('--p-primary-300') || '#93c5fd',
|
||||
data: keys.map(k => buckets[k].outros),
|
||||
barThickness: 20,
|
||||
},
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const chartOptions = computed(() => {
|
||||
const ds = getComputedStyle(document.documentElement)
|
||||
const borderColor = ds.getPropertyValue('--surface-border') || '#e2e8f0'
|
||||
const textMutedColor = ds.getPropertyValue('--text-color-secondary') || '#64748b'
|
||||
return {
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { labels: { color: textMutedColor } }
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
ticks: { color: textMutedColor },
|
||||
grid: { color: 'transparent' }
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
ticks: { color: textMutedColor, precision: 0 },
|
||||
grid: { color: borderColor, drawTicks: false }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ─── tabela ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const STATUS_LABEL = {
|
||||
agendado: 'Agendado',
|
||||
realizado: 'Realizado',
|
||||
faltou: 'Faltou',
|
||||
cancelado: 'Cancelado',
|
||||
remarcado: 'Remarcado',
|
||||
bloqueado: 'Bloqueado',
|
||||
}
|
||||
|
||||
const STATUS_SEVERITY = {
|
||||
agendado: 'info',
|
||||
realizado: 'success',
|
||||
faltou: 'danger',
|
||||
cancelado: 'warn',
|
||||
remarcado: 'secondary',
|
||||
bloqueado: 'secondary',
|
||||
}
|
||||
|
||||
function fmtDateTimeBR (iso) {
|
||||
if (!iso) return '—'
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
const dd = String(d.getDate()).padStart(2, '0')
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const yy = d.getFullYear()
|
||||
const hh = String(d.getHours()).padStart(2, '0')
|
||||
const mi = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${dd}/${mm}/${yy} ${hh}:${mi}`
|
||||
}
|
||||
|
||||
function sessionTitle (s) {
|
||||
return s.titulo_custom || s.titulo || (s.tipo ? s.tipo : 'Sessão')
|
||||
}
|
||||
|
||||
function patientName (s) {
|
||||
return s.patients?.nome_completo || '—'
|
||||
}
|
||||
|
||||
// taxa de realização
|
||||
const taxaRealizacao = computed(() => {
|
||||
const denom = realizadas.value + faltas.value + canceladas.value
|
||||
if (!denom) return null
|
||||
return Math.round((realizadas.value / denom) * 100)
|
||||
})
|
||||
|
||||
// ─── watch & mount ────────────────────────────────────────────────────────────
|
||||
|
||||
watch(selectedPeriod, loadSessions)
|
||||
watch([() => layoutConfig.primary, () => layoutConfig.surface, isDarkTheme], () => {})
|
||||
|
||||
onMounted(loadSessions)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 p-4">
|
||||
<!-- Cabeçalho -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-800">Relatórios</h1>
|
||||
<p class="text-sm text-slate-500 mt-1">Visão geral das suas sessões</p>
|
||||
</div>
|
||||
|
||||
<SelectButton
|
||||
v-model="selectedPeriod"
|
||||
:options="PERIODS"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
:allow-empty="false"
|
||||
class="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Erro -->
|
||||
<Message v-if="loadError" severity="error">{{ loadError }}</Message>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex items-center gap-2 text-slate-500">
|
||||
<i class="pi pi-spin pi-spinner" /> Carregando…
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Cards de resumo -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-3">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 flex flex-col gap-1">
|
||||
<span class="text-xs text-slate-500 uppercase tracking-wide">Total</span>
|
||||
<span class="text-3xl font-bold text-slate-800">{{ total }}</span>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-green-100 bg-green-50 p-4 flex flex-col gap-1">
|
||||
<span class="text-xs text-green-700 uppercase tracking-wide">Realizadas</span>
|
||||
<span class="text-3xl font-bold text-green-700">{{ realizadas }}</span>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-red-100 bg-red-50 p-4 flex flex-col gap-1">
|
||||
<span class="text-xs text-red-600 uppercase tracking-wide">Faltas</span>
|
||||
<span class="text-3xl font-bold text-red-600">{{ faltas }}</span>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-orange-100 bg-orange-50 p-4 flex flex-col gap-1">
|
||||
<span class="text-xs text-orange-600 uppercase tracking-wide">Canceladas</span>
|
||||
<span class="text-3xl font-bold text-orange-600">{{ canceladas }}</span>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-blue-100 bg-blue-50 p-4 flex flex-col gap-1">
|
||||
<span class="text-xs text-blue-600 uppercase tracking-wide">Agendadas</span>
|
||||
<span class="text-3xl font-bold text-blue-600">{{ agendadas }}</span>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 flex flex-col gap-1">
|
||||
<span class="text-xs text-slate-500 uppercase tracking-wide">Taxa realização</span>
|
||||
<span class="text-3xl font-bold text-slate-800">
|
||||
{{ taxaRealizacao != null ? `${taxaRealizacao}%` : '—' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gráfico -->
|
||||
<div v-if="total > 0" class="rounded-2xl border border-slate-200 bg-white p-4">
|
||||
<h2 class="text-base font-semibold text-slate-700 mb-4">
|
||||
Sessões por {{ selectedPeriod === 'week' ? 'semana' : 'mês' }}
|
||||
</h2>
|
||||
<Chart type="bar" :data="chartData" :options="chartOptions" class="h-64" />
|
||||
</div>
|
||||
|
||||
<!-- Tabela -->
|
||||
<div class="rounded-2xl border border-slate-200 bg-white overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-slate-100 flex items-center justify-between">
|
||||
<h2 class="text-base font-semibold text-slate-700">Sessões no período</h2>
|
||||
<span class="text-sm text-slate-500">{{ total }} registro{{ total !== 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!sessions.length" class="px-4 py-8 text-center text-slate-500 text-sm">
|
||||
Nenhuma sessão encontrada para o período selecionado.
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
v-else
|
||||
:value="sessions"
|
||||
:rows="20"
|
||||
paginator
|
||||
:rows-per-page-options="[10, 20, 50]"
|
||||
scrollable
|
||||
scroll-height="480px"
|
||||
class="text-sm"
|
||||
>
|
||||
<Column field="inicio_em" header="Data / Hora" :sortable="true" style="min-width: 140px">
|
||||
<template #body="{ data }">{{ fmtDateTimeBR(data.inicio_em) }}</template>
|
||||
</Column>
|
||||
<Column header="Paciente" style="min-width: 160px">
|
||||
<template #body="{ data }">{{ patientName(data) }}</template>
|
||||
</Column>
|
||||
<Column header="Sessão" style="min-width: 160px">
|
||||
<template #body="{ data }">{{ sessionTitle(data) }}</template>
|
||||
</Column>
|
||||
<Column field="modalidade" header="Modalidade" style="min-width: 110px">
|
||||
<template #body="{ data }">
|
||||
{{ data.modalidade === 'online' ? 'Online' : data.modalidade === 'presencial' ? 'Presencial' : data.modalidade || '—' }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="status" header="Status" style="min-width: 110px">
|
||||
<template #body="{ data }">
|
||||
<Tag
|
||||
:value="STATUS_LABEL[data.status] || data.status || 'Agendado'"
|
||||
:severity="STATUS_SEVERITY[data.status] || 'info'"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,198 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
const items = ref([
|
||||
{
|
||||
label: 'Update',
|
||||
icon: 'pi pi-refresh'
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
icon: 'pi pi-times'
|
||||
},
|
||||
{
|
||||
separator: true
|
||||
},
|
||||
{
|
||||
label: 'Home',
|
||||
icon: 'pi pi-home'
|
||||
}
|
||||
]);
|
||||
|
||||
const loading = ref([false, false, false]);
|
||||
|
||||
function load(index) {
|
||||
loading.value[index] = true;
|
||||
setTimeout(() => (loading.value[index] = false), 1000);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col md:flex-row gap-8">
|
||||
<div class="md:w-1/2">
|
||||
<div class="card flex flex-col gap-4">
|
||||
<div class="font-semibold text-xl">Default</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button label="Submit"></Button>
|
||||
<Button label="Disabled" :disabled="true"></Button>
|
||||
<Button label="Link" class="p-button-link" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="card flex flex-col gap-4">
|
||||
<div class="font-semibold text-xl">Severities</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button label="Primary" />
|
||||
<Button label="Secondary" severity="secondary" />
|
||||
<Button label="Success" severity="success" />
|
||||
<Button label="Info" severity="info" />
|
||||
<Button label="Warn" severity="warn" />
|
||||
<Button label="Help" severity="help" />
|
||||
<Button label="Danger" severity="danger" />
|
||||
<Button label="Contrast" severity="contrast" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="card flex flex-col gap-4">
|
||||
<div class="font-semibold text-xl">Text</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button label="Primary" text />
|
||||
<Button label="Secondary" severity="secondary" text />
|
||||
<Button label="Success" severity="success" text />
|
||||
<Button label="Info" severity="info" text />
|
||||
<Button label="Warn" severity="warn" text />
|
||||
<Button label="Help" severity="help" text />
|
||||
<Button label="Danger" severity="danger" text />
|
||||
<Button label="Plain" plain text />
|
||||
</div>
|
||||
</div>
|
||||
<div class="card flex flex-col gap-4">
|
||||
<div class="font-semibold text-xl">Outlined</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button label="Primary" outlined />
|
||||
<Button label="Secondary" severity="secondary" outlined />
|
||||
<Button label="Success" severity="success" outlined />
|
||||
<Button label="Info" severity="info" outlined />
|
||||
<Button label="warn" severity="warn" outlined />
|
||||
<Button label="Help" severity="help" outlined />
|
||||
<Button label="Danger" severity="danger" outlined />
|
||||
<Button label="Contrast" severity="contrast" outlined />
|
||||
</div>
|
||||
</div>
|
||||
<div class="card flex flex-col gap-4">
|
||||
<div class="font-semibold text-xl">Group</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<ButtonGroup>
|
||||
<Button label="Save" icon="pi pi-check" />
|
||||
<Button label="Delete" icon="pi pi-trash" />
|
||||
<Button label="Cancel" icon="pi pi-times" />
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card flex flex-col gap-4">
|
||||
<div class="font-semibold text-xl">SplitButton</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<SplitButton label="Save" :model="items"></SplitButton>
|
||||
<SplitButton label="Save" :model="items" severity="secondary"></SplitButton>
|
||||
<SplitButton label="Save" :model="items" severity="success"></SplitButton>
|
||||
<SplitButton label="Save" :model="items" severity="info"></SplitButton>
|
||||
<SplitButton label="Save" :model="items" severity="warn"></SplitButton>
|
||||
<SplitButton label="Save" :model="items" severity="help"></SplitButton>
|
||||
<SplitButton label="Save" :model="items" severity="danger"></SplitButton>
|
||||
<SplitButton label="Save" :model="items" severity="contrast"></SplitButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card flex flex-col gap-4">
|
||||
<div class="font-semibold text-xl">Templating</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button type="button">
|
||||
<img alt="logo" src="/demo/images/logo-white.svg" style="width: 1.5rem" />
|
||||
</Button>
|
||||
<Button type="button" outlined severity="success">
|
||||
<img alt="logo" src="/demo/images/logo.svg" style="width: 1.5rem" />
|
||||
<span class="ml-2 text-bold">PrimeVue</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="md:w-1/2">
|
||||
<div class="card flex flex-col gap-4">
|
||||
<div class="font-semibold text-xl">Icons</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button icon="pi pi-star-fill" class="mr-2 mb-2"></Button>
|
||||
<Button label="Bookmark" icon="pi pi-bookmark" class="mr-2 mb-2"></Button>
|
||||
<Button label="Bookmark" icon="pi pi-bookmark" iconPos="right" class="mr-2 mb-2"></Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card flex flex-col gap-4">
|
||||
<div class="font-semibold text-xl">Raised</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button label="Primary" raised />
|
||||
<Button label="Secondary" severity="secondary" raised />
|
||||
<Button label="Success" severity="success" raised />
|
||||
<Button label="Info" severity="info" raised />
|
||||
<Button label="Warn" severity="warn" raised />
|
||||
<Button label="Help" severity="help" raised />
|
||||
<Button label="Danger" severity="danger" raised />
|
||||
<Button label="Contrast" severity="contrast" raised />
|
||||
</div>
|
||||
</div>
|
||||
<div class="card flex flex-col gap-4">
|
||||
<div class="font-semibold text-xl">Rounded</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button label="Primary" rounded />
|
||||
<Button label="Secondary" severity="secondary" rounded />
|
||||
<Button label="Success" severity="success" rounded />
|
||||
<Button label="Info" severity="info" rounded />
|
||||
<Button label="Warn" severity="warn" rounded />
|
||||
<Button label="Help" severity="help" rounded />
|
||||
<Button label="Danger" severity="danger" rounded />
|
||||
<Button label="Contrast" severity="contrast" rounded />
|
||||
</div>
|
||||
</div>
|
||||
<div class="card flex flex-col gap-4">
|
||||
<div class="font-semibold text-xl">Rounded Icons</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button icon="pi pi-check" rounded />
|
||||
<Button icon="pi pi-bookmark" severity="secondary" rounded />
|
||||
<Button icon="pi pi-search" severity="success" rounded />
|
||||
<Button icon="pi pi-user" severity="info" rounded />
|
||||
<Button icon="pi pi-bell" severity="warn" rounded />
|
||||
<Button icon="pi pi-heart" severity="help" rounded />
|
||||
<Button icon="pi pi-times" severity="danger" rounded />
|
||||
</div>
|
||||
</div>
|
||||
<div class="card flex flex-col gap-4">
|
||||
<div class="font-semibold text-xl">Rounded Text</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button icon="pi pi-check" text raised rounded />
|
||||
<Button icon="pi pi-bookmark" severity="secondary" text raised rounded />
|
||||
<Button icon="pi pi-search" severity="success" text raised rounded />
|
||||
<Button icon="pi pi-user" severity="info" text raised rounded />
|
||||
<Button icon="pi pi-bell" severity="warn" text raised rounded />
|
||||
<Button icon="pi pi-heart" severity="help" text raised rounded />
|
||||
<Button icon="pi pi-times" severity="danger" text raised rounded />
|
||||
</div>
|
||||
</div>
|
||||
<div class="card flex flex-col gap-4">
|
||||
<div class="font-semibold text-xl">Rounded Outlined</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button icon="pi pi-check" rounded outlined />
|
||||
<Button icon="pi pi-bookmark" severity="secondary" rounded outlined />
|
||||
<Button icon="pi pi-search" severity="success" rounded outlined />
|
||||
<Button icon="pi pi-user" severity="info" rounded outlined />
|
||||
<Button icon="pi pi-bell" severity="warn" rounded outlined />
|
||||
<Button icon="pi pi-heart" severity="help" rounded outlined />
|
||||
<Button icon="pi pi-times" severity="danger" rounded outlined />
|
||||
</div>
|
||||
</div>
|
||||
<div class="card flex flex-col gap-4">
|
||||
<div class="font-semibold text-xl">Loading</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button type="button" class="mr-2 mb-2" label="Search" icon="pi pi-search" :loading="loading[0]" @click="load(0)" />
|
||||
<Button type="button" class="mr-2 mb-2" label="Search" icon="pi pi-search" iconPos="right" :loading="loading[1]" @click="load(1)" />
|
||||
<Button type="button" class="mr-2 mb-2" icon="pi pi-search" :loading="loading[2]" @click="load(2)" />
|
||||
<Button type="button" class="mr-2 mb-2" label="Search" :loading="loading[3]" @click="load(3)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,269 +0,0 @@
|
||||
<script setup>
|
||||
import { useLayout } from '@/layout/composables/layout';
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
|
||||
const { layoutConfig, isDarkTheme } = useLayout();
|
||||
const lineData = ref(null);
|
||||
const pieData = ref(null);
|
||||
const polarData = ref(null);
|
||||
const barData = ref(null);
|
||||
const radarData = ref(null);
|
||||
const lineOptions = ref(null);
|
||||
const pieOptions = ref(null);
|
||||
const polarOptions = ref(null);
|
||||
const barOptions = ref(null);
|
||||
const radarOptions = ref(null);
|
||||
|
||||
onMounted(() => {
|
||||
setColorOptions();
|
||||
});
|
||||
|
||||
function setColorOptions() {
|
||||
const documentStyle = getComputedStyle(document.documentElement);
|
||||
const textColor = documentStyle.getPropertyValue('--text-color');
|
||||
const textColorSecondary = documentStyle.getPropertyValue('--text-color-secondary');
|
||||
const surfaceBorder = documentStyle.getPropertyValue('--surface-border');
|
||||
|
||||
barData.value = {
|
||||
labels: ['January', 'February', 'March', 'April', 'May', 'June', 'July'],
|
||||
datasets: [
|
||||
{
|
||||
label: 'My First dataset',
|
||||
backgroundColor: documentStyle.getPropertyValue('--p-primary-500'),
|
||||
borderColor: documentStyle.getPropertyValue('--p-primary-500'),
|
||||
data: [65, 59, 80, 81, 56, 55, 40]
|
||||
},
|
||||
{
|
||||
label: 'My Second dataset',
|
||||
backgroundColor: documentStyle.getPropertyValue('--p-primary-200'),
|
||||
borderColor: documentStyle.getPropertyValue('--p-primary-200'),
|
||||
data: [28, 48, 40, 19, 86, 27, 90]
|
||||
}
|
||||
]
|
||||
};
|
||||
barOptions.value = {
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
fontColor: textColor
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: textColorSecondary,
|
||||
font: {
|
||||
weight: 500
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
display: false,
|
||||
drawBorder: false
|
||||
}
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
color: textColorSecondary
|
||||
},
|
||||
grid: {
|
||||
color: surfaceBorder,
|
||||
drawBorder: false
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
pieData.value = {
|
||||
labels: ['A', 'B', 'C'],
|
||||
datasets: [
|
||||
{
|
||||
data: [540, 325, 702],
|
||||
backgroundColor: [documentStyle.getPropertyValue('--p-indigo-500'), documentStyle.getPropertyValue('--p-purple-500'), documentStyle.getPropertyValue('--p-teal-500')],
|
||||
hoverBackgroundColor: [documentStyle.getPropertyValue('--p-indigo-400'), documentStyle.getPropertyValue('--p-purple-400'), documentStyle.getPropertyValue('--p-teal-400')]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
pieOptions.value = {
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
color: textColor
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
lineData.value = {
|
||||
labels: ['January', 'February', 'March', 'April', 'May', 'June', 'July'],
|
||||
datasets: [
|
||||
{
|
||||
label: 'First Dataset',
|
||||
data: [65, 59, 80, 81, 56, 55, 40],
|
||||
fill: false,
|
||||
backgroundColor: documentStyle.getPropertyValue('--p-primary-500'),
|
||||
borderColor: documentStyle.getPropertyValue('--p-primary-500'),
|
||||
tension: 0.4
|
||||
},
|
||||
{
|
||||
label: 'Second Dataset',
|
||||
data: [28, 48, 40, 19, 86, 27, 90],
|
||||
fill: false,
|
||||
backgroundColor: documentStyle.getPropertyValue('--p-primary-200'),
|
||||
borderColor: documentStyle.getPropertyValue('--p-primary-200'),
|
||||
tension: 0.4
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
lineOptions.value = {
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
fontColor: textColor
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: textColorSecondary
|
||||
},
|
||||
grid: {
|
||||
color: surfaceBorder,
|
||||
drawBorder: false
|
||||
}
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
color: textColorSecondary
|
||||
},
|
||||
grid: {
|
||||
color: surfaceBorder,
|
||||
drawBorder: false
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
polarData.value = {
|
||||
datasets: [
|
||||
{
|
||||
data: [11, 16, 7, 3],
|
||||
backgroundColor: [documentStyle.getPropertyValue('--p-indigo-500'), documentStyle.getPropertyValue('--p-purple-500'), documentStyle.getPropertyValue('--p-teal-500'), documentStyle.getPropertyValue('--p-orange-500')],
|
||||
label: 'My dataset'
|
||||
}
|
||||
],
|
||||
labels: ['Indigo', 'Purple', 'Teal', 'Orange']
|
||||
};
|
||||
|
||||
polarOptions.value = {
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: textColor
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
r: {
|
||||
grid: {
|
||||
color: surfaceBorder
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
radarData.value = {
|
||||
labels: ['Eating', 'Drinking', 'Sleeping', 'Designing', 'Coding', 'Cycling', 'Running'],
|
||||
datasets: [
|
||||
{
|
||||
label: 'My First dataset',
|
||||
borderColor: documentStyle.getPropertyValue('--p-indigo-400'),
|
||||
pointBackgroundColor: documentStyle.getPropertyValue('--p-indigo-400'),
|
||||
pointBorderColor: documentStyle.getPropertyValue('--p-indigo-400'),
|
||||
pointHoverBackgroundColor: textColor,
|
||||
pointHoverBorderColor: documentStyle.getPropertyValue('--p-indigo-400'),
|
||||
data: [65, 59, 90, 81, 56, 55, 40]
|
||||
},
|
||||
{
|
||||
label: 'My Second dataset',
|
||||
borderColor: documentStyle.getPropertyValue('--p-purple-400'),
|
||||
pointBackgroundColor: documentStyle.getPropertyValue('--p-purple-400'),
|
||||
pointBorderColor: documentStyle.getPropertyValue('--p-purple-400'),
|
||||
pointHoverBackgroundColor: textColor,
|
||||
pointHoverBorderColor: documentStyle.getPropertyValue('--p-purple-400'),
|
||||
data: [28, 48, 40, 19, 96, 27, 100]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
radarOptions.value = {
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
fontColor: textColor
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
r: {
|
||||
grid: {
|
||||
color: textColorSecondary
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => layoutConfig.primary, () => layoutConfig.surface, isDarkTheme],
|
||||
() => {
|
||||
setColorOptions();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Fluid class="grid grid-cols-12 gap-8">
|
||||
<div class="col-span-12 xl:col-span-6">
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">Linear</div>
|
||||
<Chart type="line" :data="lineData" :options="lineOptions"></Chart>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-12 xl:col-span-6">
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">Bar</div>
|
||||
<Chart type="bar" :data="barData" :options="barOptions"></Chart>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-12 xl:col-span-6">
|
||||
<div class="card flex flex-col items-center">
|
||||
<div class="font-semibold text-xl mb-4">Pie</div>
|
||||
<Chart type="pie" :data="pieData" :options="pieOptions"></Chart>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-12 xl:col-span-6">
|
||||
<div class="card flex flex-col items-center">
|
||||
<div class="font-semibold text-xl mb-4">Doughnut</div>
|
||||
<Chart type="doughnut" :data="pieData" :options="pieOptions"></Chart>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-12 xl:col-span-6">
|
||||
<div class="card flex flex-col items-center">
|
||||
<div class="font-semibold text-xl mb-4">Polar Area</div>
|
||||
<Chart type="polarArea" :data="polarData" :options="polarOptions"></Chart>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-12 xl:col-span-6">
|
||||
<div class="card flex flex-col items-center">
|
||||
<div class="font-semibold text-xl mb-4">Radar</div>
|
||||
<Chart type="radar" :data="radarData" :options="radarOptions"></Chart>
|
||||
</div>
|
||||
</div>
|
||||
</Fluid>
|
||||
</template>
|
||||
@@ -1,36 +0,0 @@
|
||||
<script setup>
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const toast = useToast();
|
||||
const fileupload = ref();
|
||||
|
||||
function upload() {
|
||||
fileupload.value.upload();
|
||||
}
|
||||
|
||||
function onUpload() {
|
||||
toast.add({ severity: 'info', summary: 'Success', detail: 'File Uploaded', life: 3000 });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-12 gap-8">
|
||||
<div class="col-span-full lg:col-span-6">
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">Advanced</div>
|
||||
<FileUpload name="demo[]" @uploader="onUpload" :multiple="true" accept="image/*" :maxFileSize="1000000" customUpload />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-full lg:col-span-6">
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">Basic</div>
|
||||
<div class="card flex flex-col gap-6 items-center justify-center">
|
||||
<Toast />
|
||||
<FileUpload ref="fileupload" mode="basic" name="demo[]" accept="image/*" :maxFileSize="1000000" @uploader="onUpload" customUpload />
|
||||
<Button label="Upload" @click="upload" severity="secondary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,121 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
const dropdownItems = ref([
|
||||
{ name: 'Option 1', code: 'Option 1' },
|
||||
{ name: 'Option 2', code: 'Option 2' },
|
||||
{ name: 'Option 3', code: 'Option 3' }
|
||||
]);
|
||||
|
||||
const dropdownItem = ref(null);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Fluid>
|
||||
<div class="flex flex-col md:flex-row gap-8">
|
||||
<div class="md:w-1/2">
|
||||
<div class="card flex flex-col gap-4">
|
||||
<div class="font-semibold text-xl">Vertical</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="name1">Name</label>
|
||||
<InputText id="name1" type="text" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="email1">Email</label>
|
||||
<InputText id="email1" type="text" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="age1">Age</label>
|
||||
<InputText id="age1" type="text" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card flex flex-col gap-4">
|
||||
<div class="font-semibold text-xl">Vertical Grid</div>
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<div class="flex flex-col grow basis-0 gap-2">
|
||||
<label for="name2">Name</label>
|
||||
<InputText id="name2" type="text" />
|
||||
</div>
|
||||
<div class="flex flex-col grow basis-0 gap-2">
|
||||
<label for="email2">Email</label>
|
||||
<InputText id="email2" type="text" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="md:w-1/2">
|
||||
<div class="card flex flex-col gap-4">
|
||||
<div class="font-semibold text-xl">Horizontal</div>
|
||||
<div class="grid grid-cols-12 gap-2">
|
||||
<label for="name3" class="flex items-center col-span-12 mb-2 md:col-span-2 md:mb-0">Name</label>
|
||||
<div class="col-span-12 md:col-span-10">
|
||||
<InputText id="name3" type="text" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-12 gap-2">
|
||||
<label for="email3" class="flex items-center col-span-12 mb-2 md:col-span-2 md:mb-0">Email</label>
|
||||
<div class="col-span-12 md:col-span-10">
|
||||
<InputText id="email3" type="text" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card flex flex-col gap-4">
|
||||
<div class="font-semibold text-xl">Inline</div>
|
||||
<div class="flex flex-wrap items-start gap-4">
|
||||
<div class="field">
|
||||
<label for="firstname1" class="sr-only">Firstname</label>
|
||||
<InputText id="firstname1" type="text" placeholder="Firstname" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="lastname1" class="sr-only">Lastname</label>
|
||||
<InputText id="lastname1" type="text" placeholder="Lastname" />
|
||||
</div>
|
||||
<Button label="Submit" :fluid="false"></Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card flex flex-col gap-4">
|
||||
<div class="font-semibold text-xl">Help Text</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label for="username">Username</label>
|
||||
<InputText id="username" type="text" />
|
||||
<small>Enter your username to reset your password.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex mt-8">
|
||||
<div class="card flex flex-col gap-4 w-full">
|
||||
<div class="font-semibold text-xl">Advanced</div>
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<div class="flex flex-wrap gap-2 w-full">
|
||||
<label for="firstname2">Firstname</label>
|
||||
<InputText id="firstname2" type="text" />
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 w-full">
|
||||
<label for="lastname2">Lastname</label>
|
||||
<InputText id="lastname2" type="text" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap">
|
||||
<label for="address">Address</label>
|
||||
<Textarea id="address" rows="4" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<div class="flex flex-wrap gap-2 w-full">
|
||||
<label for="state">State</label>
|
||||
<Select id="state" v-model="dropdownItem" :options="dropdownItems" optionLabel="name" placeholder="Select One" class="w-full"></Select>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 w-full">
|
||||
<label for="zip">Zip</label>
|
||||
<InputText id="zip" type="text" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Fluid>
|
||||
</template>
|
||||
@@ -1,248 +0,0 @@
|
||||
<script setup>
|
||||
import { CountryService } from '@/services/CountryService';
|
||||
import { NodeService } from '@/services/NodeService';
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
const floatValue = ref(null);
|
||||
const autoValue = ref(null);
|
||||
const selectedAutoValue = ref(null);
|
||||
const autoFilteredValue = ref([]);
|
||||
const calendarValue = ref(null);
|
||||
const inputNumberValue = ref(null);
|
||||
const sliderValue = ref(50);
|
||||
const ratingValue = ref(null);
|
||||
const colorValue = ref('#1976D2');
|
||||
const radioValue = ref(null);
|
||||
const checkboxValue = ref([]);
|
||||
const switchValue = ref(false);
|
||||
const listboxValues = ref([
|
||||
{ name: 'New York', code: 'NY' },
|
||||
{ name: 'Rome', code: 'RM' },
|
||||
{ name: 'London', code: 'LDN' },
|
||||
{ name: 'Istanbul', code: 'IST' },
|
||||
{ name: 'Paris', code: 'PRS' }
|
||||
]);
|
||||
const listboxValue = ref(null);
|
||||
const dropdownValues = ref([
|
||||
{ name: 'New York', code: 'NY' },
|
||||
{ name: 'Rome', code: 'RM' },
|
||||
{ name: 'London', code: 'LDN' },
|
||||
{ name: 'Istanbul', code: 'IST' },
|
||||
{ name: 'Paris', code: 'PRS' }
|
||||
]);
|
||||
const dropdownValue = ref(null);
|
||||
const multiselectValues = ref([
|
||||
{ name: 'Australia', code: 'AU' },
|
||||
{ name: 'Brazil', code: 'BR' },
|
||||
{ name: 'China', code: 'CN' },
|
||||
{ name: 'Egypt', code: 'EG' },
|
||||
{ name: 'France', code: 'FR' },
|
||||
{ name: 'Germany', code: 'DE' },
|
||||
{ name: 'India', code: 'IN' },
|
||||
{ name: 'Japan', code: 'JP' },
|
||||
{ name: 'Spain', code: 'ES' },
|
||||
{ name: 'United States', code: 'US' }
|
||||
]);
|
||||
|
||||
const multiselectValue = ref(null);
|
||||
const toggleValue = ref(false);
|
||||
const selectButtonValue = ref(null);
|
||||
const selectButtonValues = ref([{ name: 'Option 1' }, { name: 'Option 2' }, { name: 'Option 3' }]);
|
||||
const knobValue = ref(50);
|
||||
const inputGroupValue = ref(false);
|
||||
const treeSelectNodes = ref(null);
|
||||
const selectedNode = ref(null);
|
||||
|
||||
onMounted(() => {
|
||||
CountryService.getCountries().then((data) => (autoValue.value = data));
|
||||
NodeService.getTreeNodes().then((data) => (treeSelectNodes.value = data));
|
||||
});
|
||||
|
||||
function searchCountry(event) {
|
||||
setTimeout(() => {
|
||||
if (!event.query.trim().length) {
|
||||
autoFilteredValue.value = [...autoValue.value];
|
||||
} else {
|
||||
autoFilteredValue.value = autoValue.value.filter((country) => {
|
||||
return country.name.toLowerCase().startsWith(event.query.toLowerCase());
|
||||
});
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Fluid class="flex flex-col md:flex-row gap-8">
|
||||
<div class="md:w-1/2">
|
||||
<div class="card flex flex-col gap-4">
|
||||
<div class="font-semibold text-xl">InputText</div>
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<InputText type="text" placeholder="Default" />
|
||||
<InputText type="text" placeholder="Disabled" :disabled="true" />
|
||||
<InputText type="text" placeholder="Invalid" invalid />
|
||||
</div>
|
||||
|
||||
<div class="font-semibold text-xl">Icons</div>
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-user" />
|
||||
<InputText type="text" placeholder="Username" />
|
||||
</IconField>
|
||||
<IconField iconPosition="left">
|
||||
<InputText type="text" placeholder="Search" />
|
||||
<InputIcon class="pi pi-search" />
|
||||
</IconField>
|
||||
|
||||
<div class="font-semibold text-xl">Float Label</div>
|
||||
<FloatLabel>
|
||||
<InputText id="username" type="text" v-model="floatValue" />
|
||||
<label for="username">Username</label>
|
||||
</FloatLabel>
|
||||
|
||||
<div class="font-semibold text-xl">Textarea</div>
|
||||
<Textarea placeholder="Your Message" :autoResize="true" rows="3" cols="30" />
|
||||
|
||||
<div class="font-semibold text-xl">AutoComplete</div>
|
||||
<AutoComplete v-model="selectedAutoValue" :suggestions="autoFilteredValue" optionLabel="name" placeholder="Search" dropdown multiple display="chip" @complete="searchCountry($event)" />
|
||||
|
||||
<div class="font-semibold text-xl">DatePicker</div>
|
||||
<DatePicker :showIcon="true" :showButtonBar="true" v-model="calendarValue"></DatePicker>
|
||||
|
||||
<div class="font-semibold text-xl">InputNumber</div>
|
||||
<InputNumber v-model="inputNumberValue" showButtons mode="decimal"></InputNumber>
|
||||
</div>
|
||||
|
||||
<div class="card flex flex-col gap-4">
|
||||
<div class="font-semibold text-xl">Slider</div>
|
||||
<InputText v-model.number="sliderValue" />
|
||||
<Slider v-model="sliderValue" />
|
||||
|
||||
<div class="flex flex-row mt-6">
|
||||
<div class="flex flex-col gap-4 w-1/2">
|
||||
<div class="font-semibold text-xl">Rating</div>
|
||||
<Rating v-model="ratingValue" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-4 w-1/2">
|
||||
<div class="font-semibold text-xl">ColorPicker</div>
|
||||
<ColorPicker style="width: 2rem" v-model="colorValue" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="font-semibold text-xl">Knob</div>
|
||||
<Knob v-model="knobValue" :step="10" :min="-50" :max="50" valueTemplate="{value}%" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="md:w-1/2">
|
||||
<div class="card flex flex-col gap-4">
|
||||
<div class="font-semibold text-xl">RadioButton</div>
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<div class="flex items-center">
|
||||
<RadioButton id="option1" name="option" value="Chicago" v-model="radioValue" />
|
||||
<label for="option1" class="leading-none ml-2">Chicago</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<RadioButton id="option2" name="option" value="Los Angeles" v-model="radioValue" />
|
||||
<label for="option2" class="leading-none ml-2">Los Angeles</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<RadioButton id="option3" name="option" value="New York" v-model="radioValue" />
|
||||
<label for="option3" class="leading-none ml-2">New York</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="font-semibold text-xl">Checkbox</div>
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<div class="flex items-center">
|
||||
<Checkbox id="checkOption1" name="option" value="Chicago" v-model="checkboxValue" />
|
||||
<label for="checkOption1" class="ml-2">Chicago</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<Checkbox id="checkOption2" name="option" value="Los Angeles" v-model="checkboxValue" />
|
||||
<label for="checkOption2" class="ml-2">Los Angeles</label>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<Checkbox id="checkOption3" name="option" value="New York" v-model="checkboxValue" />
|
||||
<label for="checkOption3" class="ml-2">New York</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="font-semibold text-xl">ToggleSwitch</div>
|
||||
<ToggleSwitch v-model="switchValue" />
|
||||
</div>
|
||||
|
||||
<div class="card flex flex-col gap-4">
|
||||
<div class="font-semibold text-xl">Listbox</div>
|
||||
<Listbox v-model="listboxValue" :options="listboxValues" optionLabel="name" :filter="true" />
|
||||
|
||||
<div class="font-semibold text-xl">Select</div>
|
||||
<Select v-model="dropdownValue" :options="dropdownValues" optionLabel="name" placeholder="Select" />
|
||||
|
||||
<div class="font-semibold text-xl">MultiSelect</div>
|
||||
<MultiSelect v-model="multiselectValue" :options="multiselectValues" optionLabel="name" placeholder="Select Countries" :filter="true">
|
||||
<template #value="slotProps">
|
||||
<div class="inline-flex items-center py-1 px-2 bg-primary text-primary-contrast rounded-border mr-2" v-for="option of slotProps.value" :key="option.code">
|
||||
<span :class="'mr-2 flag flag-' + option.code.toLowerCase()" style="width: 18px; height: 12px" />
|
||||
<div>{{ option.name }}</div>
|
||||
</div>
|
||||
<template v-if="!slotProps.value || slotProps.value.length === 0">
|
||||
<div class="p-1">Select Countries</div>
|
||||
</template>
|
||||
</template>
|
||||
<template #option="slotProps">
|
||||
<div class="flex items-center">
|
||||
<span :class="'mr-2 flag flag-' + slotProps.option.code.toLowerCase()" style="width: 18px; height: 12px" />
|
||||
<div>{{ slotProps.option.name }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</MultiSelect>
|
||||
|
||||
<div class="font-semibold text-xl">TreeSelect</div>
|
||||
<TreeSelect v-model="selectedNode" :options="treeSelectNodes" placeholder="Select Item"></TreeSelect>
|
||||
</div>
|
||||
|
||||
<div class="card flex flex-col gap-4">
|
||||
<div class="font-semibold text-xl">ToggleButton</div>
|
||||
<ToggleButton v-model="toggleValue" onLabel="Yes" offLabel="No" :style="{ width: '10em' }" />
|
||||
|
||||
<div class="font-semibold text-xl">SelectButton</div>
|
||||
<SelectButton v-model="selectButtonValue" :options="selectButtonValues" optionLabel="name" />
|
||||
</div>
|
||||
</div>
|
||||
</Fluid>
|
||||
|
||||
<Fluid class="flex mt-8">
|
||||
<div class="card flex flex-col gap-4 w-full">
|
||||
<div class="font-semibold text-xl">InputGroup</div>
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<InputGroup>
|
||||
<InputGroupAddon>
|
||||
<i class="pi pi-user"></i>
|
||||
</InputGroupAddon>
|
||||
<InputText placeholder="Username" />
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<InputGroupAddon>
|
||||
<i class="pi pi-clock"></i>
|
||||
</InputGroupAddon>
|
||||
<InputGroupAddon>
|
||||
<i class="pi pi-star-fill"></i>
|
||||
</InputGroupAddon>
|
||||
<InputNumber placeholder="Price" />
|
||||
<InputGroupAddon>$</InputGroupAddon>
|
||||
<InputGroupAddon>.00</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<InputGroup>
|
||||
<Button label="Search" />
|
||||
<InputText placeholder="Keyword" />
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<InputGroupAddon>
|
||||
<Checkbox v-model="inputGroupValue" :binary="true" />
|
||||
</InputGroupAddon>
|
||||
<InputText placeholder="Confirm" />
|
||||
</InputGroup>
|
||||
</div>
|
||||
</div>
|
||||
</Fluid>
|
||||
</template>
|
||||
@@ -1,163 +0,0 @@
|
||||
<script setup>
|
||||
import { ProductService } from '@/services/ProductService';
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
const products = ref(null);
|
||||
const picklistProducts = ref(null);
|
||||
const orderlistProducts = ref(null);
|
||||
const options = ref(['list', 'grid']);
|
||||
const layout = ref('list');
|
||||
|
||||
onMounted(() => {
|
||||
ProductService.getProductsSmall().then((data) => {
|
||||
products.value = data.slice(0, 6);
|
||||
picklistProducts.value = [data, []];
|
||||
orderlistProducts.value = data;
|
||||
});
|
||||
});
|
||||
|
||||
function getSeverity(product) {
|
||||
switch (product.inventoryStatus) {
|
||||
case 'INSTOCK':
|
||||
return 'success';
|
||||
|
||||
case 'LOWSTOCK':
|
||||
return 'warning';
|
||||
|
||||
case 'OUTOFSTOCK':
|
||||
return 'danger';
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl">DataView</div>
|
||||
<DataView :value="products" :layout="layout">
|
||||
<template #header>
|
||||
<div class="flex justify-end">
|
||||
<SelectButton v-model="layout" :options="options" :allowEmpty="false">
|
||||
<template #option="{ option }">
|
||||
<i :class="[option === 'list' ? 'pi pi-bars' : 'pi pi-table']" />
|
||||
</template>
|
||||
</SelectButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #list="slotProps">
|
||||
<div class="flex flex-col">
|
||||
<div v-for="(item, index) in slotProps.items" :key="index">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center p-6 gap-4" :class="{ 'border-t border-surface': index !== 0 }">
|
||||
<div class="md:w-40 relative">
|
||||
<img class="block xl:block mx-auto rounded w-full" :src="`https://primefaces.org/cdn/primevue/images/product/${item.image}`" :alt="item.name" />
|
||||
<Tag :value="item.inventoryStatus" :severity="getSeverity(item)" class="absolute dark:bg-surface-900!" style="left: 4px; top: 4px"></Tag>
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row justify-between md:items-center flex-1 gap-6">
|
||||
<div class="flex flex-row md:flex-col justify-between items-start gap-2">
|
||||
<div>
|
||||
<span class="font-medium text-surface-500 dark:text-surface-400 text-sm">{{ item.category }}</span>
|
||||
<div class="text-lg font-medium mt-2">{{ item.name }}</div>
|
||||
</div>
|
||||
<div class="bg-surface-100 p-1" style="border-radius: 30px">
|
||||
<div
|
||||
class="bg-surface-0 flex items-center gap-2 justify-center py-1 px-2"
|
||||
style="
|
||||
border-radius: 30px;
|
||||
box-shadow:
|
||||
0px 1px 2px 0px rgba(0, 0, 0, 0.04),
|
||||
0px 1px 2px 0px rgba(0, 0, 0, 0.06);
|
||||
"
|
||||
>
|
||||
<span class="text-surface-900 font-medium text-sm">{{ item.rating }}</span>
|
||||
<i class="pi pi-star-fill text-yellow-500"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col md:items-end gap-8">
|
||||
<span class="text-xl font-semibold">${{ item.price }}</span>
|
||||
<div class="flex flex-row-reverse md:flex-row gap-2">
|
||||
<Button icon="pi pi-heart" outlined></Button>
|
||||
<Button icon="pi pi-shopping-cart" label="Buy Now" :disabled="item.inventoryStatus === 'OUTOFSTOCK'" class="flex-auto md:flex-initial whitespace-nowrap"></Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #grid="slotProps">
|
||||
<div class="grid grid-cols-12 gap-4">
|
||||
<div v-for="(item, index) in slotProps.items" :key="index" class="col-span-12 sm:col-span-6 lg:col-span-4 p-2">
|
||||
<div class="p-6 border border-surface-200 dark:border-surface-700 bg-surface-0 dark:bg-surface-900 rounded flex flex-col">
|
||||
<div class="bg-surface-50 flex justify-center rounded p-4">
|
||||
<div class="relative mx-auto">
|
||||
<img class="rounded w-full" :src="`https://primefaces.org/cdn/primevue/images/product/${item.image}`" :alt="item.name" style="max-width: 300px" />
|
||||
<Tag :value="item.inventoryStatus" :severity="getSeverity(item)" class="absolute dark:bg-surface-900!" style="left: 4px; top: 4px"></Tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-6">
|
||||
<div class="flex flex-row justify-between items-start gap-2">
|
||||
<div>
|
||||
<span class="font-medium text-surface-500 dark:text-surface-400 text-sm">{{ item.category }}</span>
|
||||
<div class="text-lg font-medium mt-1">{{ item.name }}</div>
|
||||
</div>
|
||||
<div class="bg-surface-100 p-1" style="border-radius: 30px">
|
||||
<div
|
||||
class="bg-surface-0 flex items-center gap-2 justify-center py-1 px-2"
|
||||
style="
|
||||
border-radius: 30px;
|
||||
box-shadow:
|
||||
0px 1px 2px 0px rgba(0, 0, 0, 0.04),
|
||||
0px 1px 2px 0px rgba(0, 0, 0, 0.06);
|
||||
"
|
||||
>
|
||||
<span class="text-surface-900 font-medium text-sm">{{ item.rating }}</span>
|
||||
<i class="pi pi-star-fill text-yellow-500"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-6 mt-6">
|
||||
<span class="text-2xl font-semibold">${{ item.price }}</span>
|
||||
<div class="flex gap-2">
|
||||
<Button icon="pi pi-shopping-cart" label="Buy Now" :disabled="item.inventoryStatus === 'OUTOFSTOCK'" class="flex-auto whitespace-nowrap"></Button>
|
||||
<Button icon="pi pi-heart" outlined></Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DataView>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-8">
|
||||
<div class="lg:w-2/3">
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">PickList</div>
|
||||
<PickList v-model="picklistProducts" breakpoint="1400px" dataKey="id">
|
||||
<template #option="{ option }">
|
||||
{{ option.name }}
|
||||
</template>
|
||||
</PickList>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lg:w-1/3">
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">OrderList</div>
|
||||
<OrderList v-model="orderlistProducts" breakpoint="1400px" dataKey="id" pt:pcList:root="w-full">
|
||||
<template #option="{ option }">
|
||||
{{ option.name }}
|
||||
</template>
|
||||
</OrderList>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,109 +0,0 @@
|
||||
<script setup>
|
||||
import { PhotoService } from '@/services/PhotoService';
|
||||
import { ProductService } from '@/services/ProductService';
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
const products = ref([]);
|
||||
const images = ref([]);
|
||||
const galleriaResponsiveOptions = ref([
|
||||
{
|
||||
breakpoint: '1024px',
|
||||
numVisible: 5
|
||||
},
|
||||
{
|
||||
breakpoint: '960px',
|
||||
numVisible: 4
|
||||
},
|
||||
{
|
||||
breakpoint: '768px',
|
||||
numVisible: 3
|
||||
},
|
||||
{
|
||||
breakpoint: '560px',
|
||||
numVisible: 1
|
||||
}
|
||||
]);
|
||||
const carouselResponsiveOptions = ref([
|
||||
{
|
||||
breakpoint: '1024px',
|
||||
numVisible: 3,
|
||||
numScroll: 3
|
||||
},
|
||||
{
|
||||
breakpoint: '768px',
|
||||
numVisible: 2,
|
||||
numScroll: 2
|
||||
},
|
||||
{
|
||||
breakpoint: '560px',
|
||||
numVisible: 1,
|
||||
numScroll: 1
|
||||
}
|
||||
]);
|
||||
|
||||
onMounted(() => {
|
||||
ProductService.getProductsSmall().then((data) => (products.value = data));
|
||||
PhotoService.getImages().then((data) => (images.value = data));
|
||||
});
|
||||
|
||||
function getSeverity(status) {
|
||||
switch (status) {
|
||||
case 'INSTOCK':
|
||||
return 'success';
|
||||
|
||||
case 'LOWSTOCK':
|
||||
return 'warning';
|
||||
|
||||
case 'OUTOFSTOCK':
|
||||
return 'danger';
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">Carousel</div>
|
||||
<Carousel :value="products" :numVisible="3" :numScroll="3" :responsiveOptions="carouselResponsiveOptions">
|
||||
<template #item="slotProps">
|
||||
<div class="border border-surface-200 dark:border-surface-700 rounded m-2 p-4">
|
||||
<div class="mb-4">
|
||||
<div class="relative mx-auto">
|
||||
<img :src="'https://primefaces.org/cdn/primevue/images/product/' + slotProps.data.image" :alt="slotProps.data.name" class="w-full rounded" />
|
||||
<div class="dark:bg-surface-900 absolute rounded-border" style="left: 5px; top: 5px">
|
||||
<Tag :value="slotProps.data.inventoryStatus" :severity="getSeverity(slotProps.data.inventoryStatus)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4 font-medium">{{ slotProps.data.name }}</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="mt-0 font-semibold text-xl">${{ slotProps.data.price }}</div>
|
||||
<span>
|
||||
<Button icon="pi pi-heart" severity="secondary" outlined />
|
||||
<Button icon="pi pi-shopping-cart" class="ml-2" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Carousel>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">Image</div>
|
||||
<Image src="https://primefaces.org/cdn/primevue/images/galleria/galleria10.jpg" alt="Image" width="250" />
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">Galleria</div>
|
||||
<Galleria :value="images" :responsiveOptions="galleriaResponsiveOptions" :numVisible="5" containerStyle="max-width: 640px">
|
||||
<template #item="slotProps">
|
||||
<img :src="slotProps.item.itemImageSrc" :alt="slotProps.item.alt" style="width: 100%" />
|
||||
</template>
|
||||
<template #thumbnail="slotProps">
|
||||
<img :src="slotProps.item.thumbnailImageSrc" :alt="slotProps.item.alt" />
|
||||
</template>
|
||||
</Galleria>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,514 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
const menu = ref(null);
|
||||
const contextMenu = ref(null);
|
||||
|
||||
const nestedMenuitems = ref([
|
||||
{
|
||||
label: 'Customers',
|
||||
icon: 'pi pi-fw pi-table',
|
||||
items: [
|
||||
{
|
||||
label: 'New',
|
||||
icon: 'pi pi-fw pi-user-plus',
|
||||
items: [
|
||||
{
|
||||
label: 'Customer',
|
||||
icon: 'pi pi-fw pi-plus'
|
||||
},
|
||||
{
|
||||
label: 'Duplicate',
|
||||
icon: 'pi pi-fw pi-copy'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: 'pi pi-fw pi-user-edit'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Orders',
|
||||
icon: 'pi pi-fw pi-shopping-cart',
|
||||
items: [
|
||||
{
|
||||
label: 'View',
|
||||
icon: 'pi pi-fw pi-list'
|
||||
},
|
||||
{
|
||||
label: 'Search',
|
||||
icon: 'pi pi-fw pi-search'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Shipments',
|
||||
icon: 'pi pi-fw pi-envelope',
|
||||
items: [
|
||||
{
|
||||
label: 'Tracker',
|
||||
icon: 'pi pi-fw pi-compass'
|
||||
},
|
||||
{
|
||||
label: 'Map',
|
||||
icon: 'pi pi-fw pi-map-marker'
|
||||
},
|
||||
{
|
||||
label: 'Manage',
|
||||
icon: 'pi pi-fw pi-pencil'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Profile',
|
||||
icon: 'pi pi-fw pi-user',
|
||||
items: [
|
||||
{
|
||||
label: 'Settings',
|
||||
icon: 'pi pi-fw pi-cog'
|
||||
},
|
||||
{
|
||||
label: 'Billing',
|
||||
icon: 'pi pi-fw pi-file'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Quit',
|
||||
icon: 'pi pi-fw pi-sign-out'
|
||||
}
|
||||
]);
|
||||
const breadcrumbHome = ref({ icon: 'pi pi-home', to: '/' });
|
||||
const breadcrumbItems = ref([{ label: 'Computer' }, { label: 'Notebook' }, { label: 'Accessories' }, { label: 'Backpacks' }, { label: 'Item' }]);
|
||||
const tieredMenuItems = ref([
|
||||
{
|
||||
label: 'Customers',
|
||||
icon: 'pi pi-fw pi-table',
|
||||
items: [
|
||||
{
|
||||
label: 'New',
|
||||
icon: 'pi pi-fw pi-user-plus',
|
||||
items: [
|
||||
{
|
||||
label: 'Customer',
|
||||
icon: 'pi pi-fw pi-plus'
|
||||
},
|
||||
{
|
||||
label: 'Duplicate',
|
||||
icon: 'pi pi-fw pi-copy'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: 'pi pi-fw pi-user-edit'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Orders',
|
||||
icon: 'pi pi-fw pi-shopping-cart',
|
||||
items: [
|
||||
{
|
||||
label: 'View',
|
||||
icon: 'pi pi-fw pi-list'
|
||||
},
|
||||
{
|
||||
label: 'Search',
|
||||
icon: 'pi pi-fw pi-search'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Shipments',
|
||||
icon: 'pi pi-fw pi-envelope',
|
||||
items: [
|
||||
{
|
||||
label: 'Tracker',
|
||||
icon: 'pi pi-fw pi-compass'
|
||||
},
|
||||
{
|
||||
label: 'Map',
|
||||
icon: 'pi pi-fw pi-map-marker'
|
||||
},
|
||||
{
|
||||
label: 'Manage',
|
||||
icon: 'pi pi-fw pi-pencil'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Profile',
|
||||
icon: 'pi pi-fw pi-user',
|
||||
items: [
|
||||
{
|
||||
label: 'Settings',
|
||||
icon: 'pi pi-fw pi-cog'
|
||||
},
|
||||
{
|
||||
label: 'Billing',
|
||||
icon: 'pi pi-fw pi-file'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
separator: true
|
||||
},
|
||||
{
|
||||
label: 'Quit',
|
||||
icon: 'pi pi-fw pi-sign-out'
|
||||
}
|
||||
]);
|
||||
const overlayMenuItems = ref([
|
||||
{
|
||||
label: 'Save',
|
||||
icon: 'pi pi-save'
|
||||
},
|
||||
{
|
||||
label: 'Update',
|
||||
icon: 'pi pi-refresh'
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
icon: 'pi pi-trash'
|
||||
},
|
||||
{
|
||||
separator: true
|
||||
},
|
||||
{
|
||||
label: 'Home',
|
||||
icon: 'pi pi-home'
|
||||
}
|
||||
]);
|
||||
const menuitems = ref([
|
||||
{
|
||||
label: 'Customers',
|
||||
items: [
|
||||
{
|
||||
label: 'New',
|
||||
icon: 'pi pi-fw pi-plus'
|
||||
},
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: 'pi pi-fw pi-user-edit'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Orders',
|
||||
items: [
|
||||
{
|
||||
label: 'View',
|
||||
icon: 'pi pi-fw pi-list'
|
||||
},
|
||||
{
|
||||
label: 'Search',
|
||||
icon: 'pi pi-fw pi-search'
|
||||
}
|
||||
]
|
||||
}
|
||||
]);
|
||||
const contextMenuItems = ref([
|
||||
{
|
||||
label: 'Save',
|
||||
icon: 'pi pi-save'
|
||||
},
|
||||
{
|
||||
label: 'Update',
|
||||
icon: 'pi pi-refresh'
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
icon: 'pi pi-trash'
|
||||
},
|
||||
{
|
||||
separator: true
|
||||
},
|
||||
{
|
||||
label: 'Options',
|
||||
icon: 'pi pi-cog'
|
||||
}
|
||||
]);
|
||||
const megamenuItems = ref([
|
||||
{
|
||||
label: 'Fashion',
|
||||
icon: 'pi pi-fw pi-tag',
|
||||
items: [
|
||||
[
|
||||
{
|
||||
label: 'Woman',
|
||||
items: [{ label: 'Woman Item' }, { label: 'Woman Item' }, { label: 'Woman Item' }]
|
||||
},
|
||||
{
|
||||
label: 'Men',
|
||||
items: [{ label: 'Men Item' }, { label: 'Men Item' }, { label: 'Men Item' }]
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
label: 'Kids',
|
||||
items: [{ label: 'Kids Item' }, { label: 'Kids Item' }]
|
||||
},
|
||||
{
|
||||
label: 'Luggage',
|
||||
items: [{ label: 'Luggage Item' }, { label: 'Luggage Item' }, { label: 'Luggage Item' }]
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Electronics',
|
||||
icon: 'pi pi-fw pi-desktop',
|
||||
items: [
|
||||
[
|
||||
{
|
||||
label: 'Computer',
|
||||
items: [{ label: 'Computer Item' }, { label: 'Computer Item' }]
|
||||
},
|
||||
{
|
||||
label: 'Camcorder',
|
||||
items: [{ label: 'Camcorder Item' }, { label: 'Camcorder Item' }, { label: 'Camcorder Item' }]
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
label: 'TV',
|
||||
items: [{ label: 'TV Item' }, { label: 'TV Item' }]
|
||||
},
|
||||
{
|
||||
label: 'Audio',
|
||||
items: [{ label: 'Audio Item' }, { label: 'Audio Item' }, { label: 'Audio Item' }]
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
label: 'Sports.7',
|
||||
items: [{ label: 'Sports.7.1' }, { label: 'Sports.7.2' }]
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Furniture',
|
||||
icon: 'pi pi-fw pi-image',
|
||||
items: [
|
||||
[
|
||||
{
|
||||
label: 'Living Room',
|
||||
items: [{ label: 'Living Room Item' }, { label: 'Living Room Item' }]
|
||||
},
|
||||
{
|
||||
label: 'Kitchen',
|
||||
items: [{ label: 'Kitchen Item' }, { label: 'Kitchen Item' }, { label: 'Kitchen Item' }]
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
label: 'Bedroom',
|
||||
items: [{ label: 'Bedroom Item' }, { label: 'Bedroom Item' }]
|
||||
},
|
||||
{
|
||||
label: 'Outdoor',
|
||||
items: [{ label: 'Outdoor Item' }, { label: 'Outdoor Item' }, { label: 'Outdoor Item' }]
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Sports',
|
||||
icon: 'pi pi-fw pi-star',
|
||||
items: [
|
||||
[
|
||||
{
|
||||
label: 'Basketball',
|
||||
items: [{ label: 'Basketball Item' }, { label: 'Basketball Item' }]
|
||||
},
|
||||
{
|
||||
label: 'Football',
|
||||
items: [{ label: 'Football Item' }, { label: 'Football Item' }, { label: 'Football Item' }]
|
||||
}
|
||||
],
|
||||
[
|
||||
{
|
||||
label: 'Tennis',
|
||||
items: [{ label: 'Tennis Item' }, { label: 'Tennis Item' }]
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
]);
|
||||
const panelMenuitems = ref([
|
||||
{
|
||||
label: 'Customers',
|
||||
icon: 'pi pi-fw pi-table',
|
||||
items: [
|
||||
{
|
||||
label: 'New',
|
||||
icon: 'pi pi-fw pi-user-plus',
|
||||
items: [
|
||||
{
|
||||
label: 'Customer',
|
||||
icon: 'pi pi-fw pi-plus'
|
||||
},
|
||||
{
|
||||
label: 'Duplicate',
|
||||
icon: 'pi pi-fw pi-copy'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: 'pi pi-fw pi-user-edit'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Orders',
|
||||
icon: 'pi pi-fw pi-shopping-cart',
|
||||
items: [
|
||||
{
|
||||
label: 'View',
|
||||
icon: 'pi pi-fw pi-list'
|
||||
},
|
||||
{
|
||||
label: 'Search',
|
||||
icon: 'pi pi-fw pi-search'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Shipments',
|
||||
icon: 'pi pi-fw pi-envelope',
|
||||
items: [
|
||||
{
|
||||
label: 'Tracker',
|
||||
icon: 'pi pi-fw pi-compass'
|
||||
},
|
||||
{
|
||||
label: 'Map',
|
||||
icon: 'pi pi-fw pi-map-marker'
|
||||
},
|
||||
{
|
||||
label: 'Manage',
|
||||
icon: 'pi pi-fw pi-pencil'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Profile',
|
||||
icon: 'pi pi-fw pi-user',
|
||||
items: [
|
||||
{
|
||||
label: 'Settings',
|
||||
icon: 'pi pi-fw pi-cog'
|
||||
},
|
||||
{
|
||||
label: 'Billing',
|
||||
icon: 'pi pi-fw pi-file'
|
||||
}
|
||||
]
|
||||
}
|
||||
]);
|
||||
|
||||
function toggleMenu(event) {
|
||||
menu.value.toggle(event);
|
||||
}
|
||||
|
||||
function onContextRightClick(event) {
|
||||
contextMenu.value.show(event);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">Menubar</div>
|
||||
<Menubar :model="nestedMenuitems">
|
||||
<template #end>
|
||||
<IconField iconPosition="left">
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText type="text" placeholder="Search" />
|
||||
</IconField>
|
||||
</template>
|
||||
</Menubar>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">Breadcrumb</div>
|
||||
<Breadcrumb :home="breadcrumbHome" :model="breadcrumbItems" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-8">
|
||||
<div class="md:w-1/2">
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">Steps</div>
|
||||
<Stepper value="1">
|
||||
<StepList>
|
||||
<Step value="1">Header I</Step>
|
||||
<Step value="2">Header II</Step>
|
||||
<Step value="3">Header III</Step>
|
||||
</StepList>
|
||||
</Stepper>
|
||||
</div>
|
||||
</div>
|
||||
<div class="md:w-1/2">
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">TabMenu</div>
|
||||
<Tabs value="0">
|
||||
<TabList>
|
||||
<Tab value="0">Header I</Tab>
|
||||
<Tab value="1">Header II</Tab>
|
||||
<Tab value="2">Header III</Tab>
|
||||
</TabList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-8 mt-6">
|
||||
<div class="md:w-1/3">
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">Tiered Menu</div>
|
||||
<TieredMenu :model="tieredMenuItems" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="md:w-1/3">
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">Plain Menu</div>
|
||||
<Menu :model="menuitems" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="md:w-1/3">
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">Overlay Menu</div>
|
||||
<Menu ref="menu" :model="overlayMenuItems" :popup="true" />
|
||||
<Button type="button" label="Options" icon="pi pi-angle-down" @click="toggleMenu" style="width: auto" />
|
||||
</div>
|
||||
|
||||
<div class="card" @contextmenu="onContextRightClick">
|
||||
<div class="font-semibold text-xl mb-4">Context Menu</div>
|
||||
Right click to display.
|
||||
<ContextMenu ref="contextMenu" :model="contextMenuItems" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-8 mt-8">
|
||||
<div class="md:w-1/2">
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">MegaMenu | Horizontal</div>
|
||||
<MegaMenu :model="megamenuItems" />
|
||||
|
||||
<div class="font-semibold text-xl mb-4 mt-8">MegaMenu | Vertical</div>
|
||||
<MegaMenu :model="megamenuItems" orientation="vertical" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="md:w-1/2">
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">PanelMenu</div>
|
||||
<PanelMenu :model="panelMenuitems" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,68 +0,0 @@
|
||||
<script setup>
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const toast = useToast();
|
||||
const message = ref([]);
|
||||
const username = ref(null);
|
||||
const email = ref(null);
|
||||
|
||||
function showSuccess() {
|
||||
toast.add({ severity: 'success', summary: 'Success Message', detail: 'Message Detail', life: 3000 });
|
||||
}
|
||||
|
||||
function showInfo() {
|
||||
toast.add({ severity: 'info', summary: 'Info Message', detail: 'Message Detail', life: 3000 });
|
||||
}
|
||||
|
||||
function showWarn() {
|
||||
toast.add({ severity: 'warn', summary: 'Warn Message', detail: 'Message Detail', life: 3000 });
|
||||
}
|
||||
|
||||
function showError() {
|
||||
toast.add({ severity: 'error', summary: 'Error Message', detail: 'Message Detail', life: 3000 });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col md:flex-row gap-8">
|
||||
<div class="md:w-1/2">
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">Toast</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button @click="showSuccess()" label="Success" severity="success" />
|
||||
<Button @click="showInfo()" label="Info" severity="info" />
|
||||
<Button @click="showWarn()" label="Warn" severity="warn" />
|
||||
<Button @click="showError()" label="Error" severity="danger" />
|
||||
</div>
|
||||
|
||||
<div class="font-semibold text-xl mt-4 mb-4">Inline</div>
|
||||
<div class="flex flex-wrap mb-4 gap-2">
|
||||
<InputText v-model="username" placeholder="Username" aria-label="username" invalid />
|
||||
<Message severity="error">Username is required</Message>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<InputText v-model="email" placeholder="Email" aria-label="email" invalid />
|
||||
<Message severity="error" icon="pi pi-times-circle" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="md:w-1/2">
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">Message</div>
|
||||
<div class="flex flex-col gap-4 mb-4">
|
||||
<Message severity="success">Success Message</Message>
|
||||
<Message severity="info">Info Message</Message>
|
||||
<Message severity="warn">Warn Message</Message>
|
||||
<Message severity="error">Error Message</Message>
|
||||
<Message severity="secondary">Secondary Message</Message>
|
||||
<Message severity="contrast">Contrast Message</Message>
|
||||
</div>
|
||||
|
||||
<transition-group name="p-message" tag="div">
|
||||
<Message v-for="msg of message" :severity="msg.severity" :key="msg.content">{{ msg.content }}</Message>
|
||||
</transition-group>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,197 +0,0 @@
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
|
||||
const value = ref(0);
|
||||
let interval = null;
|
||||
|
||||
function startProgress() {
|
||||
interval = setInterval(() => {
|
||||
let newValue = value.value + Math.floor(Math.random() * 10) + 1;
|
||||
if (newValue >= 100) {
|
||||
newValue = 100;
|
||||
}
|
||||
value.value = newValue;
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function endProgress() {
|
||||
clearInterval(interval);
|
||||
interval = null;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
startProgress();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
endProgress();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">ProgressBar</div>
|
||||
<div class="flex flex-col md:flex-row gap-4">
|
||||
<div class="md:w-1/2">
|
||||
<ProgressBar :value="value"></ProgressBar>
|
||||
</div>
|
||||
<div class="md:w-1/2">
|
||||
<ProgressBar :value="50" :showValue="false"></ProgressBar>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-8">
|
||||
<div class="md:w-1/2">
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">Badge</div>
|
||||
<div class="flex gap-2">
|
||||
<Badge :value="2"></Badge>
|
||||
<Badge :value="8" severity="success"></Badge>
|
||||
<Badge :value="4" severity="info"></Badge>
|
||||
<Badge :value="12" severity="Warn"></Badge>
|
||||
<Badge :value="3" severity="danger"></Badge>
|
||||
</div>
|
||||
|
||||
<div class="font-semibold my-4">Overlay</div>
|
||||
<div class="flex gap-6">
|
||||
<OverlayBadge value="2">
|
||||
<i class="pi pi-bell" style="font-size: 2rem" />
|
||||
</OverlayBadge>
|
||||
<OverlayBadge value="4" severity="danger">
|
||||
<i class="pi pi-calendar" style="font-size: 2rem" />
|
||||
</OverlayBadge>
|
||||
<OverlayBadge severity="danger">
|
||||
<i class="pi pi-envelope" style="font-size: 2rem" />
|
||||
</OverlayBadge>
|
||||
</div>
|
||||
|
||||
<div class="font-semibold my-4">Button</div>
|
||||
<div class="flex gap-2">
|
||||
<Button label="Emails" badge="8" class="mr-2"></Button>
|
||||
<Button label="Messages" icon="pi pi-users" severity="warn" badge="8" badgeClass="p-badge-danger"></Button>
|
||||
</div>
|
||||
|
||||
<div class="font-semibold my-4">Sizes</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<Badge :value="2"></Badge>
|
||||
<Badge :value="4" size="large" severity="warn"></Badge>
|
||||
<Badge :value="6" size="xlarge" severity="success"></Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">Avatar</div>
|
||||
<div class="font-semibold mb-4">Group</div>
|
||||
<AvatarGroup>
|
||||
<Avatar :image="'https://primefaces.org/cdn/primevue/images/avatar/amyelsner.png'" size="large" shape="circle"></Avatar>
|
||||
<Avatar :image="'https://primefaces.org/cdn/primevue/images/avatar/asiyajavayant.png'" size="large" shape="circle"></Avatar>
|
||||
<Avatar :image="'https://primefaces.org/cdn/primevue/images/avatar/onyamalimba.png'" size="large" shape="circle"></Avatar>
|
||||
<Avatar :image="'https://primefaces.org/cdn/primevue/images/avatar/ionibowcher.png'" size="large" shape="circle"></Avatar>
|
||||
<Avatar :image="'https://primefaces.org/cdn/primevue/images/avatar/xuxuefeng.png'" size="large" shape="circle"></Avatar>
|
||||
<Avatar label="+2" shape="circle" size="large" :style="{ 'background-color': '#9c27b0', color: '#ffffff' }"></Avatar>
|
||||
</AvatarGroup>
|
||||
|
||||
<div class="font-semibold my-4">Label - Circle</div>
|
||||
<Avatar label="P" class="mr-2" size="xlarge" shape="circle"></Avatar>
|
||||
<Avatar label="V" class="mr-2" size="large" :style="{ 'background-color': '#2196F3', color: '#ffffff' }" shape="circle"></Avatar>
|
||||
<Avatar label="U" class="mr-2" :style="{ 'background-color': '#9c27b0', color: '#ffffff' }" shape="circle"></Avatar>
|
||||
|
||||
<div class="font-semibold my-4">Icon - Badge</div>
|
||||
<OverlayBadge value="4" severity="danger" class="inline-flex">
|
||||
<Avatar label="U" size="xlarge" />
|
||||
</OverlayBadge>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">ScrollTop</div>
|
||||
<ScrollPanel :style="{ width: '250px', height: '200px' }">
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Vitae et leo duis ut diam. Ultricies mi quis hendrerit dolor magna eget est lorem. Amet consectetur
|
||||
adipiscing elit ut. Nam libero justo laoreet sit amet. Pharetra massa massa ultricies mi quis hendrerit dolor magna. Est ultricies integer quis auctor elit sed vulputate. Consequat ac felis donec et. Tellus orci ac auctor
|
||||
augue mauris. Semper feugiat nibh sed pulvinar proin gravida hendrerit lectus a. Tincidunt arcu non sodales neque sodales. Metus aliquam eleifend mi in nulla posuere sollicitudin aliquam ultrices. Sodales ut etiam sit amet
|
||||
nisl purus. Cursus sit amet dictum sit amet. Tristique senectus et netus et malesuada fames ac turpis egestas. Et tortor consequat id porta nibh venenatis cras sed. Diam maecenas ultricies mi eget mauris. Eget egestas purus
|
||||
viverra accumsan in nisl nisi. Suscipit adipiscing bibendum est ultricies integer. Mattis aliquam faucibus purus in massa tempor nec.
|
||||
</p>
|
||||
<ScrollTop target="parent" :threshold="100" icon="pi pi-arrow-up"></ScrollTop>
|
||||
</ScrollPanel>
|
||||
</div>
|
||||
</div>
|
||||
<div class="md:w-1/2">
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">Tag</div>
|
||||
<div class="font-semibold mb-4">Default</div>
|
||||
<div class="flex gap-2">
|
||||
<Tag value="Primary"></Tag>
|
||||
<Tag severity="success" value="Success"></Tag>
|
||||
<Tag severity="info" value="Info"></Tag>
|
||||
<Tag severity="warn" value="Warn"></Tag>
|
||||
<Tag severity="danger" value="Danger"></Tag>
|
||||
</div>
|
||||
|
||||
<div class="font-semibold my-4">Pills</div>
|
||||
<div class="flex gap-2">
|
||||
<Tag value="Primary" :rounded="true"></Tag>
|
||||
<Tag severity="success" value="Success" :rounded="true"></Tag>
|
||||
<Tag severity="info" value="Info" :rounded="true"></Tag>
|
||||
<Tag severity="warn" value="Warn" :rounded="true"></Tag>
|
||||
<Tag severity="danger" value="Danger" :rounded="true"></Tag>
|
||||
</div>
|
||||
|
||||
<div class="font-semibold my-4">Icons</div>
|
||||
<div class="flex gap-2">
|
||||
<Tag icon="pi pi-user" value="Primary"></Tag>
|
||||
<Tag icon="pi pi-check" severity="success" value="Success"></Tag>
|
||||
<Tag icon="pi pi-info-circle" severity="info" value="Info"></Tag>
|
||||
<Tag con="pi pi-exclamation-triangle" severity="warn" value="Warn"></Tag>
|
||||
<Tag icon="pi pi-times" severity="danger" value="Danger"></Tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">Chip</div>
|
||||
<div class="font-semibold mb-4">Basic</div>
|
||||
<div class="flex items-center flex-col sm:flex-row">
|
||||
<Chip label="Action" class="mr-2 mb-2"></Chip>
|
||||
<Chip label="Comedy" class="mr-2 mb-2"></Chip>
|
||||
<Chip label="Mystery" class="mr-2 mb-2"></Chip>
|
||||
<Chip label="Thriller" :removable="true" class="mb-2"></Chip>
|
||||
</div>
|
||||
|
||||
<div class="font-semibold my-4">Icon</div>
|
||||
<div class="flex items-center flex-col sm:flex-row">
|
||||
<Chip label="Apple" icon="pi pi-apple" class="mr-2 mb-2"></Chip>
|
||||
<Chip label="Facebook" icon="pi pi-facebook" class="mr-2 mb-2"></Chip>
|
||||
<Chip label="Google" icon="pi pi-google" class="mr-2 mb-2"></Chip>
|
||||
<Chip label="Microsoft" icon="pi pi-microsoft" :removable="true" class="mb-2"></Chip>
|
||||
</div>
|
||||
|
||||
<div class="font-semibold my-4">Image</div>
|
||||
<div class="flex items-center flex-col sm:flex-row">
|
||||
<Chip label="Amy Elsner" :image="'https://primefaces.org/cdn/primevue/images/avatar/amyelsner.png'" class="mr-2 mb-2"></Chip>
|
||||
<Chip label="Asiya Javayant" :image="'https://primefaces.org/cdn/primevue/images/avatar/asiyajavayant.png'" class="mr-2 mb-2"></Chip>
|
||||
<Chip label="Onyama Limba" :image="'https://primefaces.org/cdn/primevue/images/avatar/onyamalimba.png'" class="mr-2 mb-2"></Chip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">Skeleton</div>
|
||||
<div class="rounded-border border border-surface p-6">
|
||||
<div class="flex mb-4">
|
||||
<Skeleton shape="circle" size="4rem" class="mr-2"></Skeleton>
|
||||
<div>
|
||||
<Skeleton width="10rem" class="mb-2"></Skeleton>
|
||||
<Skeleton width="5rem" class="mb-2"></Skeleton>
|
||||
<Skeleton height=".5rem"></Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton width="100%" height="150px"></Skeleton>
|
||||
<div class="flex justify-between mt-4">
|
||||
<Skeleton width="4rem" height="2rem"></Skeleton>
|
||||
<Skeleton width="4rem" height="2rem"></Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,186 +0,0 @@
|
||||
<script setup>
|
||||
import { ProductService } from '@/services/ProductService';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
const display = ref(false);
|
||||
const displayConfirmation = ref(false);
|
||||
const visibleLeft = ref(false);
|
||||
const visibleRight = ref(false);
|
||||
const visibleTop = ref(false);
|
||||
const visibleBottom = ref(false);
|
||||
const visibleFull = ref(false);
|
||||
const products = ref(null);
|
||||
const selectedProduct = ref(null);
|
||||
const op = ref(null);
|
||||
const popup = ref(null);
|
||||
|
||||
const toast = useToast();
|
||||
const confirmPopup = useConfirm();
|
||||
|
||||
onMounted(() => {
|
||||
ProductService.getProductsSmall().then((data) => (products.value = data));
|
||||
});
|
||||
|
||||
function open() {
|
||||
display.value = true;
|
||||
}
|
||||
|
||||
function close() {
|
||||
display.value = false;
|
||||
}
|
||||
|
||||
function openConfirmation() {
|
||||
displayConfirmation.value = true;
|
||||
}
|
||||
|
||||
function closeConfirmation() {
|
||||
displayConfirmation.value = false;
|
||||
}
|
||||
|
||||
function toggleDataTable(event) {
|
||||
op.value.toggle(event);
|
||||
}
|
||||
|
||||
function onProductSelect(event) {
|
||||
op.value.hide();
|
||||
toast.add({ severity: 'info', summary: 'Product Selected', detail: event.data.name, life: 3000 });
|
||||
}
|
||||
|
||||
function confirm(event) {
|
||||
confirmPopup.require({
|
||||
target: event.target,
|
||||
message: 'Are you sure you want to proceed?',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
rejectProps: {
|
||||
label: 'Cancel',
|
||||
severity: 'secondary',
|
||||
outlined: true
|
||||
},
|
||||
acceptProps: {
|
||||
label: 'Save'
|
||||
},
|
||||
accept: () => {
|
||||
toast.add({ severity: 'info', summary: 'Confirmed', detail: 'You have accepted', life: 3000 });
|
||||
},
|
||||
reject: () => {
|
||||
toast.add({ severity: 'info', summary: 'Rejected', detail: 'You have rejected', life: 3000 });
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col md:flex-row gap-8">
|
||||
<div class="md:w-1/2">
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">Dialog</div>
|
||||
<Dialog header="Dialog" v-model:visible="display" :breakpoints="{ '960px': '75vw' }" :style="{ width: '30vw' }" :modal="true">
|
||||
<p class="leading-normal m-0">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
|
||||
consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
|
||||
</p>
|
||||
<template #footer>
|
||||
<Button label="Save" @click="close" />
|
||||
</template>
|
||||
</Dialog>
|
||||
<Button label="Show" style="width: auto" @click="open" />
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">Popover</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button type="button" label="Show" @click="toggleDataTable" />
|
||||
<Popover ref="op" id="overlay_panel" style="width: 450px">
|
||||
<DataTable v-model:selection="selectedProduct" :value="products" selectionMode="single" :paginator="true" :rows="5" @row-select="onProductSelect">
|
||||
<Column field="name" header="Name" sortable style="min-width: 12rem"></Column>
|
||||
<Column header="Image">
|
||||
<template #body="slotProps">
|
||||
<img :src="`https://primefaces.org/cdn/primevue/images/product/${slotProps.data.image}`" :alt="slotProps.data.image" class="w-16 shadow-sm" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="price" header="Price" sortable style="min-width: 8rem">
|
||||
<template #body="slotProps"> $ {{ slotProps.data.price }} </template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">Tooltip</div>
|
||||
<div class="inline-flex gap-4">
|
||||
<InputText type="text" placeholder="Username" v-tooltip="'Your username'" />
|
||||
<Button type="button" label="Save" v-tooltip="'Click to proceed'" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="md:w-1/2">
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">Drawer</div>
|
||||
<Drawer v-model:visible="visibleLeft" header="Drawer">
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
|
||||
consequat.
|
||||
</p>
|
||||
</Drawer>
|
||||
|
||||
<Drawer v-model:visible="visibleRight" header="Drawer" position="right">
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
|
||||
consequat.
|
||||
</p>
|
||||
</Drawer>
|
||||
|
||||
<Drawer v-model:visible="visibleTop" header="Drawer" position="top">
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
|
||||
consequat.
|
||||
</p>
|
||||
</Drawer>
|
||||
|
||||
<Drawer v-model:visible="visibleBottom" header="Drawer" position="bottom">
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
|
||||
consequat.
|
||||
</p>
|
||||
</Drawer>
|
||||
|
||||
<Drawer v-model:visible="visibleFull" header="Drawer" position="full">
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
|
||||
consequat.
|
||||
</p>
|
||||
</Drawer>
|
||||
|
||||
<Button icon="pi pi-arrow-right" @click="visibleLeft = true" style="margin-right: 0.25em" />
|
||||
<Button icon="pi pi-arrow-left" @click="visibleRight = true" style="margin-right: 0.25em" />
|
||||
<Button icon="pi pi-arrow-down" @click="visibleTop = true" style="margin-right: 0.25em" />
|
||||
<Button icon="pi pi-arrow-up" @click="visibleBottom = true" style="margin-right: 0.25em" />
|
||||
<Button icon="pi pi-external-link" @click="visibleFull = true" />
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">ConfirmPopup</div>
|
||||
<ConfirmPopup></ConfirmPopup>
|
||||
<Button ref="popup" @click="confirm($event)" icon="pi pi-check" label="Confirm" class="mr-2"></Button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">ConfirmDialog</div>
|
||||
<Button label="Delete" icon="pi pi-trash" severity="danger" style="width: auto" @click="openConfirmation" />
|
||||
<Dialog header="Confirmation" v-model:visible="displayConfirmation" :style="{ width: '350px' }" :modal="true">
|
||||
<div class="flex items-center justify-center">
|
||||
<i class="pi pi-exclamation-triangle mr-4" style="font-size: 2rem" />
|
||||
<span>Are you sure you want to proceed?</span>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="No" icon="pi pi-times" @click="closeConfirmation" text severity="secondary" />
|
||||
<Button label="Yes" icon="pi pi-check" @click="closeConfirmation" severity="danger" outlined autofocus />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,216 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
const items = ref([
|
||||
{
|
||||
label: 'Save',
|
||||
icon: 'pi pi-check'
|
||||
},
|
||||
{
|
||||
label: 'Update',
|
||||
icon: 'pi pi-upload'
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
icon: 'pi pi-trash'
|
||||
},
|
||||
{
|
||||
label: 'Home Page',
|
||||
icon: 'pi pi-home'
|
||||
}
|
||||
]);
|
||||
const cardMenu = ref([
|
||||
{ label: 'Save', icon: 'pi pi-fw pi-check' },
|
||||
{ label: 'Update', icon: 'pi pi-fw pi-refresh' },
|
||||
{ label: 'Delete', icon: 'pi pi-fw pi-trash' }
|
||||
]);
|
||||
const menuRef = ref(null);
|
||||
|
||||
function toggle() {
|
||||
menuRef.value.toggle(event);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col">
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">Toolbar</div>
|
||||
<Toolbar>
|
||||
<template #start>
|
||||
<Button icon="pi pi-plus" class="mr-2" severity="secondary" text />
|
||||
<Button icon="pi pi-print" class="mr-2" severity="secondary" text />
|
||||
<Button icon="pi pi-upload" severity="secondary" text />
|
||||
</template>
|
||||
|
||||
<template #center>
|
||||
<IconField>
|
||||
<InputIcon>
|
||||
<i class="pi pi-search" />
|
||||
</InputIcon>
|
||||
<InputText placeholder="Search" />
|
||||
</IconField>
|
||||
</template>
|
||||
|
||||
<template #end> <SplitButton label="Save" :model="items"></SplitButton></template>
|
||||
</Toolbar>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-8">
|
||||
<div class="md:w-1/2">
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">Accordion</div>
|
||||
<Accordion value="0">
|
||||
<AccordionPanel value="0">
|
||||
<AccordionHeader>Header I</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<p class="m-0">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea
|
||||
commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit
|
||||
anim id est laborum.
|
||||
</p>
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
<AccordionPanel value="1">
|
||||
<AccordionHeader>Header II</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<p class="m-0">
|
||||
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt
|
||||
explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Consectetur, adipisci velit, sed quia non
|
||||
numquam eius modi.
|
||||
</p>
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
<AccordionPanel value="2">
|
||||
<AccordionHeader>Header III</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<p class="m-0">
|
||||
At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident, similique
|
||||
sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil
|
||||
impedit quo minus.
|
||||
</p>
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
</Accordion>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">Tabs</div>
|
||||
<Tabs value="0">
|
||||
<TabList>
|
||||
<Tab value="0">Header I</Tab>
|
||||
<Tab value="1">Header II</Tab>
|
||||
<Tab value="2">Header III</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel value="0">
|
||||
<p class="m-0">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea
|
||||
commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit
|
||||
anim id est laborum.
|
||||
</p>
|
||||
</TabPanel>
|
||||
<TabPanel value="1">
|
||||
<p class="m-0">
|
||||
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt
|
||||
explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Consectetur, adipisci velit, sed quia non
|
||||
numquam eius modi.
|
||||
</p>
|
||||
</TabPanel>
|
||||
<TabPanel value="2">
|
||||
<p class="m-0">
|
||||
At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident, similique
|
||||
sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil
|
||||
impedit quo minus.
|
||||
</p>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
<div class="md:w-1/2 mt-6 md:mt-0">
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">Panel</div>
|
||||
<Panel header="Header" :toggleable="true">
|
||||
<p class="leading-normal m-0">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
|
||||
consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est
|
||||
laborum.
|
||||
</p>
|
||||
</Panel>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">Fieldset</div>
|
||||
<Fieldset legend="Legend" :toggleable="true">
|
||||
<p class="leading-normal m-0">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
|
||||
consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est
|
||||
laborum.
|
||||
</p>
|
||||
</Fieldset>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<template v-slot:title>
|
||||
<div class="flex items-center justify-between mb-0">
|
||||
<div class="font-semibold text-xl mb-4">Card</div>
|
||||
<Button icon="pi pi-plus" class="p-button-text" @click="toggle" />
|
||||
</div>
|
||||
<Menu id="config_menu" ref="menuRef" :model="cardMenu" :popup="true" />
|
||||
</template>
|
||||
|
||||
<template v-slot:content>
|
||||
<p class="leading-normal m-0">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo
|
||||
consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est
|
||||
laborum.
|
||||
</p>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-8">
|
||||
<div class="font-semibold text-xl mb-4">Divider</div>
|
||||
<div class="flex flex-col md:flex-row">
|
||||
<div class="w-full md:w-5/12 flex flex-col items-center justify-center gap-3 py-5">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="username">Username</label>
|
||||
<InputText id="username" type="text" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="password">Password</label>
|
||||
<InputText id="password" type="password" />
|
||||
</div>
|
||||
<div class="flex">
|
||||
<Button label="Login" icon="pi pi-user" class="w-full max-w-[17.35rem] mx-auto"></Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full md:w-2/12">
|
||||
<Divider layout="vertical" class="hidden! md:flex!"><b>OR</b></Divider>
|
||||
<Divider layout="horizontal" class="flex! md:hidden!" align="center"><b>OR</b></Divider>
|
||||
</div>
|
||||
<div class="w-full md:w-5/12 flex items-center justify-center py-5">
|
||||
<Button label="Sign Up" icon="pi pi-user-plus" severity="success" class="w-full max-w-[17.35rem] mx-auto"></Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">Splitter</div>
|
||||
<Splitter style="height: 300px" class="mb-8">
|
||||
<SplitterPanel :size="30" :minSize="10">
|
||||
<div className="h-full flex items-center justify-center">Panel 1</div>
|
||||
</SplitterPanel>
|
||||
<SplitterPanel :size="70">
|
||||
<Splitter layout="vertical">
|
||||
<SplitterPanel :size="15">
|
||||
<div className="h-full flex items-center justify-center">Panel 2</div>
|
||||
</SplitterPanel>
|
||||
<SplitterPanel :size="50">
|
||||
<div className="h-full flex items-center justify-center">Panel 3</div>
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,391 +0,0 @@
|
||||
<script setup>
|
||||
import { CustomerService } from '@/services/CustomerService';
|
||||
import { ProductService } from '@/services/ProductService';
|
||||
import { FilterMatchMode, FilterOperator } from '@primevue/core/api';
|
||||
import { onBeforeMount, reactive, ref } from 'vue';
|
||||
|
||||
const customers1 = ref(null);
|
||||
const customers2 = ref(null);
|
||||
const customers3 = ref(null);
|
||||
const filters1 = ref(null);
|
||||
const loading1 = ref(null);
|
||||
const balanceFrozen = ref(false);
|
||||
const products = ref(null);
|
||||
const expandedRows = ref([]);
|
||||
const statuses = reactive(['unqualified', 'qualified', 'new', 'negotiation', 'renewal', 'proposal']);
|
||||
const representatives = reactive([
|
||||
{ name: 'Amy Elsner', image: 'amyelsner.png' },
|
||||
{ name: 'Anna Fali', image: 'annafali.png' },
|
||||
{ name: 'Asiya Javayant', image: 'asiyajavayant.png' },
|
||||
{ name: 'Bernardo Dominic', image: 'bernardodominic.png' },
|
||||
{ name: 'Elwin Sharvill', image: 'elwinsharvill.png' },
|
||||
{ name: 'Ioni Bowcher', image: 'ionibowcher.png' },
|
||||
{ name: 'Ivan Magalhaes', image: 'ivanmagalhaes.png' },
|
||||
{ name: 'Onyama Limba', image: 'onyamalimba.png' },
|
||||
{ name: 'Stephen Shaw', image: 'stephenshaw.png' },
|
||||
{ name: 'XuXue Feng', image: 'xuxuefeng.png' }
|
||||
]);
|
||||
|
||||
function getOrderSeverity(order) {
|
||||
switch (order.status) {
|
||||
case 'DELIVERED':
|
||||
return 'success';
|
||||
|
||||
case 'CANCELLED':
|
||||
return 'danger';
|
||||
|
||||
case 'PENDING':
|
||||
return 'warn';
|
||||
|
||||
case 'RETURNED':
|
||||
return 'info';
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSeverity(status) {
|
||||
switch (status) {
|
||||
case 'unqualified':
|
||||
return 'danger';
|
||||
|
||||
case 'qualified':
|
||||
return 'success';
|
||||
|
||||
case 'new':
|
||||
return 'info';
|
||||
|
||||
case 'negotiation':
|
||||
return 'warn';
|
||||
|
||||
case 'renewal':
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getStockSeverity(product) {
|
||||
switch (product.inventoryStatus) {
|
||||
case 'INSTOCK':
|
||||
return 'success';
|
||||
|
||||
case 'LOWSTOCK':
|
||||
return 'warn';
|
||||
|
||||
case 'OUTOFSTOCK':
|
||||
return 'danger';
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeMount(() => {
|
||||
ProductService.getProductsWithOrdersSmall().then((data) => (products.value = data));
|
||||
CustomerService.getCustomersLarge().then((data) => {
|
||||
customers1.value = data;
|
||||
loading1.value = false;
|
||||
customers1.value.forEach((customer) => (customer.date = new Date(customer.date)));
|
||||
});
|
||||
CustomerService.getCustomersLarge().then((data) => (customers2.value = data));
|
||||
CustomerService.getCustomersMedium().then((data) => (customers3.value = data));
|
||||
|
||||
initFilters1();
|
||||
});
|
||||
|
||||
function initFilters1() {
|
||||
filters1.value = {
|
||||
global: { value: null, matchMode: FilterMatchMode.CONTAINS },
|
||||
name: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.STARTS_WITH }] },
|
||||
'country.name': { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.STARTS_WITH }] },
|
||||
representative: { value: null, matchMode: FilterMatchMode.IN },
|
||||
date: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.DATE_IS }] },
|
||||
balance: { operator: FilterOperator.AND, constraints: [{ value: null, matchMode: FilterMatchMode.EQUALS }] },
|
||||
status: { operator: FilterOperator.OR, constraints: [{ value: null, matchMode: FilterMatchMode.EQUALS }] },
|
||||
activity: { value: [0, 100], matchMode: FilterMatchMode.BETWEEN },
|
||||
verified: { value: null, matchMode: FilterMatchMode.EQUALS }
|
||||
};
|
||||
}
|
||||
|
||||
function expandAll() {
|
||||
expandedRows.value = products.value.reduce((acc, p) => (acc[p.id] = true) && acc, {});
|
||||
}
|
||||
|
||||
function collapseAll() {
|
||||
expandedRows.value = null;
|
||||
}
|
||||
|
||||
function formatCurrency(value) {
|
||||
return value.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
return value.toLocaleDateString('en-US', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function calculateCustomerTotal(name) {
|
||||
let total = 0;
|
||||
if (customers3.value) {
|
||||
for (let customer of customers3.value) {
|
||||
if (customer.representative.name === name) {
|
||||
total++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">Filtering</div>
|
||||
<DataTable
|
||||
:value="customers1"
|
||||
:paginator="true"
|
||||
:rows="10"
|
||||
dataKey="id"
|
||||
:rowHover="true"
|
||||
v-model:filters="filters1"
|
||||
filterDisplay="menu"
|
||||
:loading="loading1"
|
||||
:filters="filters1"
|
||||
:globalFilterFields="['name', 'country.name', 'representative.name', 'balance', 'status']"
|
||||
showGridlines
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex justify-between">
|
||||
<Button type="button" icon="pi pi-filter-slash" label="Clear" outlined @click="clearFilter()" />
|
||||
<IconField>
|
||||
<InputIcon>
|
||||
<i class="pi pi-search" />
|
||||
</InputIcon>
|
||||
<InputText v-model="filters1['global'].value" placeholder="Keyword Search" />
|
||||
</IconField>
|
||||
</div>
|
||||
</template>
|
||||
<template #empty> No customers found. </template>
|
||||
<template #loading> Loading customers data. Please wait. </template>
|
||||
<Column field="name" header="Name" style="min-width: 12rem">
|
||||
<template #body="{ data }">
|
||||
{{ data.name }}
|
||||
</template>
|
||||
<template #filter="{ filterModel }">
|
||||
<InputText v-model="filterModel.value" type="text" placeholder="Search by name" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Country" filterField="country.name" style="min-width: 12rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-2">
|
||||
<img alt="flag" src="https://primefaces.org/cdn/primevue/images/flag/flag_placeholder.png" :class="`flag flag-${data.country.code}`" style="width: 24px" />
|
||||
<span>{{ data.country.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #filter="{ filterModel }">
|
||||
<InputText v-model="filterModel.value" type="text" placeholder="Search by country" />
|
||||
</template>
|
||||
<template #filterclear="{ filterCallback }">
|
||||
<Button type="button" icon="pi pi-times" @click="filterCallback()" severity="secondary"></Button>
|
||||
</template>
|
||||
<template #filterapply="{ filterCallback }">
|
||||
<Button type="button" icon="pi pi-check" @click="filterCallback()" severity="success"></Button>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Agent" filterField="representative" :showFilterMatchModes="false" :filterMenuStyle="{ width: '14rem' }" style="min-width: 14rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-2">
|
||||
<img :alt="data.representative.name" :src="`https://primefaces.org/cdn/primevue/images/avatar/${data.representative.image}`" style="width: 32px" />
|
||||
<span>{{ data.representative.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #filter="{ filterModel }">
|
||||
<MultiSelect v-model="filterModel.value" :options="representatives" optionLabel="name" placeholder="Any">
|
||||
<template #option="slotProps">
|
||||
<div class="flex items-center gap-2">
|
||||
<img :alt="slotProps.option.name" :src="`https://primefaces.org/cdn/primevue/images/avatar/${slotProps.option.image}`" style="width: 32px" />
|
||||
<span>{{ slotProps.option.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</MultiSelect>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Date" filterField="date" dataType="date" style="min-width: 10rem">
|
||||
<template #body="{ data }">
|
||||
{{ formatDate(data.date) }}
|
||||
</template>
|
||||
<template #filter="{ filterModel }">
|
||||
<DatePicker v-model="filterModel.value" dateFormat="mm/dd/yy" placeholder="mm/dd/yyyy" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Balance" filterField="balance" dataType="numeric" style="min-width: 10rem">
|
||||
<template #body="{ data }">
|
||||
{{ formatCurrency(data.balance) }}
|
||||
</template>
|
||||
<template #filter="{ filterModel }">
|
||||
<InputNumber v-model="filterModel.value" mode="currency" currency="USD" locale="en-US" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Status" field="status" :filterMenuStyle="{ width: '14rem' }" style="min-width: 12rem">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.status" :severity="getSeverity(data.status)" />
|
||||
</template>
|
||||
<template #filter="{ filterModel }">
|
||||
<Select v-model="filterModel.value" :options="statuses" placeholder="Select One" showClear>
|
||||
<template #option="slotProps">
|
||||
<Tag :value="slotProps.option" :severity="getSeverity(slotProps.option)" />
|
||||
</template>
|
||||
</Select>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="activity" header="Activity" :showFilterMatchModes="false" style="min-width: 12rem">
|
||||
<template #body="{ data }">
|
||||
<ProgressBar :value="data.activity" :showValue="false" style="height: 6px"></ProgressBar>
|
||||
</template>
|
||||
<template #filter="{ filterModel }">
|
||||
<Slider v-model="filterModel.value" range class="m-4"></Slider>
|
||||
<div class="flex items-center justify-between px-2">
|
||||
<span>{{ filterModel.value ? filterModel.value[0] : 0 }}</span>
|
||||
<span>{{ filterModel.value ? filterModel.value[1] : 100 }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="verified" header="Verified" dataType="boolean" bodyClass="text-center" style="min-width: 8rem">
|
||||
<template #body="{ data }">
|
||||
<i class="pi" :class="{ 'pi-check-circle text-green-500 ': data.verified, 'pi-times-circle text-red-500': !data.verified }"></i>
|
||||
</template>
|
||||
<template #filter="{ filterModel }">
|
||||
<label for="verified-filter" class="font-bold"> Verified </label>
|
||||
<Checkbox v-model="filterModel.value" :indeterminate="filterModel.value === null" binary inputId="verified-filter" />
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">Frozen Columns</div>
|
||||
<ToggleButton v-model="balanceFrozen" onIcon="pi pi-lock" offIcon="pi pi-lock-open" onLabel="Balance" offLabel="Balance" />
|
||||
|
||||
<DataTable :value="customers2" scrollable scrollHeight="400px" class="mt-6">
|
||||
<Column field="name" header="Name" style="min-width: 200px" frozen class="font-bold"></Column>
|
||||
<Column field="id" header="Id" style="min-width: 100px"></Column>
|
||||
<Column field="name" header="Name" style="min-width: 200px"></Column>
|
||||
<Column field="country.name" header="Country" style="min-width: 200px"></Column>
|
||||
<Column field="date" header="Date" style="min-width: 200px"></Column>
|
||||
<Column field="company" header="Company" style="min-width: 200px"></Column>
|
||||
<Column field="status" header="Status" style="min-width: 200px"></Column>
|
||||
<Column field="activity" header="Activity" style="min-width: 200px"></Column>
|
||||
<Column field="representative.name" header="Representative" style="min-width: 200px"></Column>
|
||||
<Column field="balance" header="Balance" style="min-width: 200px" alignFrozen="right" :frozen="balanceFrozen">
|
||||
<template #body="{ data }">
|
||||
<span class="font-bold">{{ formatCurrency(data.balance) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">Row Expansion</div>
|
||||
<DataTable v-model:expandedRows="expandedRows" :value="products" dataKey="id" tableStyle="min-width: 60rem">
|
||||
<template #header>
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<Button text icon="pi pi-plus" label="Expand All" @click="expandAll" />
|
||||
<Button text icon="pi pi-minus" label="Collapse All" @click="collapseAll" />
|
||||
</div>
|
||||
</template>
|
||||
<Column expander style="width: 5rem" />
|
||||
<Column field="name" header="Name"></Column>
|
||||
<Column header="Image">
|
||||
<template #body="slotProps">
|
||||
<img :src="`https://primefaces.org/cdn/primevue/images/product/${slotProps.data.image}`" :alt="slotProps.data.image" class="shadow-lg" width="64" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="price" header="Price">
|
||||
<template #body="slotProps">
|
||||
{{ formatCurrency(slotProps.data.price) }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="category" header="Category"></Column>
|
||||
<Column field="rating" header="Reviews">
|
||||
<template #body="slotProps">
|
||||
<Rating :modelValue="slotProps.data.rating" readonly />
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Status">
|
||||
<template #body="slotProps">
|
||||
<Tag :value="slotProps.data.inventoryStatus" :severity="getStockSeverity(slotProps.data)" />
|
||||
</template>
|
||||
</Column>
|
||||
<template #expansion="slotProps">
|
||||
<div class="p-4">
|
||||
<h5>Orders for {{ slotProps.data.name }}</h5>
|
||||
<DataTable :value="slotProps.data.orders">
|
||||
<Column field="id" header="Id" sortable></Column>
|
||||
<Column field="customer" header="Customer" sortable></Column>
|
||||
<Column field="date" header="Date" sortable></Column>
|
||||
<Column field="amount" header="Amount" sortable>
|
||||
<template #body="slotProps">
|
||||
{{ formatCurrency(slotProps.data.amount) }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="status" header="Status" sortable>
|
||||
<template #body="slotProps">
|
||||
<Tag :value="slotProps.data.status.toLowerCase()" :severity="getOrderSeverity(slotProps.data)" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column headerStyle="width:4rem">
|
||||
<template #body>
|
||||
<Button icon="pi pi-search" />
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">Grouping</div>
|
||||
<DataTable :value="customers3" rowGroupMode="subheader" groupRowsBy="representative.name" sortMode="single" sortField="representative.name" :sortOrder="1" scrollable scrollHeight="400px" tableStyle="min-width: 50rem">
|
||||
<template #groupheader="slotProps">
|
||||
<div class="flex items-center gap-2">
|
||||
<img :alt="slotProps.data.representative.name" :src="`https://primefaces.org/cdn/primevue/images/avatar/${slotProps.data.representative.image}`" width="32" style="vertical-align: middle" />
|
||||
<span>{{ slotProps.data.representative.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<Column field="representative.name" header="Representative"></Column>
|
||||
<Column field="name" header="Name" style="min-width: 200px"></Column>
|
||||
<Column field="country" header="Country" style="min-width: 200px">
|
||||
<template #body="slotProps">
|
||||
<div class="flex items-center gap-2">
|
||||
<img alt="flag" src="https://primefaces.org/cdn/primevue/images/flag/flag_placeholder.png" :class="`flag flag-${slotProps.data.country.code}`" style="width: 24px" />
|
||||
<span>{{ slotProps.data.country.name }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="company" header="Company" style="min-width: 200px"></Column>
|
||||
<Column field="status" header="Status" style="min-width: 200px">
|
||||
<template #body="slotProps">
|
||||
<Tag :value="slotProps.data.status" :severity="getSeverity(slotProps.data.status)" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="date" header="Date" style="min-width: 200px"></Column>
|
||||
<template #groupfooter="slotProps">
|
||||
<div class="flex justify-end font-bold w-full">Total Customers: {{ calculateCustomerTotal(slotProps.data.representative.name) }}</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:deep(.p-datatable-frozen-tbody) {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
:deep(.p-datatable-scrollable .p-frozen-column) {
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
@@ -1,159 +0,0 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
|
||||
const events = ref([
|
||||
{
|
||||
status: 'Ordered',
|
||||
date: '15/10/2020 10:30',
|
||||
icon: 'pi pi-shopping-cart',
|
||||
color: '#9C27B0',
|
||||
image: 'game-controller.jpg'
|
||||
},
|
||||
{
|
||||
status: 'Processing',
|
||||
date: '15/10/2020 14:00',
|
||||
icon: 'pi pi-cog',
|
||||
color: '#673AB7'
|
||||
},
|
||||
{
|
||||
status: 'Shipped',
|
||||
date: '15/10/2020 16:15',
|
||||
icon: 'pi pi-envelope',
|
||||
color: '#FF9800'
|
||||
},
|
||||
{
|
||||
status: 'Delivered',
|
||||
date: '16/10/2020 10:00',
|
||||
icon: 'pi pi-check',
|
||||
color: '#607D8B'
|
||||
}
|
||||
]);
|
||||
|
||||
const horizontalEvents = ref(['2020', '2021', '2022', '2023']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-12 gap-8">
|
||||
<div class="col-span-6">
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">Left Align</div>
|
||||
<Timeline :value="events">
|
||||
<template #content="slotProps">
|
||||
{{ slotProps.item.status }}
|
||||
</template>
|
||||
</Timeline>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-6">
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">Right Align</div>
|
||||
<Timeline :value="events" align="right">
|
||||
<template #content="slotProps">
|
||||
{{ slotProps.item.status }}
|
||||
</template>
|
||||
</Timeline>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-6">
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">Alternate Align</div>
|
||||
<Timeline :value="events" align="alternate">
|
||||
<template #content="slotProps">
|
||||
{{ slotProps.item.status }}
|
||||
</template>
|
||||
</Timeline>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-6">
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">Opposite Content</div>
|
||||
<Timeline :value="events">
|
||||
<template #opposite="slotProps">
|
||||
<small class="text-muted-color">{{ slotProps.item.date }}</small>
|
||||
</template>
|
||||
<template #content="slotProps">
|
||||
{{ slotProps.item.status }}
|
||||
</template>
|
||||
</Timeline>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-full">
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">Templating</div>
|
||||
<Timeline :value="events" align="alternate" class="customized-timeline">
|
||||
<template #marker="slotProps">
|
||||
<span class="flex w-8 h-8 items-center justify-center text-white rounded-full z-10 shadow-sm" :style="{ backgroundColor: slotProps.item.color }">
|
||||
<i :class="slotProps.item.icon"></i>
|
||||
</span>
|
||||
</template>
|
||||
<template #content="slotProps">
|
||||
<Card class="mt-4">
|
||||
<template #title>
|
||||
{{ slotProps.item.status }}
|
||||
</template>
|
||||
<template #subtitle>
|
||||
{{ slotProps.item.date }}
|
||||
</template>
|
||||
<template #content>
|
||||
<img v-if="slotProps.item.image" :src="`https://primefaces.org/cdn/primevue/images/product/${slotProps.item.image}`" :alt="slotProps.item.name" width="200" class="shadow-sm" />
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Inventore sed consequuntur error repudiandae numquam deserunt quisquam repellat libero asperiores earum nam nobis, culpa ratione quam perferendis esse,
|
||||
cupiditate neque quas!
|
||||
</p>
|
||||
<Button label="Read more" text></Button>
|
||||
</template>
|
||||
</Card>
|
||||
</template>
|
||||
</Timeline>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-full">
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">Horizontal</div>
|
||||
<div class="font-semibold mb-2">Top Align</div>
|
||||
<Timeline :value="horizontalEvents" layout="horizontal" align="top">
|
||||
<template #content="slotProps">
|
||||
{{ slotProps.item }}
|
||||
</template>
|
||||
</Timeline>
|
||||
|
||||
<div class="font-semibold mt-4 mb-2">Bottom Align</div>
|
||||
<Timeline :value="horizontalEvents" layout="horizontal" align="bottom">
|
||||
<template #content="slotProps">
|
||||
{{ slotProps.item }}
|
||||
</template>
|
||||
</Timeline>
|
||||
|
||||
<div class="font-semibold mt-4 mb-2">Alternate Align</div>
|
||||
<Timeline :value="horizontalEvents" layout="horizontal" align="alternate">
|
||||
<template #opposite> </template>
|
||||
<template #content="slotProps">
|
||||
{{ slotProps.item }}
|
||||
</template>
|
||||
</Timeline>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@media screen and (max-width: 960px) {
|
||||
::v-deep(.customized-timeline) {
|
||||
.p-timeline-event:nth-child(even) {
|
||||
flex-direction: row !important;
|
||||
|
||||
.p-timeline-event-content {
|
||||
text-align: left !important;
|
||||
}
|
||||
}
|
||||
|
||||
.p-timeline-event-opposite {
|
||||
flex: 0;
|
||||
}
|
||||
|
||||
.p-card {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,30 +0,0 @@
|
||||
<script setup>
|
||||
import { NodeService } from '@/services/NodeService';
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
const treeValue = ref(null);
|
||||
const selectedTreeValue = ref(null);
|
||||
const treeTableValue = ref(null);
|
||||
const selectedTreeTableValue = ref(null);
|
||||
|
||||
onMounted(() => {
|
||||
NodeService.getTreeNodes().then((data) => (treeValue.value = data));
|
||||
NodeService.getTreeTableNodes().then((data) => (treeTableValue.value = data));
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl">Tree</div>
|
||||
<Tree :value="treeValue" selectionMode="checkbox" v-model:selectionKeys="selectedTreeValue"></Tree>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="font-semibold text-xl mb-4">TreeTable</div>
|
||||
<TreeTable :value="treeTableValue" selectionMode="checkbox" v-model:selectionKeys="selectedTreeTableValue">
|
||||
<Column field="name" header="Name" :expander="true"></Column>
|
||||
<Column field="size" header="Size"></Column>
|
||||
<Column field="type" header="Type"></Column>
|
||||
</TreeTable>
|
||||
</div>
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user