Agenda, Agendador, Configurações

This commit is contained in:
Leonardo
2026-03-12 08:58:36 -03:00
parent f733db8436
commit f4b185ae17
197 changed files with 33405 additions and 6507 deletions

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -1,2 +0,0 @@
@use './code.scss';
@use './flags/flags.css';

File diff suppressed because one or more lines are too long

View File

@@ -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';

View 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 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 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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
View 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,
}
}

View 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 }
}

View 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 (112) ─────────────────────────────
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
}
}

View File

@@ -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, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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

View 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 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>

View 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 / bloqueado -->
<template v-if="isDiaUtil(f.data)">
<!-- bloqueado -->
<span
v-if="jaFoiBloqueado(f.data)"
v-tooltip.top="'Dia 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" />
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>

View 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)
})
})

View File

@@ -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
}

View File

@@ -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 }
}

View 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 }
}

View 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

View 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

View 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>

View 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)
})
})

View File

@@ -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)

View File

@@ -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}`
}

View File

@@ -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 || []
}
/**

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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 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>

View File

@@ -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'
})

View 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" />
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>

View File

@@ -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>
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 {

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View File

@@ -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

View File

@@ -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
}
]
}

View File

@@ -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' }
]
}
]
}
]

View File

@@ -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' }
]
}
]

View File

@@ -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

View File

@@ -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()

View File

@@ -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]),

View File

@@ -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'
}
}
]
}

View File

@@ -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')
}
]
}

View File

@@ -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') }
]
}

View File

@@ -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 }
}
]
};

View File

@@ -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 }
}
]
}

View File

@@ -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
// ======================================================

View 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>

View 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,
}
})

View 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)
}
}

View 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 (1120, 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
View 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 112
*/
export function getFeriadosNacionaisDoMes (ano, mes) {
const m = String(mes).padStart(2, '0')
return getFeriadosNacionais(ano).filter(f => f.data.slice(5, 7) === m)
}

View 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,
]

View File

@@ -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>&lt;template&gt;
&lt;NuxtLayout&gt;
&lt;NuxtPage /&gt;
&lt;/NuxtLayout&gt;
&lt;/template&gt;</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>&lt;script setup&gt;
import AppLayout from './AppLayout.vue';
&lt;/script&gt;
&lt;template&gt;
&lt;AppLayout /&gt;
&lt;/template&gt;</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>

View File

@@ -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',

View File

@@ -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">

View File

@@ -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%)">

View File

@@ -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%)">

View File

@@ -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 ===== -->

View File

@@ -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 ===== -->

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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> &nbsp; </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>

View File

@@ -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