Agenda, Agendador, Configurações
This commit is contained in:
@@ -1,149 +0,0 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="font-semibold text-2xl mb-4">Documentation</div>
|
||||
<div class="font-semibold text-xl mb-4">Get Started</div>
|
||||
<p class="text-lg mb-4">
|
||||
Sakai is an application template for Vue based on the <a href="https://github.com/vuejs/create-vue" class="font-medium text-primary hover:underline">create-vue</a>, the recommended way to start a <strong>Vite-powered</strong> Vue
|
||||
projects. To get started, clone the <a href="https://github.com/primefaces/sakai-vue" class="font-medium text-primary hover:underline">repository</a> from GitHub and install the dependencies with npm or yarn.
|
||||
</p>
|
||||
<pre class="app-code">
|
||||
<code>git clone https://github.com/primefaces/sakai-vue
|
||||
npm install
|
||||
npm run dev</code></pre>
|
||||
|
||||
<p class="text-lg mb-4">Navigate to <i class="bg-highlight px-2 py-1 rounded-border not-italic text-base">http://localhost:5173/</i> to view the application in your local environment.</p>
|
||||
|
||||
<pre class="app-code"><code>npm run dev</code></pre>
|
||||
|
||||
<div class="font-semibold text-xl mb-4">Structure</div>
|
||||
<p class="text-lg mb-4">Templates consists of a couple folders, demos and layout have been separated so that you can easily remove what is not necessary for your application.</p>
|
||||
<ul class="leading-normal list-disc pl-8 text-lg mb-4">
|
||||
<li><span class="text-primary font-medium">src/layout</span>: Main layout files, needs to be present.</li>
|
||||
<li><span class="text-primary font-medium">src/views</span>: Demo pages like Dashboard.</li>
|
||||
<li><span class="text-primary font-medium">public/demo</span>: Assets used in demos</li>
|
||||
<li><span class="text-primary font-medium">src/assets/demo</span>: Styles used in demos</li>
|
||||
<li><span class="text-primary font-medium">src/assets/layout</span>: SCSS files of the main layout</li>
|
||||
</ul>
|
||||
|
||||
<div class="font-semibold text-xl mb-4">Menu</div>
|
||||
<p class="text-lg mb-4">
|
||||
Main menu is defined at <span class="bg-highlight px-2 py-1 rounded-border not-italic text-base">src/layout/AppMenu.vue</span> file. Update the <i class="bg-highlight px-2 py-1 rounded-border not-italic text-base">model</i> property to
|
||||
define your own menu items.
|
||||
</p>
|
||||
|
||||
<div class="font-semibold text-xl mb-4">Layout Composable</div>
|
||||
<p class="text-lg mb-4">
|
||||
The <span class="bg-highlight px-2 py-1 rounded-border not-italic text-base">src/layout/composables/layout.js</span> is a composable that manages the layout state changes including dark mode, PrimeVue theme, menu modes and states. If you
|
||||
change the initial values like the preset or colors, make sure to apply them at PrimeVue config at main.js as well.
|
||||
</p>
|
||||
|
||||
<div class="font-semibold text-xl mb-4">Tailwind CSS</div>
|
||||
<p class="text-lg mb-4">The demo pages are developed with Tailwind CSS however the core application shell mainly uses custom CSS.</p>
|
||||
|
||||
<div class="font-semibold text-xl mb-4">Variables</div>
|
||||
<p class="text-lg mb-4">
|
||||
CSS variables used in the template derive their values from the PrimeVue styled mode presets, use the files under <span class="bg-highlight px-2 py-1 rounded-border not-italic text-base">assets/layout/_variables.scss</span> to customize
|
||||
according to your requirements.
|
||||
</p>
|
||||
|
||||
<div class="font-semibold text-xl mb-4">Add Sakai-Vue to a Nuxt Project</div>
|
||||
<p class="text-lg mb-4">To get started, create a Nuxt project.</p>
|
||||
<pre class="app-code">
|
||||
<code>npx nuxi@latest init sakai-nuxt</code></pre>
|
||||
|
||||
<p class="text-lg mb-4">Add Prime related libraries to the project.</p>
|
||||
<pre class="app-code">
|
||||
<code>npm install primevue @primevue/themes tailwindcss-primeui primeicons
|
||||
npm install --save-dev @primevue/nuxt-module</code></pre>
|
||||
|
||||
<p class="text-lg mb-4">Add PrimeVue-Nuxt module to <span class="bg-highlight px-2 py-1 rounded-border not-italic text-base">nuxt.config.js</span></p>
|
||||
<pre class="app-code">
|
||||
<code>modules: [
|
||||
'@primevue/nuxt-module',
|
||||
]</code></pre>
|
||||
|
||||
<p class="text-lg mb-4">Install <a href="https://tailwindcss.com/docs/guides/nuxtjs" class="font-medium text-primary hover:underline">Tailwind CSS</a> with Nuxt using official documentation.</p>
|
||||
|
||||
<p class="text-lg mb-4">
|
||||
Add <span class="bg-highlight px-2 py-1 rounded-border not-italic text-base">tailwindcss-primeui</span> package as a plugin to <span class="bg-highlight px-2 py-1 rounded-border not-italic text-base">tailwind.config.js</span>
|
||||
</p>
|
||||
<pre class="app-code">
|
||||
<code>plugins: [require('tailwindcss-primeui')]</code></pre>
|
||||
|
||||
<p class="text-lg mb-4">Add PrimeVue to in <span class="bg-highlight px-2 py-1 rounded-border not-italic text-base">nuxt.config.js</span></p>
|
||||
<pre class="app-code">
|
||||
<code>import Aura from '@primevue/themes/aura';
|
||||
|
||||
primevue: {
|
||||
options: {
|
||||
theme: {
|
||||
preset: Aura,
|
||||
options: {
|
||||
darkModeSelector: '.app-dark'
|
||||
}
|
||||
}
|
||||
}
|
||||
}</code></pre>
|
||||
|
||||
<p class="text-lg mb-4">
|
||||
Copy <span class="bg-highlight px-2 py-1 rounded-border not-italic text-base">src/assets</span> folder and paste them to <span class="bg-highlight px-2 py-1 rounded-border not-italic text-base">assets</span> folder to your Nuxt project.
|
||||
And add to <span class="bg-highlight px-2 py-1 rounded-border not-italic text-base">nuxt.config.js</span>
|
||||
</p>
|
||||
<pre class="app-code">
|
||||
<code>css: ['~/assets/tailwind.css', '~/assets/styles.scss']</code></pre>
|
||||
|
||||
<p class="text-lg mb-4">Change <span class="bg-highlight px-2 py-1 rounded-border not-italic text-base">app.vue</span></p>
|
||||
<pre class="app-code">
|
||||
<code><template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template></code></pre>
|
||||
|
||||
<p class="text-lg mb-4">Create <span class="bg-highlight px-2 py-1 rounded-border not-italic text-base">layouts/default.vue</span> and paste this code:</p>
|
||||
<pre class="app-code">
|
||||
<code><script setup>
|
||||
import AppLayout from './AppLayout.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout />
|
||||
</template></code></pre>
|
||||
|
||||
<p class="text-lg mb-4">
|
||||
Create <span class="bg-highlight px-2 py-1 rounded-border not-italic text-base">layouts</span> folder and copy <span class="bg-highlight px-2 py-1 rounded-border not-italic text-base">src/layout</span> folder and paste them. And then
|
||||
create <span class="bg-highlight px-2 py-1 rounded-border not-italic text-base">composables/use-layout.vue</span> and replace it with
|
||||
<span class="bg-highlight px-2 py-1 rounded-border not-italic text-base">src/layout/composables/layout.js</span>. Then remove this line:
|
||||
</p>
|
||||
<pre class="app-code">
|
||||
<code>import { useLayout } from '@/layout/composables/layout';</code></pre>
|
||||
|
||||
<p class="text-lg mb-4">As a final step, copy the following folders:</p>
|
||||
<ul class="leading-normal list-disc pl-8 text-lg mb-4">
|
||||
<li><span class="text-primary font-medium">public/demo</span> <i class="pi pi-arrow-right text-sm! mr-1"></i> <span class="text-primary font-medium">public</span></li>
|
||||
<li><span class="text-primary font-medium">src/components</span> <i class="pi pi-arrow-right text-sm! mr-1"></i> <span class="text-primary font-medium">components</span></li>
|
||||
<li><span class="text-primary font-medium">src/service</span> <i class="pi pi-arrow-right text-sm! mr-1"></i> <span class="text-primary font-medium">service</span></li>
|
||||
<li><span class="text-primary font-medium">src/views/uikit</span> <i class="pi pi-arrow-right text-sm! mr-1"></i> <span class="text-primary font-medium">pages/uikit</span></li>
|
||||
<li><span class="text-primary font-medium">src/views/pages</span> <i class="pi pi-arrow-right text-sm! mr-1"></i> <span class="text-primary font-medium">pages</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@media screen and (max-width: 991px) {
|
||||
.video-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
padding-bottom: 56.25%;
|
||||
|
||||
iframe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -91,7 +91,7 @@ const PROFILE_CARDS = [
|
||||
{
|
||||
key: 'saas',
|
||||
index: '06',
|
||||
label: 'Master',
|
||||
label: 'SaaS',
|
||||
description: 'Visão global da plataforma: tenants, assinaturas e saúde.',
|
||||
icon: 'pi-shield',
|
||||
color: '#F43F5E',
|
||||
|
||||
@@ -3,10 +3,6 @@ import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
|
||||
// Se você ainda usa o FloatingConfigurator no template de páginas públicas,
|
||||
// pode manter. Se não usa, pode remover tranquilamente.
|
||||
import FloatingConfigurator from '@/components/FloatingConfigurator.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
@@ -19,8 +15,6 @@ function goDashboard () {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FloatingConfigurator />
|
||||
|
||||
<div class="relative min-h-screen overflow-hidden bg-[var(--surface-ground)] text-[var(--text-color)]">
|
||||
<!-- fundo conceitual: grid + halos -->
|
||||
<div class="pointer-events-none absolute inset-0 opacity-80">
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
<script setup>
|
||||
import FloatingConfigurator from '@/components/FloatingConfigurator.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FloatingConfigurator />
|
||||
<div class="bg-surface-50 dark:bg-surface-950 flex items-center justify-center min-h-screen min-w-[100vw] overflow-hidden">
|
||||
<div class="flex flex-col items-center justify-center">
|
||||
<div style="border-radius: 56px; padding: 0.3rem; background: linear-gradient(180deg, rgba(247, 149, 48, 0.4) 10%, rgba(247, 149, 48, 0) 30%)">
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
<script setup>
|
||||
import FloatingConfigurator from '@/components/FloatingConfigurator.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FloatingConfigurator />
|
||||
<div class="bg-surface-50 dark:bg-surface-950 flex items-center justify-center min-h-screen min-w-[100vw] overflow-hidden">
|
||||
<div class="flex flex-col items-center justify-center">
|
||||
<div style="border-radius: 56px; padding: 0.3rem; background: linear-gradient(180deg, rgba(233, 30, 99, 0.4) 10%, rgba(33, 150, 243, 0) 30%)">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup>
|
||||
import FloatingConfigurator from '@/components/FloatingConfigurator.vue'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
|
||||
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
|
||||
@@ -295,8 +294,6 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FloatingConfigurator />
|
||||
|
||||
<div class="min-h-screen w-full flex">
|
||||
|
||||
<!-- ===== ESQUERDA: CARROSSEL ===== -->
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup>
|
||||
import FloatingConfigurator from '@/components/FloatingConfigurator.vue'
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
@@ -124,8 +123,6 @@ async function submit () {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FloatingConfigurator />
|
||||
|
||||
<div class="min-h-screen w-full flex">
|
||||
|
||||
<!-- ===== ESQUERDA: Painel de segurança ===== -->
|
||||
|
||||
1619
src/views/pages/public/AgendadorPublicoPage.vue
Normal file
1619
src/views/pages/public/AgendadorPublicoPage.vue
Normal file
File diff suppressed because it is too large
Load Diff
1200
src/views/pages/saas/SaasDocsPage.vue
Normal file
1200
src/views/pages/saas/SaasDocsPage.vue
Normal file
File diff suppressed because it is too large
Load Diff
550
src/views/pages/saas/SaasFaqPage.vue
Normal file
550
src/views/pages/saas/SaasFaqPage.vue
Normal file
@@ -0,0 +1,550 @@
|
||||
<!-- src/views/pages/saas/SaasFaqPage.vue -->
|
||||
<!-- Portal de FAQ — consulta de perguntas frequentes por usuários logados -->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useDocsAdmin } from '@/composables/useDocsAdmin'
|
||||
|
||||
const router = useRouter()
|
||||
const { requestEditDoc } = useDocsAdmin()
|
||||
|
||||
function editarDoc (docId) {
|
||||
requestEditDoc(docId)
|
||||
router.push('/saas/docs')
|
||||
}
|
||||
|
||||
// ── Estado ────────────────────────────────────────────────────
|
||||
const loading = ref(false)
|
||||
const docs = ref([]) // docs com exibir_no_faq = true
|
||||
const faqItens = ref([]) // todos os itens FAQ dos docs acima
|
||||
|
||||
const busca = ref('')
|
||||
const catAtiva = ref(null) // categoria selecionada no sidebar
|
||||
|
||||
// Controla quais perguntas estão abertas { [itemId]: boolean }
|
||||
const abertos = ref({})
|
||||
|
||||
// ── Load ──────────────────────────────────────────────────────
|
||||
async function load () {
|
||||
loading.value = true
|
||||
try {
|
||||
// Busca docs habilitados no FAQ
|
||||
const { data: docsData, error: docsErr } = await supabase
|
||||
.from('saas_docs')
|
||||
.select('id, titulo, categoria, ordem, pagina_path')
|
||||
.eq('ativo', true)
|
||||
.eq('exibir_no_faq', true)
|
||||
.order('categoria')
|
||||
.order('ordem')
|
||||
if (docsErr) throw docsErr
|
||||
|
||||
docs.value = docsData || []
|
||||
|
||||
if (!docs.value.length) return
|
||||
|
||||
// Busca todos os itens FAQ desses docs
|
||||
const docIds = docs.value.map(d => d.id)
|
||||
const { data: itensData, error: itensErr } = await supabase
|
||||
.from('saas_faq_itens')
|
||||
.select('id, doc_id, pergunta, resposta, ordem')
|
||||
.in('doc_id', docIds)
|
||||
.eq('ativo', true)
|
||||
.order('ordem')
|
||||
if (itensErr) throw itensErr
|
||||
|
||||
faqItens.value = itensData || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
|
||||
// ── Categorias disponíveis ────────────────────────────────────
|
||||
const categorias = computed(() => {
|
||||
const set = new Set(docs.value.map(d => d.categoria).filter(Boolean))
|
||||
return [...set].sort()
|
||||
})
|
||||
|
||||
// ── Docs filtrados pela categoria ativa ───────────────────────
|
||||
const docsFiltrados = computed(() => {
|
||||
if (!catAtiva.value) return docs.value
|
||||
return docs.value.filter(d => d.categoria === catAtiva.value)
|
||||
})
|
||||
|
||||
// ── Itens de um doc, aplicando busca ─────────────────────────
|
||||
function itensDo (docId) {
|
||||
const q = busca.value.trim().toLowerCase()
|
||||
return faqItens.value.filter(f => {
|
||||
if (f.doc_id !== docId) return false
|
||||
if (!q) return true
|
||||
return (
|
||||
f.pergunta.toLowerCase().includes(q) ||
|
||||
(f.resposta || '').toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// ── Docs que têm resultado na busca ──────────────────────────
|
||||
const docsComResultado = computed(() => {
|
||||
return docsFiltrados.value.filter(d => itensDo(d.id).length > 0)
|
||||
})
|
||||
|
||||
// Total de resultados para feedback
|
||||
const totalResultados = computed(() => {
|
||||
if (!busca.value.trim()) return null
|
||||
return docsComResultado.value.reduce((acc, d) => acc + itensDo(d.id).length, 0)
|
||||
})
|
||||
|
||||
// ── Toggle pergunta ───────────────────────────────────────────
|
||||
function toggle (id) {
|
||||
abertos.value[id] = !abertos.value[id]
|
||||
}
|
||||
|
||||
// Abre todas as perguntas dos resultados quando há busca ativa
|
||||
function expandirResultados () {
|
||||
docsComResultado.value.forEach(d => {
|
||||
itensDo(d.id).forEach(item => {
|
||||
abertos.value[item.id] = true
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Observa busca: expande automaticamente quando tem busca
|
||||
watch(busca, (val) => {
|
||||
if (val.trim()) expandirResultados()
|
||||
})
|
||||
|
||||
// ── Selecionar categoria ──────────────────────────────────────
|
||||
function selecionarCat (cat) {
|
||||
catAtiva.value = catAtiva.value === cat ? null : cat
|
||||
busca.value = ''
|
||||
abertos.value = {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="faq-page">
|
||||
|
||||
<!-- ── Cabeçalho ─────────────────────────────────────────── -->
|
||||
<div class="faq-header">
|
||||
<div class="faq-header-inner">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="faq-icon-wrap">
|
||||
<i class="pi pi-comments text-xl" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="faq-title">Central de Ajuda</h1>
|
||||
<p class="faq-subtitle">Encontre respostas para as dúvidas mais comuns</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Busca -->
|
||||
<div class="faq-search-wrap">
|
||||
<IconField class="w-full">
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText
|
||||
v-model="busca"
|
||||
placeholder="Buscar pergunta…"
|
||||
class="faq-search-input"
|
||||
/>
|
||||
<InputIcon v-if="busca" class="pi pi-times cursor-pointer" @click="busca = ''" />
|
||||
</IconField>
|
||||
<div v-if="totalResultados !== null" class="faq-search-result">
|
||||
{{ totalResultados }} resultado{{ totalResultados !== 1 ? 's' : '' }} encontrado{{ totalResultados !== 1 ? 's' : '' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Corpo ─────────────────────────────────────────────── -->
|
||||
<div class="faq-body">
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex justify-center py-16">
|
||||
<i class="pi pi-spinner pi-spin text-2xl opacity-30" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
|
||||
<!-- Sidebar de categorias -->
|
||||
<aside v-if="categorias.length" class="faq-sidebar">
|
||||
<div class="faq-sidebar-title">Categorias</div>
|
||||
<button
|
||||
class="faq-cat-btn"
|
||||
:class="{ 'faq-cat-btn--active': !catAtiva }"
|
||||
@click="selecionarCat(null)"
|
||||
>
|
||||
<i class="pi pi-th-large text-xs mr-2" />
|
||||
Todas
|
||||
<span class="faq-cat-count">{{ faqItens.length }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-for="cat in categorias"
|
||||
:key="cat"
|
||||
class="faq-cat-btn"
|
||||
:class="{ 'faq-cat-btn--active': catAtiva === cat }"
|
||||
@click="selecionarCat(cat)"
|
||||
>
|
||||
<i class="pi pi-tag text-xs mr-2 opacity-60" />
|
||||
{{ cat }}
|
||||
<span class="faq-cat-count">
|
||||
{{ faqItens.filter(f => docs.find(d => d.id === f.doc_id && d.categoria === cat)).length }}
|
||||
</span>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<!-- Conteúdo principal -->
|
||||
<main class="faq-main">
|
||||
|
||||
<!-- Sem resultados -->
|
||||
<div v-if="docsComResultado.length === 0" class="faq-empty">
|
||||
<i class="pi pi-search text-3xl opacity-20 mb-3" />
|
||||
<p class="text-[var(--text-color-secondary)]">Nenhuma pergunta encontrada.</p>
|
||||
<button v-if="busca || catAtiva" class="text-[var(--primary-color)] text-sm mt-2 underline" @click="busca = ''; catAtiva = null; abertos = {}">
|
||||
Limpar filtros
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Grupos de docs -->
|
||||
<div
|
||||
v-for="doc in docsComResultado"
|
||||
:key="doc.id"
|
||||
class="faq-group"
|
||||
>
|
||||
<!-- Cabeçalho do grupo (doc) -->
|
||||
<div class="faq-group-header">
|
||||
<div class="faq-group-icon">
|
||||
<i class="pi pi-file-edit text-sm" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h2 class="faq-group-title">{{ doc.titulo }}</h2>
|
||||
<span v-if="doc.categoria" class="faq-group-cat">{{ doc.categoria }}</span>
|
||||
</div>
|
||||
<button
|
||||
class="edit-doc-btn"
|
||||
v-tooltip.top="'Editar documento'"
|
||||
@click="editarDoc(doc.id)"
|
||||
>
|
||||
<i class="pi pi-pencil text-xs" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Itens FAQ do grupo -->
|
||||
<div class="faq-items">
|
||||
<div
|
||||
v-for="item in itensDo(doc.id)"
|
||||
:key="item.id"
|
||||
class="faq-item"
|
||||
:class="{ 'faq-item--open': abertos[item.id] }"
|
||||
>
|
||||
<button class="faq-pergunta" @click="toggle(item.id)">
|
||||
<span class="faq-pergunta-text">{{ item.pergunta }}</span>
|
||||
<i
|
||||
class="pi shrink-0 text-sm opacity-40 transition-transform duration-200"
|
||||
:class="abertos[item.id] ? 'pi-chevron-up' : 'pi-chevron-down'"
|
||||
/>
|
||||
</button>
|
||||
<Transition name="faq-expand">
|
||||
<div
|
||||
v-if="abertos[item.id] && item.resposta"
|
||||
class="faq-resposta ql-content"
|
||||
v-html="item.resposta"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── Layout ──────────────────────────────────────────────────── */
|
||||
.faq-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────────── */
|
||||
.faq-header {
|
||||
background: var(--surface-card);
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
padding: 2rem 1.5rem 1.5rem;
|
||||
}
|
||||
.faq-header-inner {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.faq-icon-wrap {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
|
||||
color: var(--primary-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.faq-title {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.faq-subtitle {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color-secondary);
|
||||
margin: 2px 0 0;
|
||||
}
|
||||
|
||||
.faq-search-wrap {
|
||||
position: relative;
|
||||
}
|
||||
.faq-search-input {
|
||||
width: 100%;
|
||||
border-radius: 0.75rem !important;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.faq-search-result {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.7;
|
||||
margin-top: 0.375rem;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* ── Corpo ──────────────────────────────────────────────────── */
|
||||
.faq-body {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
padding: 1.5rem;
|
||||
flex: 1;
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* ── Sidebar ─────────────────────────────────────────────────── */
|
||||
.faq-sidebar {
|
||||
width: 200px;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.faq-sidebar-title {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.6;
|
||||
padding: 0 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.faq-cat-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-color-secondary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.faq-cat-btn:hover {
|
||||
background: var(--surface-hover);
|
||||
color: var(--text-color);
|
||||
}
|
||||
.faq-cat-btn--active {
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
color: var(--primary-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
.faq-cat-count {
|
||||
margin-left: auto;
|
||||
font-size: 0.7rem;
|
||||
opacity: 0.5;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── Main ─────────────────────────────────────────────────────── */
|
||||
.faq-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.faq-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 3rem 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Grupo (doc) ─────────────────────────────────────────────── */
|
||||
.faq-group {
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 1rem;
|
||||
overflow: hidden;
|
||||
background: var(--surface-card);
|
||||
}
|
||||
|
||||
.faq-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem 1.25rem;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
background: var(--surface-ground);
|
||||
}
|
||||
.faq-group-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
color: var(--primary-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.faq-group-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.faq-group-cat {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.6;
|
||||
display: block;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
.edit-doc-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
color: var(--text-color-secondary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, background 0.15s, color 0.15s;
|
||||
}
|
||||
.faq-group-header:hover .edit-doc-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
.edit-doc-btn:hover {
|
||||
background: var(--surface-hover);
|
||||
color: var(--primary-color);
|
||||
border-color: color-mix(in srgb, var(--primary-color) 30%, transparent);
|
||||
}
|
||||
|
||||
/* ── Itens FAQ ───────────────────────────────────────────────── */
|
||||
.faq-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.faq-item {
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.faq-item:last-child { border-bottom: none; }
|
||||
.faq-item--open { background: color-mix(in srgb, var(--primary-color) 3%, transparent); }
|
||||
|
||||
.faq-pergunta {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.875rem 1.25rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.faq-pergunta:hover { background: var(--surface-hover); }
|
||||
|
||||
.faq-pergunta-text {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.faq-resposta {
|
||||
padding: 0 1.25rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-color-secondary);
|
||||
line-height: 1.65;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Quill content */
|
||||
.faq-resposta.ql-content :deep(p) { margin: 0 0 0.5rem; }
|
||||
.faq-resposta.ql-content :deep(p:last-child) { margin-bottom: 0; }
|
||||
.faq-resposta.ql-content :deep(strong) { font-weight: 600; color: var(--text-color); }
|
||||
.faq-resposta.ql-content :deep(em) { font-style: italic; }
|
||||
.faq-resposta.ql-content :deep(ul),
|
||||
.faq-resposta.ql-content :deep(ol) { padding-left: 1.25rem; margin: 0.4rem 0; }
|
||||
.faq-resposta.ql-content :deep(li) { margin-bottom: 0.2rem; }
|
||||
.faq-resposta.ql-content :deep(a) { color: var(--primary-color); text-decoration: underline; }
|
||||
.faq-resposta.ql-content :deep(blockquote) {
|
||||
border-left: 3px solid var(--surface-border);
|
||||
margin: 0.5rem 0;
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Animação expand */
|
||||
.faq-expand-enter-active,
|
||||
.faq-expand-leave-active {
|
||||
transition: opacity 0.2s ease, max-height 0.25s ease;
|
||||
max-height: 800px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.faq-expand-enter-from,
|
||||
.faq-expand-leave-to {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
}
|
||||
|
||||
/* ── Responsivo ─────────────────────────────────────────────── */
|
||||
@media (max-width: 640px) {
|
||||
.faq-body { flex-direction: column; padding: 1rem; }
|
||||
.faq-sidebar { width: 100%; position: static; flex-direction: row; flex-wrap: wrap; gap: 0.375rem; }
|
||||
.faq-sidebar-title { display: none; }
|
||||
.faq-cat-btn { width: auto; padding: 0.3rem 0.625rem; font-size: 0.75rem; }
|
||||
}
|
||||
</style>
|
||||
425
src/views/pages/saas/SaasFeriadosPage.vue
Normal file
425
src/views/pages/saas/SaasFeriadosPage.vue
Normal file
@@ -0,0 +1,425 @@
|
||||
<!-- src/views/pages/saas/SaasFeriadosPage.vue -->
|
||||
<!-- SAAS admin: visualização centralizada de feriados municipais cadastrados pelos tenants -->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import DatePicker from 'primevue/datepicker'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
// ── Estado ───────────────────────────────────────────────────
|
||||
const loading = ref(false)
|
||||
const feriados = ref([])
|
||||
const tenants = ref([])
|
||||
const ano = ref(new Date().getFullYear())
|
||||
const search = ref('')
|
||||
|
||||
// ── Filtros ──────────────────────────────────────────────────
|
||||
const filtroEstado = ref(null)
|
||||
const filtroCidade = ref(null)
|
||||
|
||||
// ── Dialog ───────────────────────────────────────────────────
|
||||
const dlgOpen = ref(false)
|
||||
const saving = ref(false)
|
||||
const form = ref(emptyForm())
|
||||
|
||||
function emptyForm () {
|
||||
return {
|
||||
nome: '',
|
||||
data: null,
|
||||
cidade: '',
|
||||
estado: '',
|
||||
tenant_id: null,
|
||||
observacao: '',
|
||||
bloqueia_sessoes: false
|
||||
}
|
||||
}
|
||||
|
||||
const formValid = computed(() => !!form.value.nome.trim() && !!form.value.data)
|
||||
|
||||
function abrirDialog () {
|
||||
form.value = emptyForm()
|
||||
dlgOpen.value = true
|
||||
}
|
||||
|
||||
function dateToISO (d) {
|
||||
if (!d) return null
|
||||
const dt = d instanceof Date ? d : new Date(d)
|
||||
return `${dt.getFullYear()}-${String(dt.getMonth()+1).padStart(2,'0')}-${String(dt.getDate()).padStart(2,'0')}`
|
||||
}
|
||||
|
||||
async function salvar () {
|
||||
if (!formValid.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
const { data: me } = await supabase.auth.getUser()
|
||||
const payload = {
|
||||
owner_id: me?.user?.id || null,
|
||||
tenant_id: form.value.tenant_id || null,
|
||||
tipo: 'municipal',
|
||||
nome: form.value.nome.trim(),
|
||||
data: dateToISO(form.value.data),
|
||||
cidade: form.value.cidade.trim() || null,
|
||||
estado: form.value.estado.trim() || null,
|
||||
observacao: form.value.observacao.trim() || null,
|
||||
bloqueia_sessoes: form.value.bloqueia_sessoes
|
||||
}
|
||||
const { data, error } = await supabase.from('feriados').insert(payload).select('*, tenants(name)').single()
|
||||
if (error) throw error
|
||||
feriados.value = [...feriados.value, data].sort((a, b) => a.data.localeCompare(b.data))
|
||||
toast.add({ severity: 'success', summary: 'Feriado cadastrado', life: 1800 })
|
||||
dlgOpen.value = false
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3500 })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Load feriados ─────────────────────────────────────────────
|
||||
async function load () {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('feriados')
|
||||
.select('*, tenants(name)')
|
||||
.gte('data', `${ano.value}-01-01`)
|
||||
.lte('data', `${ano.value}-12-31`)
|
||||
.order('data')
|
||||
if (error) throw error
|
||||
feriados.value = data || []
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3500 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Load tenants (para o select do dialog) ────────────────────
|
||||
async function loadTenants () {
|
||||
const { data } = await supabase.from('tenants').select('id, name').order('name')
|
||||
tenants.value = data || []
|
||||
}
|
||||
|
||||
onMounted(() => { load(); loadTenants() })
|
||||
|
||||
// ── Navegação de ano ─────────────────────────────────────────
|
||||
async function anoAnterior () { ano.value--; await load() }
|
||||
async function anoProximo () { ano.value++; await load() }
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────
|
||||
function fmtDate (iso) {
|
||||
if (!iso) return '—'
|
||||
const [y, m, d] = String(iso).split('-')
|
||||
return `${d}/${m}/${y}`
|
||||
}
|
||||
|
||||
// ── Opções de filtro ─────────────────────────────────────────
|
||||
const estadoOptions = computed(() => {
|
||||
const set = new Set(feriados.value.map(f => f.estado).filter(Boolean))
|
||||
return [{ label: 'Todos os estados', value: null }, ...[...set].sort().map(e => ({ label: e, value: e }))]
|
||||
})
|
||||
|
||||
const cidadeOptions = computed(() => {
|
||||
const set = new Set(
|
||||
feriados.value
|
||||
.filter(f => !filtroEstado.value || f.estado === filtroEstado.value)
|
||||
.map(f => f.cidade)
|
||||
.filter(Boolean)
|
||||
)
|
||||
return [{ label: 'Todas as cidades', value: null }, ...[...set].sort().map(c => ({ label: c, value: c }))]
|
||||
})
|
||||
|
||||
const tenantOptions = computed(() => [
|
||||
{ label: 'Sem vínculo (global)', value: null },
|
||||
...tenants.value.map(t => ({ label: t.name, value: t.id }))
|
||||
])
|
||||
|
||||
// ── Lista filtrada ────────────────────────────────────────────
|
||||
const listaFiltrada = computed(() => {
|
||||
let list = feriados.value
|
||||
if (filtroEstado.value) list = list.filter(f => f.estado === filtroEstado.value)
|
||||
if (filtroCidade.value) list = list.filter(f => f.cidade === filtroCidade.value)
|
||||
const q = search.value.trim().toLowerCase()
|
||||
if (q) list = list.filter(f => f.nome.toLowerCase().includes(q) || (f.cidade || '').toLowerCase().includes(q))
|
||||
return list
|
||||
})
|
||||
|
||||
// ── Agrupamento por data ──────────────────────────────────────
|
||||
const agrupados = computed(() => {
|
||||
const map = new Map()
|
||||
for (const f of listaFiltrada.value) {
|
||||
if (!map.has(f.data)) map.set(f.data, [])
|
||||
map.get(f.data).push(f)
|
||||
}
|
||||
return [...map.entries()].sort(([a], [b]) => a.localeCompare(b))
|
||||
})
|
||||
|
||||
// ── Stats ─────────────────────────────────────────────────────
|
||||
const totalFeriados = computed(() => feriados.value.length)
|
||||
const totalTenants = computed(() => new Set(feriados.value.map(f => f.tenant_id).filter(Boolean)).size)
|
||||
const totalMunicipios = computed(() => new Set(feriados.value.map(f => f.cidade).filter(Boolean)).size)
|
||||
|
||||
// ── Excluir ───────────────────────────────────────────────────
|
||||
async function excluir (id) {
|
||||
try {
|
||||
const { error } = await supabase.from('feriados').delete().eq('id', id)
|
||||
if (error) throw error
|
||||
feriados.value = feriados.value.filter(f => f.id !== id)
|
||||
toast.add({ severity: 'success', summary: 'Removido', life: 1500 })
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3000 })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Toast />
|
||||
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
|
||||
<!-- ── Header ─────────────────────────────────────────── -->
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] px-5 py-4">
|
||||
<div>
|
||||
<div class="font-bold text-lg flex items-center gap-2">
|
||||
<i class="pi pi-star text-amber-500" />
|
||||
Feriados Municipais
|
||||
</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">
|
||||
Feriados cadastrados pelos tenants — alimentam o banco central de feriados do SAAS.
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button icon="pi pi-chevron-left" text rounded severity="secondary" @click="anoAnterior" />
|
||||
<span class="font-bold text-lg w-14 text-center">{{ ano }}</span>
|
||||
<Button icon="pi pi-chevron-right" text rounded severity="secondary" @click="anoProximo" />
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined rounded :loading="loading" @click="load" />
|
||||
<Button icon="pi pi-plus" label="Cadastrar feriado" class="rounded-full" @click="abrirDialog" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Stats ──────────────────────────────────────────── -->
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 text-center">
|
||||
<div class="text-2xl font-bold text-amber-500">{{ totalFeriados }}</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Total de feriados</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 text-center">
|
||||
<div class="text-2xl font-bold text-blue-500">{{ totalTenants }}</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Tenants contribuintes</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 text-center">
|
||||
<div class="text-2xl font-bold text-green-500">{{ totalMunicipios }}</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-1">Municípios</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Filtros ─────────────────────────────────────────── -->
|
||||
<div class="flex flex-wrap gap-3 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] px-4 py-3">
|
||||
<div class="flex-1 min-w-[160px]">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText v-model="search" class="w-full" placeholder="Buscar feriado ou cidade…" />
|
||||
</IconField>
|
||||
</div>
|
||||
<Select
|
||||
v-model="filtroEstado"
|
||||
:options="estadoOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
class="min-w-[160px]"
|
||||
@change="filtroCidade = null"
|
||||
/>
|
||||
<Select
|
||||
v-model="filtroCidade"
|
||||
:options="cidadeOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
class="min-w-[180px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ── Loading ─────────────────────────────────────────── -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-16">
|
||||
<i class="pi pi-spinner pi-spin text-2xl opacity-40" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
|
||||
<div v-if="!agrupados.length" class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-8 text-center text-[var(--text-color-secondary)]">
|
||||
Nenhum feriado municipal cadastrado para {{ ano }}.
|
||||
</div>
|
||||
|
||||
<!-- ── Lista agrupada por data ───────────────────────── -->
|
||||
<div v-for="[data, lista] in agrupados" :key="data" class="blk-group">
|
||||
<div class="blk-group__head">
|
||||
<span class="font-mono text-sm">{{ fmtDate(data) }}</span>
|
||||
<span class="blk-group__count">{{ lista.length }}</span>
|
||||
</div>
|
||||
|
||||
<div class="blk-list">
|
||||
<div v-for="f in lista" :key="f.id" class="blk-item">
|
||||
<div class="blk-item__name">{{ f.nome }}</div>
|
||||
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<Tag v-if="f.cidade" :value="f.cidade" severity="secondary" class="text-xs" />
|
||||
<Tag v-if="f.estado" :value="f.estado" severity="info" class="text-xs" />
|
||||
<Tag v-if="f.bloqueia_sessoes" value="Bloqueia" severity="danger" class="text-xs" />
|
||||
</div>
|
||||
|
||||
<div v-if="f.tenants?.name" class="blk-item__tenant">
|
||||
<i class="pi pi-building text-xs" /> {{ f.tenants.name }}
|
||||
</div>
|
||||
|
||||
<div v-if="f.observacao" class="blk-item__obs">{{ f.observacao }}</div>
|
||||
|
||||
<div class="blk-item__actions">
|
||||
<Button icon="pi pi-trash" text rounded size="small" severity="danger" @click="excluir(f.id)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- ══ Dialog cadastro ════════════════════════════════════ -->
|
||||
<Dialog
|
||||
v-model:visible="dlgOpen"
|
||||
modal
|
||||
:draggable="false"
|
||||
header="Cadastrar feriado"
|
||||
:style="{ width: '460px' }"
|
||||
>
|
||||
<div class="flex flex-col gap-4 pt-1">
|
||||
|
||||
<div>
|
||||
<label class="dlg-label">Nome do feriado *</label>
|
||||
<InputText v-model="form.nome" class="w-full mt-1" placeholder="Ex.: Padroeiro Municipal, Aniversário da cidade…" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="dlg-label">Data *</label>
|
||||
<DatePicker
|
||||
v-model="form.data"
|
||||
showIcon fluid iconDisplay="input"
|
||||
dateFormat="dd/mm/yy"
|
||||
:manualInput="false"
|
||||
class="mt-1"
|
||||
>
|
||||
<template #inputicon="sp"><i class="pi pi-calendar" @click="sp.clickCallback" /></template>
|
||||
</DatePicker>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<div class="flex-1">
|
||||
<label class="dlg-label">Cidade</label>
|
||||
<InputText v-model="form.cidade" class="w-full mt-1" placeholder="Ex.: São Paulo" />
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<label class="dlg-label">Estado (UF)</label>
|
||||
<InputText v-model="form.estado" class="w-full mt-1" placeholder="SP" maxlength="2" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="dlg-label">Vincular a um tenant <span class="opacity-60">(opcional)</span></label>
|
||||
<Select
|
||||
v-model="form.tenant_id"
|
||||
:options="tenantOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
class="w-full mt-1"
|
||||
placeholder="Sem vínculo (global)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="dlg-label">Observação <span class="opacity-60">(opcional)</span></label>
|
||||
<Textarea v-model="form.observacao" class="w-full mt-1" rows="2" autoResize placeholder="Nota interna…" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox v-model="form.bloqueia_sessoes" :binary="true" inputId="bloqueia" />
|
||||
<label for="bloqueia" class="text-sm cursor-pointer">Bloqueia sessões neste dia</label>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Cancelar" severity="secondary" outlined @click="dlgOpen = false" />
|
||||
<Button
|
||||
label="Cadastrar"
|
||||
icon="pi pi-check"
|
||||
:disabled="!formValid"
|
||||
:loading="saving"
|
||||
@click="salvar"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.blk-group {
|
||||
border-radius: 1.25rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
.blk-group__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
font-weight: 600;
|
||||
background: var(--surface-ground);
|
||||
}
|
||||
.blk-group__count {
|
||||
font-size: 0.75rem;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 999px;
|
||||
padding: 1px 8px;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
.blk-list { display: flex; flex-direction: column; }
|
||||
.blk-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem 1.25rem;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.blk-item:last-child { border-bottom: none; }
|
||||
.blk-item:hover { background: var(--surface-hover); }
|
||||
.blk-item__name {
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
}
|
||||
.blk-item__tenant {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-color-secondary);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.blk-item__obs {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-color-secondary);
|
||||
width: 100%;
|
||||
font-style: italic;
|
||||
}
|
||||
.blk-item__actions { margin-left: auto; }
|
||||
|
||||
.dlg-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-color-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
364
src/views/pages/saas/SaasSupportPage.vue
Normal file
364
src/views/pages/saas/SaasSupportPage.vue
Normal file
@@ -0,0 +1,364 @@
|
||||
<template>
|
||||
<div class="saas-support p-4 md:p-6">
|
||||
<Toast />
|
||||
|
||||
<!-- Cabeçalho -->
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-xl bg-orange-100 dark:bg-orange-900/30">
|
||||
<i class="pi pi-headphones text-orange-600 dark:text-orange-400 text-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-xl font-bold text-surface-900 dark:text-surface-0 m-0">Suporte Técnico</h1>
|
||||
<p class="text-sm text-surface-500 m-0">Gere links seguros para acessar a agenda de um cliente em modo debug</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card: Gerar nova sessão -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="card">
|
||||
<h2 class="text-base font-semibold mb-4 flex items-center gap-2">
|
||||
<i class="pi pi-plus-circle text-primary" />
|
||||
Nova Sessão de Suporte
|
||||
</h2>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Seleção de tenant -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium text-surface-700 dark:text-surface-300">Selecionar Cliente (Tenant)</label>
|
||||
<Select
|
||||
v-model="selectedTenantId"
|
||||
:options="tenants"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
placeholder="Buscar tenant..."
|
||||
filter
|
||||
:loading="loadingTenants"
|
||||
class="w-full"
|
||||
empty-filter-message="Nenhum tenant encontrado"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- TTL -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium text-surface-700 dark:text-surface-300">Duração do Acesso</label>
|
||||
<Select
|
||||
v-model="ttlMinutes"
|
||||
:options="ttlOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Botão -->
|
||||
<Button
|
||||
label="Ativar Modo Suporte"
|
||||
icon="pi pi-shield"
|
||||
severity="warning"
|
||||
:loading="creating"
|
||||
:disabled="!selectedTenantId"
|
||||
class="w-full"
|
||||
@click="handleCreate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card: URL Gerada -->
|
||||
<div class="card">
|
||||
<h2 class="text-base font-semibold mb-4 flex items-center gap-2">
|
||||
<i class="pi pi-link text-primary" />
|
||||
URL de Suporte Gerada
|
||||
</h2>
|
||||
|
||||
<div v-if="generatedUrl" class="flex flex-col gap-3">
|
||||
<!-- URL -->
|
||||
<div class="flex flex-col gap-1">
|
||||
<label class="text-sm font-medium text-surface-700 dark:text-surface-300">Link de Acesso</label>
|
||||
<div class="flex gap-2">
|
||||
<InputText
|
||||
:value="generatedUrl"
|
||||
readonly
|
||||
class="flex-1 font-mono text-xs"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-copy"
|
||||
severity="secondary"
|
||||
outlined
|
||||
v-tooltip.top="'Copiar URL'"
|
||||
@click="copyUrl"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expira em -->
|
||||
<div class="flex items-center gap-2 text-sm text-surface-500">
|
||||
<i class="pi pi-clock text-orange-500" />
|
||||
<span>Expira em: <strong class="text-surface-700 dark:text-surface-300">{{ expiresLabel }}</strong></span>
|
||||
</div>
|
||||
|
||||
<!-- Token (reduzido) -->
|
||||
<div class="flex items-center gap-2 text-xs text-surface-400 font-mono">
|
||||
<i class="pi pi-key" />
|
||||
<span>{{ tokenPreview }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Instruções -->
|
||||
<Message severity="info" :closable="false" class="text-sm">
|
||||
Envie este link ao terapeuta ou acesse diretamente para ver os logs da agenda.
|
||||
O link expira automaticamente.
|
||||
</Message>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col items-center justify-center py-10 text-surface-400 gap-2">
|
||||
<i class="pi pi-shield text-4xl opacity-30" />
|
||||
<span class="text-sm">Nenhuma sessão gerada ainda</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sessões ativas -->
|
||||
<div class="card mt-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-base font-semibold flex items-center gap-2 m-0">
|
||||
<i class="pi pi-list text-primary" />
|
||||
Sessões Ativas
|
||||
</h2>
|
||||
<Button
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
outlined
|
||||
size="small"
|
||||
:loading="loadingSessions"
|
||||
@click="loadActiveSessions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:value="activeSessions"
|
||||
:loading="loadingSessions"
|
||||
empty-message="Nenhuma sessão ativa no momento"
|
||||
size="small"
|
||||
striped-rows
|
||||
>
|
||||
<Column field="tenant_id" header="Tenant ID">
|
||||
<template #body="{ data }">
|
||||
<span class="font-mono text-xs">{{ data.tenant_id }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Token">
|
||||
<template #body="{ data }">
|
||||
<span class="font-mono text-xs">{{ data.token.slice(0, 16) }}…</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Expira em">
|
||||
<template #body="{ data }">
|
||||
<span :class="isExpiringSoon(data.expires_at) ? 'text-orange-500 font-semibold' : ''">
|
||||
{{ formatExpires(data.expires_at) }}
|
||||
</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Criada">
|
||||
<template #body="{ data }">
|
||||
{{ formatDate(data.created_at) }}
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Ações">
|
||||
<template #body="{ data }">
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
icon="pi pi-copy"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
outlined
|
||||
v-tooltip.top="'Copiar URL'"
|
||||
@click="copySessionUrl(data.token)"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
size="small"
|
||||
severity="danger"
|
||||
outlined
|
||||
v-tooltip.top="'Revogar'"
|
||||
:loading="revokingToken === data.token"
|
||||
@click="handleRevoke(data.token)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import {
|
||||
createSupportSession,
|
||||
listActiveSupportSessions,
|
||||
revokeSupportSession,
|
||||
buildSupportUrl,
|
||||
} from '@/support/supportSessionService'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
// ── Estado ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const selectedTenantId = ref(null)
|
||||
const ttlMinutes = ref(60)
|
||||
const creating = ref(false)
|
||||
const loadingTenants = ref(false)
|
||||
const loadingSessions = ref(false)
|
||||
const revokingToken = ref(null)
|
||||
|
||||
const tenants = ref([])
|
||||
const activeSessions = ref([])
|
||||
|
||||
const generatedUrl = ref(null)
|
||||
const generatedData = ref(null) // { token, expires_at }
|
||||
|
||||
// ── Opções de TTL ──────────────────────────────────────────────────────────
|
||||
|
||||
const ttlOptions = [
|
||||
{ label: '30 minutos', value: 30 },
|
||||
{ label: '60 minutos', value: 60 },
|
||||
{ label: '2 horas', value: 120 },
|
||||
]
|
||||
|
||||
// ── Computed ───────────────────────────────────────────────────────────────
|
||||
|
||||
const expiresLabel = computed(() => {
|
||||
if (!generatedData.value?.expires_at) return ''
|
||||
return new Date(generatedData.value.expires_at).toLocaleString('pt-BR')
|
||||
})
|
||||
|
||||
const tokenPreview = computed(() => {
|
||||
if (!generatedData.value?.token) return ''
|
||||
const t = generatedData.value.token
|
||||
return `${t.slice(0, 8)}…${t.slice(-8)}`
|
||||
})
|
||||
|
||||
// ── Lifecycle ──────────────────────────────────────────────────────────────
|
||||
|
||||
onMounted(() => {
|
||||
loadTenants()
|
||||
loadActiveSessions()
|
||||
})
|
||||
|
||||
// ── Métodos ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadTenants () {
|
||||
loadingTenants.value = true
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('tenants')
|
||||
.select('id, name, kind')
|
||||
.order('name', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
|
||||
tenants.value = (data || []).map(t => ({
|
||||
value: t.id,
|
||||
label: `${t.name} (${t.kind ?? 'tenant'})`,
|
||||
}))
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 })
|
||||
} finally {
|
||||
loadingTenants.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadActiveSessions () {
|
||||
loadingSessions.value = true
|
||||
try {
|
||||
activeSessions.value = await listActiveSupportSessions()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 })
|
||||
} finally {
|
||||
loadingSessions.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate () {
|
||||
if (!selectedTenantId.value) return
|
||||
creating.value = true
|
||||
generatedUrl.value = null
|
||||
generatedData.value = null
|
||||
|
||||
try {
|
||||
const result = await createSupportSession(selectedTenantId.value, ttlMinutes.value)
|
||||
generatedData.value = result
|
||||
generatedUrl.value = buildSupportUrl(result.token)
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Sessão criada',
|
||||
detail: 'URL de suporte gerada com sucesso.',
|
||||
life: 4000,
|
||||
})
|
||||
|
||||
await loadActiveSessions()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao criar sessão', detail: e?.message, life: 5000 })
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevoke (token) {
|
||||
revokingToken.value = token
|
||||
try {
|
||||
await revokeSupportSession(token)
|
||||
toast.add({ severity: 'success', summary: 'Sessão revogada', life: 3000 })
|
||||
if (generatedData.value?.token === token) {
|
||||
generatedUrl.value = null
|
||||
generatedData.value = null
|
||||
}
|
||||
await loadActiveSessions()
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao revogar', detail: e?.message, life: 4000 })
|
||||
} finally {
|
||||
revokingToken.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function copyUrl () {
|
||||
if (!generatedUrl.value) return
|
||||
navigator.clipboard.writeText(generatedUrl.value)
|
||||
toast.add({ severity: 'info', summary: 'Copiado!', detail: 'URL copiada para a área de transferência.', life: 2000 })
|
||||
}
|
||||
|
||||
function copySessionUrl (token) {
|
||||
const url = buildSupportUrl(token)
|
||||
navigator.clipboard.writeText(url)
|
||||
toast.add({ severity: 'info', summary: 'Copiado!', life: 2000 })
|
||||
}
|
||||
|
||||
// ── Formatação ─────────────────────────────────────────────────────────────
|
||||
|
||||
function formatDate (iso) {
|
||||
if (!iso) return '-'
|
||||
return new Date(iso).toLocaleString('pt-BR')
|
||||
}
|
||||
|
||||
function formatExpires (iso) {
|
||||
if (!iso) return '-'
|
||||
const d = new Date(iso)
|
||||
const now = new Date()
|
||||
const diffMin = Math.round((d - now) / 60000)
|
||||
if (diffMin < 0) return 'Expirada'
|
||||
if (diffMin < 60) return `em ${diffMin} min`
|
||||
return new Date(iso).toLocaleString('pt-BR')
|
||||
}
|
||||
|
||||
function isExpiringSoon (iso) {
|
||||
if (!iso) return false
|
||||
const diffMin = (new Date(iso) - new Date()) / 60000
|
||||
return diffMin > 0 && diffMin < 15
|
||||
}
|
||||
</script>
|
||||
360
src/views/pages/therapist/RelatoriosPage.vue
Normal file
360
src/views/pages/therapist/RelatoriosPage.vue
Normal file
@@ -0,0 +1,360 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
import { useLayout } from '@/layout/composables/layout'
|
||||
|
||||
const { layoutConfig, isDarkTheme } = useLayout()
|
||||
const tenantStore = useTenantStore()
|
||||
|
||||
// ─── período ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const PERIODS = [
|
||||
{ label: 'Esta semana', value: 'week' },
|
||||
{ label: 'Este mês', value: 'month' },
|
||||
{ label: 'Últimos 3 meses', value: '3months' },
|
||||
{ label: 'Últimos 6 meses', value: '6months' },
|
||||
]
|
||||
|
||||
const selectedPeriod = ref('month')
|
||||
|
||||
function periodRange (period) {
|
||||
const now = new Date()
|
||||
let start, end
|
||||
|
||||
if (period === 'week') {
|
||||
const dow = now.getDay() // 0=Dom
|
||||
start = new Date(now)
|
||||
start.setDate(now.getDate() - dow)
|
||||
start.setHours(0, 0, 0, 0)
|
||||
end = new Date(now)
|
||||
end.setHours(23, 59, 59, 999)
|
||||
} else if (period === 'month') {
|
||||
start = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0)
|
||||
end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999)
|
||||
} else if (period === '3months') {
|
||||
start = new Date(now.getFullYear(), now.getMonth() - 2, 1, 0, 0, 0, 0)
|
||||
end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999)
|
||||
} else if (period === '6months') {
|
||||
start = new Date(now.getFullYear(), now.getMonth() - 5, 1, 0, 0, 0, 0)
|
||||
end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999)
|
||||
}
|
||||
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
// ─── dados ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const loading = ref(false)
|
||||
const sessions = ref([])
|
||||
const loadError = ref('')
|
||||
|
||||
async function loadSessions () {
|
||||
const uid = tenantStore.user?.id || null
|
||||
const tenantId = tenantStore.activeTenantId || null
|
||||
if (!uid || !tenantId) return
|
||||
|
||||
const { start, end } = periodRange(selectedPeriod.value)
|
||||
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
sessions.value = []
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('id, inicio_em, fim_em, status, modalidade, tipo, titulo, titulo_custom, patient_id, patients(nome_completo)')
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('owner_id', uid)
|
||||
.gte('inicio_em', start.toISOString())
|
||||
.lte('inicio_em', end.toISOString())
|
||||
.order('inicio_em', { ascending: false })
|
||||
.limit(500)
|
||||
|
||||
if (error) throw error
|
||||
sessions.value = data || []
|
||||
} catch (e) {
|
||||
loadError.value = e?.message || 'Falha ao carregar relatório.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── métricas ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const total = computed(() => sessions.value.length)
|
||||
const realizadas = computed(() => sessions.value.filter(s => s.status === 'realizado').length)
|
||||
const faltas = computed(() => sessions.value.filter(s => s.status === 'faltou').length)
|
||||
const canceladas = computed(() => sessions.value.filter(s => s.status === 'cancelado').length)
|
||||
const agendadas = computed(() => sessions.value.filter(s => !s.status || s.status === 'agendado').length)
|
||||
const remarcadas = computed(() => sessions.value.filter(s => s.status === 'remarcado').length)
|
||||
|
||||
// ─── gráfico (sessions por semana/mês) ───────────────────────────────────────
|
||||
|
||||
function isoWeek (d) {
|
||||
const dt = new Date(d)
|
||||
const day = dt.getDay() || 7
|
||||
dt.setDate(dt.getDate() + 4 - day)
|
||||
const yearStart = new Date(dt.getFullYear(), 0, 1)
|
||||
const wk = Math.ceil((((dt - yearStart) / 86400000) + 1) / 7)
|
||||
return `${dt.getFullYear()}-S${String(wk).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function isoMonth (d) {
|
||||
const dt = new Date(d)
|
||||
const yy = dt.getFullYear()
|
||||
const mm = String(dt.getMonth() + 1).padStart(2, '0')
|
||||
return `${yy}-${mm}`
|
||||
}
|
||||
|
||||
function monthLabel (key) {
|
||||
const [y, m] = key.split('-')
|
||||
const names = ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun', 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez']
|
||||
return `${names[Number(m) - 1]}/${y}`
|
||||
}
|
||||
|
||||
const chartData = computed(() => {
|
||||
const groupBy = selectedPeriod.value === 'week' ? isoWeek : isoMonth
|
||||
const labelFn = selectedPeriod.value === 'week'
|
||||
? k => k
|
||||
: monthLabel
|
||||
|
||||
const buckets = {}
|
||||
for (const s of sessions.value) {
|
||||
const key = groupBy(s.inicio_em)
|
||||
if (!buckets[key]) buckets[key] = { realizado: 0, faltou: 0, cancelado: 0, outros: 0 }
|
||||
const st = s.status || 'agendado'
|
||||
if (st === 'realizado') buckets[key].realizado++
|
||||
else if (st === 'faltou') buckets[key].faltou++
|
||||
else if (st === 'cancelado') buckets[key].cancelado++
|
||||
else buckets[key].outros++
|
||||
}
|
||||
|
||||
const keys = Object.keys(buckets).sort()
|
||||
const labels = keys.map(labelFn)
|
||||
const ds = getComputedStyle(document.documentElement)
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Realizadas',
|
||||
backgroundColor: '#22c55e',
|
||||
data: keys.map(k => buckets[k].realizado),
|
||||
barThickness: 20,
|
||||
},
|
||||
{
|
||||
label: 'Faltas',
|
||||
backgroundColor: '#ef4444',
|
||||
data: keys.map(k => buckets[k].faltou),
|
||||
barThickness: 20,
|
||||
},
|
||||
{
|
||||
label: 'Canceladas',
|
||||
backgroundColor: '#f97316',
|
||||
data: keys.map(k => buckets[k].cancelado),
|
||||
barThickness: 20,
|
||||
},
|
||||
{
|
||||
label: 'Outros',
|
||||
backgroundColor: ds.getPropertyValue('--p-primary-300') || '#93c5fd',
|
||||
data: keys.map(k => buckets[k].outros),
|
||||
barThickness: 20,
|
||||
},
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const chartOptions = computed(() => {
|
||||
const ds = getComputedStyle(document.documentElement)
|
||||
const borderColor = ds.getPropertyValue('--surface-border') || '#e2e8f0'
|
||||
const textMutedColor = ds.getPropertyValue('--text-color-secondary') || '#64748b'
|
||||
return {
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { labels: { color: textMutedColor } }
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
ticks: { color: textMutedColor },
|
||||
grid: { color: 'transparent' }
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
ticks: { color: textMutedColor, precision: 0 },
|
||||
grid: { color: borderColor, drawTicks: false }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ─── tabela ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const STATUS_LABEL = {
|
||||
agendado: 'Agendado',
|
||||
realizado: 'Realizado',
|
||||
faltou: 'Faltou',
|
||||
cancelado: 'Cancelado',
|
||||
remarcado: 'Remarcado',
|
||||
bloqueado: 'Bloqueado',
|
||||
}
|
||||
|
||||
const STATUS_SEVERITY = {
|
||||
agendado: 'info',
|
||||
realizado: 'success',
|
||||
faltou: 'danger',
|
||||
cancelado: 'warn',
|
||||
remarcado: 'secondary',
|
||||
bloqueado: 'secondary',
|
||||
}
|
||||
|
||||
function fmtDateTimeBR (iso) {
|
||||
if (!iso) return '—'
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
const dd = String(d.getDate()).padStart(2, '0')
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const yy = d.getFullYear()
|
||||
const hh = String(d.getHours()).padStart(2, '0')
|
||||
const mi = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${dd}/${mm}/${yy} ${hh}:${mi}`
|
||||
}
|
||||
|
||||
function sessionTitle (s) {
|
||||
return s.titulo_custom || s.titulo || (s.tipo ? s.tipo : 'Sessão')
|
||||
}
|
||||
|
||||
function patientName (s) {
|
||||
return s.patients?.nome_completo || '—'
|
||||
}
|
||||
|
||||
// taxa de realização
|
||||
const taxaRealizacao = computed(() => {
|
||||
const denom = realizadas.value + faltas.value + canceladas.value
|
||||
if (!denom) return null
|
||||
return Math.round((realizadas.value / denom) * 100)
|
||||
})
|
||||
|
||||
// ─── watch & mount ────────────────────────────────────────────────────────────
|
||||
|
||||
watch(selectedPeriod, loadSessions)
|
||||
watch([() => layoutConfig.primary, () => layoutConfig.surface, isDarkTheme], () => {})
|
||||
|
||||
onMounted(loadSessions)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 p-4">
|
||||
<!-- Cabeçalho -->
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-800">Relatórios</h1>
|
||||
<p class="text-sm text-slate-500 mt-1">Visão geral das suas sessões</p>
|
||||
</div>
|
||||
|
||||
<SelectButton
|
||||
v-model="selectedPeriod"
|
||||
:options="PERIODS"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
:allow-empty="false"
|
||||
class="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Erro -->
|
||||
<Message v-if="loadError" severity="error">{{ loadError }}</Message>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex items-center gap-2 text-slate-500">
|
||||
<i class="pi pi-spin pi-spinner" /> Carregando…
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Cards de resumo -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-3">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 flex flex-col gap-1">
|
||||
<span class="text-xs text-slate-500 uppercase tracking-wide">Total</span>
|
||||
<span class="text-3xl font-bold text-slate-800">{{ total }}</span>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-green-100 bg-green-50 p-4 flex flex-col gap-1">
|
||||
<span class="text-xs text-green-700 uppercase tracking-wide">Realizadas</span>
|
||||
<span class="text-3xl font-bold text-green-700">{{ realizadas }}</span>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-red-100 bg-red-50 p-4 flex flex-col gap-1">
|
||||
<span class="text-xs text-red-600 uppercase tracking-wide">Faltas</span>
|
||||
<span class="text-3xl font-bold text-red-600">{{ faltas }}</span>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-orange-100 bg-orange-50 p-4 flex flex-col gap-1">
|
||||
<span class="text-xs text-orange-600 uppercase tracking-wide">Canceladas</span>
|
||||
<span class="text-3xl font-bold text-orange-600">{{ canceladas }}</span>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-blue-100 bg-blue-50 p-4 flex flex-col gap-1">
|
||||
<span class="text-xs text-blue-600 uppercase tracking-wide">Agendadas</span>
|
||||
<span class="text-3xl font-bold text-blue-600">{{ agendadas }}</span>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4 flex flex-col gap-1">
|
||||
<span class="text-xs text-slate-500 uppercase tracking-wide">Taxa realização</span>
|
||||
<span class="text-3xl font-bold text-slate-800">
|
||||
{{ taxaRealizacao != null ? `${taxaRealizacao}%` : '—' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gráfico -->
|
||||
<div v-if="total > 0" class="rounded-2xl border border-slate-200 bg-white p-4">
|
||||
<h2 class="text-base font-semibold text-slate-700 mb-4">
|
||||
Sessões por {{ selectedPeriod === 'week' ? 'semana' : 'mês' }}
|
||||
</h2>
|
||||
<Chart type="bar" :data="chartData" :options="chartOptions" class="h-64" />
|
||||
</div>
|
||||
|
||||
<!-- Tabela -->
|
||||
<div class="rounded-2xl border border-slate-200 bg-white overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-slate-100 flex items-center justify-between">
|
||||
<h2 class="text-base font-semibold text-slate-700">Sessões no período</h2>
|
||||
<span class="text-sm text-slate-500">{{ total }} registro{{ total !== 1 ? 's' : '' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!sessions.length" class="px-4 py-8 text-center text-slate-500 text-sm">
|
||||
Nenhuma sessão encontrada para o período selecionado.
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
v-else
|
||||
:value="sessions"
|
||||
:rows="20"
|
||||
paginator
|
||||
:rows-per-page-options="[10, 20, 50]"
|
||||
scrollable
|
||||
scroll-height="480px"
|
||||
class="text-sm"
|
||||
>
|
||||
<Column field="inicio_em" header="Data / Hora" :sortable="true" style="min-width: 140px">
|
||||
<template #body="{ data }">{{ fmtDateTimeBR(data.inicio_em) }}</template>
|
||||
</Column>
|
||||
<Column header="Paciente" style="min-width: 160px">
|
||||
<template #body="{ data }">{{ patientName(data) }}</template>
|
||||
</Column>
|
||||
<Column header="Sessão" style="min-width: 160px">
|
||||
<template #body="{ data }">{{ sessionTitle(data) }}</template>
|
||||
</Column>
|
||||
<Column field="modalidade" header="Modalidade" style="min-width: 110px">
|
||||
<template #body="{ data }">
|
||||
{{ data.modalidade === 'online' ? 'Online' : data.modalidade === 'presencial' ? 'Presencial' : data.modalidade || '—' }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="status" header="Status" style="min-width: 110px">
|
||||
<template #body="{ data }">
|
||||
<Tag
|
||||
:value="STATUS_LABEL[data.status] || data.status || 'Agendado'"
|
||||
:severity="STATUS_SEVERITY[data.status] || 'info'"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user