Layout 100%, Notificações, SetupWizard
This commit is contained in:
@@ -7,11 +7,10 @@ import { useLayout } from '@/layout/composables/layout'
|
||||
const { layoutConfig, isDarkTheme } = useLayout()
|
||||
const tenantStore = useTenantStore()
|
||||
|
||||
// ─── período ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// ── Período ───────────────────────────────────────────────
|
||||
const PERIODS = [
|
||||
{ label: 'Esta semana', value: 'week' },
|
||||
{ label: 'Este mês', value: 'month' },
|
||||
{ label: 'Esta semana', value: 'week' },
|
||||
{ label: 'Este mês', value: 'month' },
|
||||
{ label: 'Últimos 3 meses', value: '3months' },
|
||||
{ label: 'Últimos 6 meses', value: '6months' },
|
||||
]
|
||||
@@ -21,44 +20,36 @@ 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)
|
||||
start = new Date(now); start.setDate(now.getDate() - now.getDay()); 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)
|
||||
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)
|
||||
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)
|
||||
end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999)
|
||||
}
|
||||
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
// ─── dados ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const loading = ref(false)
|
||||
const sessions = ref([])
|
||||
// ── Dados ─────────────────────────────────────────────────
|
||||
const loading = ref(false)
|
||||
const sessions = ref([])
|
||||
const loadError = ref('')
|
||||
|
||||
async function loadSessions () {
|
||||
const uid = tenantStore.user?.id || null
|
||||
const uid = tenantStore.user?.id || null
|
||||
const tenantId = tenantStore.activeTenantId || null
|
||||
if (!uid || !tenantId) return
|
||||
|
||||
const { start, end } = periodRange(selectedPeriod.value)
|
||||
|
||||
loading.value = true
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
sessions.value = []
|
||||
sessions.value = []
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
@@ -70,7 +61,6 @@ async function loadSessions () {
|
||||
.lte('inicio_em', end.toISOString())
|
||||
.order('inicio_em', { ascending: false })
|
||||
.limit(500)
|
||||
|
||||
if (error) throw error
|
||||
sessions.value = data || []
|
||||
} catch (e) {
|
||||
@@ -80,133 +70,108 @@ async function loadSessions () {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── métricas ─────────────────────────────────────────────────────────────────
|
||||
// ── 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)
|
||||
const taxaRealizacao = computed(() => {
|
||||
const denom = realizadas.value + faltas.value + canceladas.value
|
||||
if (!denom) return null
|
||||
return Math.round((realizadas.value / denom) * 100)
|
||||
})
|
||||
|
||||
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)
|
||||
// ── Filtro de status na tabela ────────────────────────────
|
||||
const filtroTabela = ref(null) // null = todos
|
||||
|
||||
// ─── gráfico (sessions por semana/mês) ───────────────────────────────────────
|
||||
const sessionsFiltradas = computed(() => {
|
||||
if (!filtroTabela.value) return sessions.value
|
||||
if (filtroTabela.value === 'agendado') return sessions.value.filter(s => !s.status || s.status === 'agendado')
|
||||
return sessions.value.filter(s => s.status === filtroTabela.value)
|
||||
})
|
||||
|
||||
function toggleFiltroTabela (val) {
|
||||
filtroTabela.value = filtroTabela.value === val ? null : val
|
||||
}
|
||||
|
||||
// ── Quick-stats config ────────────────────────────────────
|
||||
const quickStats = computed(() => [
|
||||
{ label: 'Total', value: total.value, filter: null, cls: '', valCls: 'text-[var(--text-color)]' },
|
||||
{ label: 'Realizadas', value: realizadas.value, filter: 'realizado', cls: 'qs-ok', valCls: 'text-green-500' },
|
||||
{ label: 'Faltas', value: faltas.value, filter: 'faltou', cls: 'qs-danger', valCls: 'text-red-500' },
|
||||
{ label: 'Canceladas', value: canceladas.value, filter: 'cancelado', cls: 'qs-warn', valCls: 'text-orange-500' },
|
||||
{ label: 'Agendadas', value: agendadas.value, filter: 'agendado', cls: 'qs-info', valCls: 'text-sky-500' },
|
||||
{ label: 'Taxa realização', value: taxaRealizacao.value != null ? `${taxaRealizacao.value}%` : '—', filter: null, cls: taxaRealizacao.value != null && taxaRealizacao.value >= 85 ? 'qs-ok' : '', valCls: taxaRealizacao.value != null && taxaRealizacao.value >= 85 ? 'text-green-500' : 'text-[var(--text-color)]' },
|
||||
])
|
||||
|
||||
// ── Gráfico ───────────────────────────────────────────────
|
||||
function isoWeek (d) {
|
||||
const dt = new Date(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}`
|
||||
return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function monthLabel (key) {
|
||||
const [y, m] = key.split('-')
|
||||
const names = ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun', 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez']
|
||||
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 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++
|
||||
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,
|
||||
labels: keys.map(labelFn),
|
||||
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,
|
||||
},
|
||||
{ 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: '#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 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 } }
|
||||
},
|
||||
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 }
|
||||
}
|
||||
x: { stacked: true, ticks: { color: textMutedColor }, grid: { color: 'transparent' } },
|
||||
y: { stacked: true, ticks: { color: textMutedColor, precision: 0 }, grid: { color: borderColor, drawTicks: false } }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ─── tabela ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// ── Tabela helpers ────────────────────────────────────────
|
||||
const STATUS_LABEL = {
|
||||
agendado: 'Agendado',
|
||||
realizado: 'Realizado',
|
||||
faltou: 'Faltou',
|
||||
cancelado: 'Cancelado',
|
||||
remarcado: 'Remarcado',
|
||||
bloqueado: 'Bloqueado',
|
||||
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',
|
||||
agendado: 'info', realizado: 'success', faltou: 'danger',
|
||||
cancelado: 'warn', remarcado: 'secondary', bloqueado: 'secondary',
|
||||
}
|
||||
|
||||
function fmtDateTimeBR (iso) {
|
||||
@@ -220,131 +185,252 @@ function fmtDateTimeBR (iso) {
|
||||
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 || '—' }
|
||||
|
||||
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 & mount ─────────────────────────────────────────
|
||||
watch(selectedPeriod, () => { filtroTabela.value = null; 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>
|
||||
<!-- Sentinel -->
|
||||
<div class="h-px" />
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
HERO sticky
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<section
|
||||
class="sticky mx-3 md:mx-4 mb-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5 transition-[border-radius] duration-200"
|
||||
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
|
||||
>
|
||||
<!-- Blobs -->
|
||||
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
|
||||
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-indigo-500/10" />
|
||||
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-emerald-400/[0.08]" />
|
||||
</div>
|
||||
|
||||
<div class="relative z-[1] flex items-center gap-3 flex-wrap">
|
||||
|
||||
<!-- Brand -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-chart-bar text-base" />
|
||||
</div>
|
||||
<div class="min-w-0 hidden lg:block">
|
||||
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Relatórios</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)]">Visão geral das suas sessões</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SelectButton
|
||||
v-model="selectedPeriod"
|
||||
:options="PERIODS"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
:allow-empty="false"
|
||||
class="shrink-0"
|
||||
/>
|
||||
<!-- Seletor de período -->
|
||||
<div class="flex-1 min-w-0 hidden xl:flex items-center mx-2">
|
||||
<SelectButton
|
||||
v-model="selectedPeriod"
|
||||
:options="PERIODS"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
:allow-empty="false"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Refresh -->
|
||||
<div class="flex items-center gap-1.5 flex-shrink-0 ml-auto">
|
||||
<Button
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="h-9 w-9 rounded-full"
|
||||
:loading="loading"
|
||||
title="Recarregar"
|
||||
@click="loadSessions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Seletor de período — mobile (abaixo da linha principal) -->
|
||||
<div class="xl:hidden relative z-[1] mt-2.5 flex flex-wrap gap-1.5">
|
||||
<button
|
||||
v-for="p in PERIODS"
|
||||
:key="p.value"
|
||||
class="inline-flex items-center px-3 py-1 rounded-full text-[1rem] font-semibold border-[1.5px] cursor-pointer transition-all duration-150 select-none"
|
||||
:class="selectedPeriod === p.value
|
||||
? 'bg-[var(--primary-color,#6366f1)] border-[var(--primary-color,#6366f1)] text-white'
|
||||
: 'border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)] text-[var(--text-color-secondary)] hover:border-indigo-300 hover:text-[var(--text-color)]'"
|
||||
@click="selectedPeriod = p.value"
|
||||
>
|
||||
{{ p.label }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════════════════════
|
||||
CONTEÚDO
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<div class="px-3 md:px-4 pb-8 flex flex-col gap-3">
|
||||
|
||||
<!-- 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…
|
||||
<!-- Loading skeleton -->
|
||||
<div v-if="loading" class="flex flex-col gap-3">
|
||||
<!-- Stats skeleton -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div v-for="n in 6" :key="n" class="flex-1 min-w-[80px] h-[72px] rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
|
||||
</div>
|
||||
<!-- Chart skeleton -->
|
||||
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] h-[280px] animate-pulse" />
|
||||
</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>
|
||||
|
||||
<!-- ── QUICK-STATS clicáveis ────────────────────── -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="s in quickStats"
|
||||
:key="s.label"
|
||||
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] transition-[border-color,box-shadow,background] duration-150"
|
||||
:class="[
|
||||
s.filter !== null ? 'cursor-pointer select-none' : '',
|
||||
s.filter !== null && filtroTabela === s.filter
|
||||
? 'border-[var(--primary-color,#6366f1)] shadow-[0_0_0_3px_rgba(99,102,241,0.15)] bg-[var(--surface-card,#fff)]'
|
||||
: s.cls === 'qs-ok'
|
||||
? 'border-green-500/25 bg-green-500/5 hover:border-green-500/40'
|
||||
: s.cls === 'qs-danger'
|
||||
? 'border-red-500/25 bg-red-500/5 hover:border-red-500/40'
|
||||
: s.cls === 'qs-warn'
|
||||
? 'border-orange-500/25 bg-orange-500/5 hover:border-orange-500/40'
|
||||
: s.cls === 'qs-info'
|
||||
? 'border-sky-500/25 bg-sky-500/5 hover:border-sky-500/40'
|
||||
: 'border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] hover:border-indigo-300/50'
|
||||
]"
|
||||
@click="s.filter !== null ? toggleFiltroTabela(s.filter) : null"
|
||||
>
|
||||
<div class="text-[1.35rem] font-bold leading-none" :class="s.valCls">{{ s.value }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">{{ s.label }}</div>
|
||||
</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>
|
||||
|
||||
<!-- Chip de filtro ativo na tabela -->
|
||||
<div v-if="filtroTabela" class="flex items-center gap-2">
|
||||
<span class="text-[1rem] text-[var(--text-color-secondary)]">Filtrando por:</span>
|
||||
<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[0.75rem] font-semibold bg-[var(--primary-color,#6366f1)] text-white">
|
||||
{{ STATUS_LABEL[filtroTabela] || filtroTabela }}
|
||||
<button class="ml-0.5 opacity-70 hover:opacity-100" @click="filtroTabela = null">
|
||||
<i class="pi pi-times text-[0.6rem]" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- ── GRÁFICO ──────────────────────────────────── -->
|
||||
<div v-if="total > 0" class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-chart-bar text-[var(--text-color-secondary)] opacity-60" />
|
||||
<span class="font-semibold text-[1rem] text-[var(--text-color)]">
|
||||
Sessões por {{ selectedPeriod === 'week' ? 'semana' : 'mês' }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-[0.72rem] text-[var(--text-color-secondary)] opacity-60">{{ total }} sessão{{ total !== 1 ? 'ões' : '' }}</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 class="p-4">
|
||||
<Chart type="bar" :data="chartData" :options="chartOptions" class="h-64" />
|
||||
</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}%` : '—' }}
|
||||
</div>
|
||||
|
||||
<!-- ── TABELA ───────────────────────────────────── -->
|
||||
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
|
||||
|
||||
<!-- Cabeçalho da seção -->
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-table text-[var(--text-color-secondary)] opacity-60" />
|
||||
<span class="font-semibold text-[1rem] text-[var(--text-color)]">Sessões no período</span>
|
||||
<span
|
||||
v-if="filtroTabela"
|
||||
class="text-[0.72rem] text-[var(--text-color-secondary)] opacity-70"
|
||||
>(filtrado)</span>
|
||||
</div>
|
||||
<span class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1.5 rounded-full bg-[var(--primary-color,#6366f1)] text-white text-[1rem] font-bold">
|
||||
{{ sessionsFiltradas.length }}
|
||||
</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>
|
||||
<!-- Empty state (sem dados no período) -->
|
||||
<div
|
||||
v-if="!sessions.length"
|
||||
class="flex flex-col items-center justify-center gap-3 py-14 px-6 text-center border-2 border-dashed border-[var(--surface-border,#e2e8f0)] mx-4 my-4 rounded-md bg-[var(--surface-ground,#f8fafc)]"
|
||||
>
|
||||
<div class="relative">
|
||||
<div class="grid place-items-center w-16 h-16 rounded-2xl bg-[var(--surface-card,#fff)] border border-[var(--surface-border,#e2e8f0)] shadow-sm text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-chart-bar text-3xl opacity-25" />
|
||||
</div>
|
||||
<div class="absolute -top-1.5 -right-1.5 w-6 h-6 rounded-full bg-[var(--surface-card,#fff)] border border-[var(--surface-border,#e2e8f0)] shadow-sm grid place-items-center">
|
||||
<i class="pi pi-times text-[0.58rem] text-[var(--text-color-secondary)] opacity-50" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold text-[0.9rem] text-[var(--text-color)] mb-0.5">Nenhuma sessão no período</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] max-w-xs">Tente selecionar um período diferente.</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 mt-1 justify-center">
|
||||
<button
|
||||
v-for="p in PERIODS"
|
||||
:key="p.value"
|
||||
class="inline-flex items-center px-3 py-1 rounded-full text-[0.75rem] font-semibold border cursor-pointer transition-colors duration-150"
|
||||
:class="selectedPeriod === p.value
|
||||
? 'bg-[var(--primary-color,#6366f1)] border-[var(--primary-color,#6366f1)] text-white'
|
||||
: 'border-[var(--surface-border,#e2e8f0)] text-[var(--text-color-secondary)] hover:border-indigo-300'"
|
||||
@click="selectedPeriod = p.value"
|
||||
>
|
||||
{{ p.label }}
|
||||
</button>
|
||||
</div>
|
||||
</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.
|
||||
<!-- Empty state (filtro sem resultado) -->
|
||||
<div
|
||||
v-else-if="!sessionsFiltradas.length"
|
||||
class="flex flex-col items-center gap-2 py-10 text-center text-[var(--text-color-secondary)]"
|
||||
>
|
||||
<i class="pi pi-filter-slash text-2xl opacity-30" />
|
||||
<div class="font-semibold text-[0.88rem]">Nenhuma sessão com este status</div>
|
||||
<Button label="Limpar filtro" icon="pi pi-times" severity="secondary" outlined size="small" class="rounded-full mt-1" @click="filtroTabela = null" />
|
||||
</div>
|
||||
|
||||
<!-- DataTable -->
|
||||
<DataTable
|
||||
v-else
|
||||
:value="sessions"
|
||||
:value="sessionsFiltradas"
|
||||
:rows="20"
|
||||
paginator
|
||||
:rows-per-page-options="[10, 20, 50]"
|
||||
scrollable
|
||||
scroll-height="480px"
|
||||
class="text-sm"
|
||||
class="rel-datatable"
|
||||
>
|
||||
<Column field="inicio_em" header="Data / Hora" :sortable="true" style="min-width: 140px">
|
||||
<template #body="{ data }">{{ fmtDateTimeBR(data.inicio_em) }}</template>
|
||||
<template #body="{ data }">
|
||||
<span class="font-medium">{{ fmtDateTimeBR(data.inicio_em) }}</span>
|
||||
</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
|
||||
@@ -355,6 +441,13 @@ onMounted(loadSessions)
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rel-datatable :deep(.p-datatable-table-container) { border-radius: 0; }
|
||||
.rel-datatable :deep(th) { background: var(--surface-ground) !important; font-size: 0.82rem; }
|
||||
.rel-datatable :deep(td) { font-size: 0.85rem; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user