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

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