adf9208d2d
Card novo pra clínica e terapeuta com 3 métricas + sparkline: - Tempo médio (e mediana) de 1ª resposta no período - Taxa de SLA cumprido — % de respostas dentro do threshold configurado - Contagem total de respostas no período - Sparkline da evolução com indicador de tendência (melhorando/piorando) - Ranking top 5 terapeutas (só no ClinicDashboard) Filtro de período: 7/30/90 dias (muda granularidade do bucket: 1/7/15 dias pra sparkline com ~5-6 pontos). Banco (migration 20260423000006): - Helper interno _first_response_runs: identifica "runs" de inbound (sequências do paciente sem outbound entre) e calcula delta até a próxima outbound. Evita contar múltiplas mensagens repetidas do paciente. responder_id vem de conversation_assignments. - first_response_stats: agregados (count, avg, median, min, max, sla_compliance_rate baseado em conversation_sla_rules). - first_response_by_therapist: ranking com avg e count por assigned_to. - first_response_evolution: série temporal com bucket alinhado a p_from (p_from + bucket_index * N days). Parâmetro p_bucket_days deixa o frontend escolher granularidade por período. Todas SECURITY DEFINER + GRANT authenticated/service_role. Filtro opcional por therapist_id nas funções que aplicam. Frontend: - useFirstResponseAnalytics composable wraps as 3 RPCs com cache via Promise.all paralelo. Helper formatSeconds (Ns/Xmin/Xh). - FirstResponseCard.vue renderiza sparkline SVG nativo (sem lib extra), cor da taxa SLA por threshold (verde ≥80%, âmbar ≥50%, vermelho). - Integrado em ClinicDashboard (visão global) e TherapistDashboard (filtrado por ownerId, sem ranking). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1273 lines
64 KiB
Vue
1273 lines
64 KiB
Vue
<!--
|
|
|--------------------------------------------------------------------------
|
|
| Agência PSI
|
|
|--------------------------------------------------------------------------
|
|
| Criado e desenvolvido por Leonardo Nohama
|
|
|
|
|
| Tecnologia aplicada à escuta.
|
|
| Estrutura para o cuidado.
|
|
|
|
|
| Arquivo: src/views/pages/clinic/ClinicDashboard.vue
|
|
| Data: 2026
|
|
| Local: São Carlos/SP — Brasil
|
|
|--------------------------------------------------------------------------
|
|
| © 2026 — Todos os direitos reservados
|
|
|--------------------------------------------------------------------------
|
|
-->
|
|
<script setup>
|
|
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
|
import { useRouter } from 'vue-router';
|
|
import { useLayout } from '@/layout/composables/layout';
|
|
import Menu from 'primevue/menu';
|
|
import { supabase } from '@/lib/supabase/client';
|
|
import { useTenantStore } from '@/stores/tenantStore';
|
|
import { useClinicKPIs } from '@/composables/useClinicKPIs';
|
|
import FirstResponseCard from '@/components/dashboard/FirstResponseCard.vue';
|
|
|
|
// Fase 3a — KPIs financeiros/operacionais da clínica
|
|
const kpis = useClinicKPIs();
|
|
const brl = new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' });
|
|
function fmtBRL(v) { return brl.format(Number(v) || 0); }
|
|
const revenueMax = computed(() => {
|
|
const arr = kpis.revenueSeries.value || [];
|
|
return arr.reduce((m, r) => Math.max(m, r.received || 0), 0) || 1;
|
|
});
|
|
|
|
const dashHeroSentinelRef = ref(null);
|
|
const heroStuck = ref(false);
|
|
let _heroObserver = null;
|
|
|
|
const agora = ref(new Date());
|
|
const asideOpen = ref(false);
|
|
|
|
const { effectiveVariant, layoutState, layoutConfig, isMobile, railPanelPushesLayout } = useLayout();
|
|
const isMobileLayout = computed(() => isMobile.value);
|
|
const asideLeft = computed(() => {
|
|
if (isMobileLayout.value) return undefined;
|
|
if (effectiveVariant.value !== 'rail') {
|
|
const isStaticActive = layoutConfig.menuMode === 'static' && !layoutState.staticMenuInactive;
|
|
return isStaticActive ? '20rem' : '0';
|
|
}
|
|
return railPanelPushesLayout.value ? 'calc(60px + 260px)' : '60px';
|
|
});
|
|
|
|
let timer = null;
|
|
onBeforeUnmount(() => {
|
|
clearInterval(timer);
|
|
_heroObserver?.disconnect();
|
|
});
|
|
|
|
const horaAtual = computed(() => {
|
|
const h = agora.value.getHours().toString().padStart(2, '0');
|
|
const m = agora.value.getMinutes().toString().padStart(2, '0');
|
|
return `${h}:${m}`;
|
|
});
|
|
|
|
const saudacao = computed(() => {
|
|
const h = agora.value.getHours();
|
|
if (h < 12) return 'Bom dia';
|
|
if (h < 18) return 'Boa tarde';
|
|
return 'Boa noite';
|
|
});
|
|
|
|
const tenantStore = useTenantStore();
|
|
const router = useRouter();
|
|
|
|
// ── Mini calendário: menu de contexto ────────────────────────
|
|
const calDayMenuRef = ref(null);
|
|
const calDayMenuItems = computed(() => [
|
|
{
|
|
label: 'Opções do dia',
|
|
items: [
|
|
{
|
|
label: 'Ver dia na agenda da clínica',
|
|
icon: 'pi pi-calendar',
|
|
command: () => verDiaNaAgenda()
|
|
}
|
|
]
|
|
}
|
|
]);
|
|
|
|
function onCalDayClick(event, day) {
|
|
selectedDay.value = day;
|
|
calDayMenuRef.value?.toggle(event);
|
|
}
|
|
|
|
function verDiaNaAgenda() {
|
|
const d = String(selectedDay.value).padStart(2, '0');
|
|
const m = String(mesAtual + 1).padStart(2, '0');
|
|
router.push(`/admin/agenda/clinica?date=${anoAtual}-${m}-${d}`);
|
|
}
|
|
|
|
// ── Menu de contexto: Eventos do dia ─────────────────────────
|
|
const evMenuRef = ref(null);
|
|
const _evAtivo = ref(null);
|
|
|
|
const evMenuItems = computed(() => [
|
|
{
|
|
label: 'Opções',
|
|
items: [
|
|
{
|
|
label: 'Ver na agenda da clínica',
|
|
icon: 'pi pi-calendar',
|
|
command: () => {
|
|
if (!_evAtivo.value?.inicioISO) return;
|
|
const d = new Date(_evAtivo.value.inicioISO);
|
|
const ds = d.toISOString().slice(0, 10);
|
|
router.push(`/admin/agenda/clinica?date=${ds}`);
|
|
}
|
|
}
|
|
]
|
|
}
|
|
]);
|
|
|
|
function openEvMenu(event, ev) {
|
|
_evAtivo.value = ev;
|
|
evMenuRef.value?.toggle(event);
|
|
}
|
|
|
|
// ── Dados ─────────────────────────────────────────────────────
|
|
const loading = ref(true);
|
|
const eventosDoMes = ref([]);
|
|
const _solicitacoesBruto = ref([]);
|
|
const _cadastrosBruto = ref([]);
|
|
const _terapeutasBruto = ref([]);
|
|
|
|
const diasSemana = ['D', 'S', 'T', 'Q', 'Q', 'S', 'S'];
|
|
const hoje = agora.value.getDate();
|
|
const mesAtual = agora.value.getMonth();
|
|
const anoAtual = agora.value.getFullYear();
|
|
const selectedDay = ref(hoje);
|
|
|
|
const eventMap = computed(() => {
|
|
const map = {};
|
|
for (const ev of eventosDoMes.value) {
|
|
if (!ev.inicio_em) continue;
|
|
const d = new Date(ev.inicio_em);
|
|
if (d.getMonth() !== mesAtual || d.getFullYear() !== anoAtual) continue;
|
|
const day = d.getDate();
|
|
if (!map[day]) map[day] = { count: 0, hasEvent: true };
|
|
map[day].count++;
|
|
}
|
|
for (const day in map) {
|
|
const c = map[day].count;
|
|
map[day].urgency = c >= 8 ? 'urg-alta' : c >= 4 ? 'urg-media' : 'urg-baixa';
|
|
}
|
|
return map;
|
|
});
|
|
|
|
const calCells = computed(() => {
|
|
const primeiroDia = new Date(anoAtual, mesAtual, 1).getDay();
|
|
const totalDias = new Date(anoAtual, mesAtual + 1, 0).getDate();
|
|
const cells = [];
|
|
for (let i = 0; i < primeiroDia; i++) cells.push({ key: 'e' + i, day: null, isOther: true, isToday: false, hasEvent: false, count: 0, urgency: '' });
|
|
for (let d = 1; d <= totalDias; d++) {
|
|
const ev = eventMap.value[d] || {};
|
|
cells.push({ key: 'd' + d, day: d, isToday: d === hoje, isOther: false, hasEvent: !!ev.hasEvent, count: ev.count || 0, urgency: ev.urgency || '' });
|
|
}
|
|
return cells;
|
|
});
|
|
|
|
const labelDiaSelecionado = computed(() => {
|
|
if (selectedDay.value === hoje) return 'Hoje';
|
|
if (selectedDay.value === hoje + 1) return 'Amanhã';
|
|
return `Dia ${selectedDay.value}/${String(mesAtual + 1).padStart(2, '0')}`;
|
|
});
|
|
|
|
const STATUS_ICON = {
|
|
realizado: 'pi pi-check-circle',
|
|
faltou: 'pi pi-times-circle',
|
|
cancelado: 'pi pi-times-circle',
|
|
remarcar: 'pi pi-undo',
|
|
agendado: 'pi pi-clock'
|
|
};
|
|
|
|
function buildEventoItem(ev) {
|
|
const inicio = new Date(ev.inicio_em);
|
|
const fim = ev.fim_em ? new Date(ev.fim_em) : null;
|
|
const durMin = fim ? Math.round((fim - inicio) / 60000) : 50;
|
|
const h = inicio.getHours().toString().padStart(2, '0');
|
|
const m = inicio.getMinutes().toString().padStart(2, '0');
|
|
return {
|
|
id: ev.id,
|
|
hora: `${h}:${m}`,
|
|
dur: `${durMin}min`,
|
|
nome: ev.patients?.nome_completo || ev.titulo || ev.titulo_custom || '—',
|
|
terapeutaNome: _terapeutasBruto.value.find(t => t.user_id === ev.owner_id)?.full_name || '—',
|
|
modalidade: ev.modalidade || 'Presencial',
|
|
recorrente: !!ev.recurrence_id,
|
|
status: ev.status || 'agendado',
|
|
statusIcon: STATUS_ICON[ev.status] || 'pi pi-clock',
|
|
tipo: ev.tipo || 'sessao',
|
|
inicioISO: ev.inicio_em || null
|
|
};
|
|
}
|
|
|
|
const eventosDoDia = computed(() =>
|
|
eventosDoMes.value
|
|
.filter((ev) => {
|
|
if (!ev.inicio_em) return false;
|
|
const d = new Date(ev.inicio_em);
|
|
return d.getDate() === selectedDay.value && d.getMonth() === mesAtual && d.getFullYear() === anoAtual;
|
|
})
|
|
.sort((a, b) => new Date(a.inicio_em) - new Date(b.inicio_em))
|
|
.map(buildEventoItem)
|
|
);
|
|
|
|
// ── Terapeutas com sessões hoje ───────────────────────────────
|
|
const terapeutas = computed(() => {
|
|
const eventosHoje = eventosDoMes.value.filter((ev) => {
|
|
if (!ev.inicio_em) return false;
|
|
const d = new Date(ev.inicio_em);
|
|
return d.getDate() === hoje && d.getMonth() === mesAtual && d.getFullYear() === anoAtual && ev.tipo !== 'bloqueio';
|
|
});
|
|
|
|
const map = {};
|
|
for (const ev of eventosHoje) {
|
|
const uid = ev.owner_id || '?';
|
|
if (!map[uid]) {
|
|
const membro = _terapeutasBruto.value.find((t) => t.user_id === uid);
|
|
map[uid] = {
|
|
id: uid,
|
|
nome: membro?.full_name || 'Terapeuta',
|
|
sessoesHoje: 0,
|
|
realizadas: 0
|
|
};
|
|
}
|
|
map[uid].sessoesHoje++;
|
|
if (ev.status === 'realizado') map[uid].realizadas++;
|
|
}
|
|
return Object.values(map).sort((a, b) => b.sessoesHoje - a.sessoesHoje).slice(0, 8);
|
|
});
|
|
|
|
// ── Derivados — single pass ────────────────────────────────────
|
|
const _statsDoMes = computed(() => {
|
|
const now = agora.value;
|
|
const semIni = new Date(now);
|
|
semIni.setDate(now.getDate() - now.getDay());
|
|
semIni.setHours(0, 0, 0, 0);
|
|
const semFim = new Date(semIni);
|
|
semFim.setDate(semIni.getDate() + 6);
|
|
semFim.setHours(23, 59, 59, 999);
|
|
|
|
let hojeCnt = 0, semanaCnt = 0, realizadosCnt = 0, encerradosCnt = 0;
|
|
const hojeLista = [], timelineLista = [];
|
|
const diasSemanaMap = [[], [], [], [], [], [], []];
|
|
const terapeutasAtivosHoje = new Set();
|
|
|
|
for (const ev of eventosDoMes.value) {
|
|
if (!ev.inicio_em) continue;
|
|
const d = new Date(ev.inicio_em);
|
|
const dDay = d.getDate(), dMon = d.getMonth(), dYear = d.getFullYear();
|
|
const isHoje = dDay === hoje && dMon === mesAtual && dYear === anoAtual;
|
|
|
|
if (isHoje) {
|
|
hojeCnt++;
|
|
hojeLista.push(ev);
|
|
timelineLista.push(ev);
|
|
if (ev.owner_id && ev.tipo !== 'bloqueio') terapeutasAtivosHoje.add(ev.owner_id);
|
|
}
|
|
if (d >= semIni && d <= semFim) {
|
|
semanaCnt++;
|
|
diasSemanaMap[d.getDay()].push(ev);
|
|
}
|
|
if (d < now && ['realizado', 'faltou', 'cancelado'].includes(ev.status)) {
|
|
encerradosCnt++;
|
|
if (ev.status === 'realizado') realizadosCnt++;
|
|
}
|
|
}
|
|
|
|
const taxaPresenca = encerradosCnt > 0 ? Math.round((realizadosCnt / encerradosCnt) * 100) : null;
|
|
return { hojeCnt, semanaCnt, taxaPresenca, hojeLista, timelineLista, diasSemanaMap, terapeutasAtivosHoje };
|
|
});
|
|
|
|
const taxaPresenca = computed(() => _statsDoMes.value.taxaPresenca);
|
|
|
|
const quickStats = computed(() => {
|
|
const pendentes = _solicitacoesBruto.value.length + _cadastrosBruto.value.length;
|
|
const terapAtivos = _statsDoMes.value.terapeutasAtivosHoje.size || _terapeutasBruto.value.length;
|
|
const pct = taxaPresenca.value;
|
|
return [
|
|
{ value: String(_statsDoMes.value.hojeCnt), label: 'Sessões Hoje', cls: '' },
|
|
{ value: String(terapAtivos), label: 'Terapeutas', cls: '' },
|
|
{ value: String(pendentes), label: 'Pendentes', cls: pendentes > 0 ? 'qs-urgente' : '' },
|
|
{ value: pct !== null ? `${pct}%` : '—', label: 'Presença', cls: pct !== null && pct >= 85 ? 'qs-ok' : '' }
|
|
];
|
|
});
|
|
|
|
const resumoHoje = computed(() => {
|
|
const sessoes = _statsDoMes.value.hojeLista.filter((ev) => ev.tipo !== 'bloqueio').length;
|
|
const sols = _solicitacoesBruto.value.length;
|
|
const terapAtivos = _statsDoMes.value.terapeutasAtivosHoje.size;
|
|
const parts = [];
|
|
if (sessoes === 1) parts.push('1 sessão na clínica hoje');
|
|
else if (sessoes > 1) parts.push(`${sessoes} sessões na clínica hoje`);
|
|
else parts.push('Nenhuma sessão hoje');
|
|
if (terapAtivos > 0) parts.push(`${terapAtivos} terapeuta${terapAtivos > 1 ? 's' : ''} ativ${terapAtivos > 1 ? 'os' : 'o'}`);
|
|
if (sols > 0) parts.push(`${sols} solicitaç${sols === 1 ? 'ão' : 'ões'} pendente${sols > 1 ? 's' : ''}`);
|
|
return parts.join(' · ');
|
|
});
|
|
|
|
// ── Solicitações ──────────────────────────────────────────────
|
|
function initials(nome) {
|
|
const parts = (nome || '').trim().split(/\s+/);
|
|
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
|
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
|
}
|
|
|
|
const solicitacoes = computed(() =>
|
|
_solicitacoesBruto.value.map((s) => {
|
|
const nome = `${s.paciente_nome || ''} ${s.paciente_sobrenome || ''}`.trim() || '—';
|
|
const dia = s.data_solicitada ? new Date(s.data_solicitada + 'T00:00:00').toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' }) : '—';
|
|
const hora = s.hora_solicitada ? String(s.hora_solicitada).slice(0, 5) : '';
|
|
const tipo = s.tipo === 'primeira' ? 'Primeira consulta' : s.tipo === 'retorno' ? 'Retorno' : s.tipo || '—';
|
|
const modal = s.modalidade === 'online' ? 'Online' : s.modalidade === 'presencial' ? 'Presencial' : s.modalidade || '—';
|
|
return { id: s.id, nome, initials: initials(nome), detalhe: `${tipo} · ${modal} · ${dia}${hora ? ' ' + hora : ''}` };
|
|
})
|
|
);
|
|
const solicitacoesPendentes = computed(() => solicitacoes.value.length);
|
|
|
|
const cadastros = computed(() =>
|
|
_cadastrosBruto.value.map((c) => {
|
|
const nome = c.nome_completo || '—';
|
|
const criado = c.created_at ? new Date(c.created_at).toLocaleDateString('pt-BR') : '—';
|
|
return { id: c.id, nome, initials: initials(nome), detalhe: `Via portal · ${criado}` };
|
|
})
|
|
);
|
|
const cadastrosPendentes = computed(() => cadastros.value.length);
|
|
|
|
// ── Timeline ──────────────────────────────────────────────────
|
|
const hoursRange = [7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19];
|
|
const TL_START = 7, TL_END = 20, TL_SPAN = TL_END - TL_START;
|
|
|
|
function toPercent(h, m) {
|
|
return ((h + m / 60 - TL_START) / TL_SPAN) * 100;
|
|
}
|
|
|
|
const timelineEvents = computed(() =>
|
|
_statsDoMes.value.timelineLista
|
|
.slice()
|
|
.sort((a, b) => new Date(a.inicio_em) - new Date(b.inicio_em))
|
|
.map((ev) => {
|
|
const item = buildEventoItem(ev);
|
|
const [hh, mm] = item.hora.split(':').map(Number);
|
|
const durMin = parseInt(item.dur) || 50;
|
|
return {
|
|
id: item.id,
|
|
label: item.nome.split(' ')[0],
|
|
tipo: item.tipo,
|
|
status: item.status,
|
|
tooltip: `${item.hora} · ${item.nome} · ${item.terapeutaNome} · ${item.modalidade}`,
|
|
badge: item.modalidade?.toLowerCase() === 'online' ? '📱' : '',
|
|
style: { left: toPercent(hh, mm) + '%', width: Math.max((durMin / 60 / TL_SPAN) * 100, 4) + '%' }
|
|
};
|
|
})
|
|
);
|
|
|
|
const nowCursorLeft = computed(() => {
|
|
const pct = toPercent(agora.value.getHours(), agora.value.getMinutes());
|
|
return Math.min(Math.max(pct, 0), 100) + '%';
|
|
});
|
|
|
|
// ── Radar da semana ───────────────────────────────────────────
|
|
const DIAS_PT = ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb'];
|
|
|
|
const radarSemana = computed(() => {
|
|
const diasMap = _statsDoMes.value.diasSemanaMap;
|
|
const dow = agora.value.getDay();
|
|
return DIAS_PT.map((dia, i) => {
|
|
const evs = diasMap[i];
|
|
const total = evs.length;
|
|
const presentes = evs.filter((ev) => ev.status === 'realizado').length;
|
|
const faltas = evs.filter((ev) => ev.status === 'faltou').length;
|
|
let status = 'ok';
|
|
if (faltas > 0 && faltas >= presentes) status = 'falta';
|
|
return { dia, total, pct: total === 0 ? 0 : Math.min(Math.round((total / 12) * 100), 100), status, isToday: i === dow };
|
|
});
|
|
});
|
|
|
|
// ── Ocupação dos terapeutas (card) ────────────────────────────
|
|
function hashColor(str) {
|
|
const palette = ['#6366f1', '#0ea5e9', '#ec4899', '#f59e0b', '#22c55e', '#8b5cf6', '#ef4444', '#14b8a6'];
|
|
let h = 0;
|
|
for (let i = 0; i < (str || '').length; i++) h = (h * 31 + str.charCodeAt(i)) & 0xffffffff;
|
|
return palette[Math.abs(h) % palette.length];
|
|
}
|
|
|
|
const ocupacaoTerapeutas = computed(() => {
|
|
const maxSessoes = Math.max(...terapeutas.value.map((t) => t.sessoesHoje), 1);
|
|
return terapeutas.value.map((t) => ({
|
|
...t,
|
|
pct: Math.round((t.sessoesHoje / maxSessoes) * 100),
|
|
color: hashColor(t.id),
|
|
initials: initials(t.nome)
|
|
}));
|
|
});
|
|
|
|
// ── Load ──────────────────────────────────────────────────────
|
|
async function load() {
|
|
loading.value = true;
|
|
await tenantStore.ensureLoaded();
|
|
const tid = tenantStore.activeTenantId || tenantStore.tenantId || null;
|
|
if (!tid) {
|
|
loading.value = false;
|
|
return;
|
|
}
|
|
|
|
const mesInicio = new Date(anoAtual, mesAtual, 1, 0, 0, 0, 0).toISOString();
|
|
const mesFim = new Date(anoAtual, mesAtual + 1, 0, 23, 59, 59, 999).toISOString();
|
|
|
|
try {
|
|
const [eventosRes, membrosRes, solRes, cadRes] = await Promise.all([
|
|
supabase
|
|
.from('agenda_eventos')
|
|
.select('id, inicio_em, fim_em, status, modalidade, tipo, titulo, titulo_custom, patient_id, owner_id, recurrence_id, patients(nome_completo)')
|
|
.eq('tenant_id', tid)
|
|
.gte('inicio_em', mesInicio)
|
|
.lte('inicio_em', mesFim)
|
|
.order('inicio_em', { ascending: true }),
|
|
supabase
|
|
.from('v_tenant_members_with_profiles')
|
|
.select('user_id, full_name, role')
|
|
.eq('tenant_id', tid)
|
|
.in('role', ['therapist', 'tenant_admin'])
|
|
.eq('status', 'active'),
|
|
supabase
|
|
.from('agendador_solicitacoes')
|
|
.select('id, paciente_nome, paciente_sobrenome, tipo, modalidade, data_solicitada, hora_solicitada')
|
|
.eq('tenant_id', tid)
|
|
.eq('status', 'pendente')
|
|
.order('created_at', { ascending: false })
|
|
.limit(10),
|
|
supabase
|
|
.from('patient_intake_requests')
|
|
.select('id, nome_completo, status, created_at')
|
|
.eq('tenant_id', tid)
|
|
.eq('status', 'new')
|
|
.order('created_at', { ascending: false })
|
|
.limit(10)
|
|
]);
|
|
|
|
eventosDoMes.value = eventosRes.data || [];
|
|
_terapeutasBruto.value = membrosRes.data || [];
|
|
_solicitacoesBruto.value = solRes.data || [];
|
|
_cadastrosBruto.value = cadRes.data || [];
|
|
|
|
// KPIs financeiros em paralelo (não bloqueante)
|
|
kpis.load();
|
|
} catch (e) {
|
|
console.error('[ClinicDashboard] load:', e);
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
timer = setInterval(() => { agora.value = new Date(); }, 60000);
|
|
_heroObserver = new IntersectionObserver(
|
|
([entry]) => { heroStuck.value = !entry.isIntersecting; },
|
|
{ threshold: 0 }
|
|
);
|
|
if (dashHeroSentinelRef.value) _heroObserver.observe(dashHeroSentinelRef.value);
|
|
await load();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div class="flex min-h-[calc(100vh-4.5rem)] bg-[var(--surface-ground,#f5f7fa)]">
|
|
<!-- ═══════════════════════════════════════
|
|
OVERLAY (mobile) + ASIDE
|
|
══════════════════════════════════════════ -->
|
|
<div v-if="asideOpen" class="fixed inset-0 bg-black/40 backdrop-blur-sm z-[39] xl:hidden" @click="asideOpen = false" />
|
|
|
|
<aside
|
|
class="aside-drawer flex flex-col overflow-y-auto shrink-0 bg-[var(--surface-card,#fff)] border-r border-[var(--surface-border,#e2e8f0)]"
|
|
:class="asideOpen ? 'translate-x-0 visible' : 'max-xl:-translate-x-full max-xl:invisible'"
|
|
:style="{ left: asideLeft }"
|
|
>
|
|
<!-- Mini calendário -->
|
|
<div class="p-3.5 pb-2.5 border-b border-[var(--surface-border,#e2e8f0)]">
|
|
<div class="flex items-center gap-1.5 text-xs font-bold uppercase tracking-widest text-[var(--text-color-secondary,#64748b)] mb-2.5">
|
|
<i class="pi pi-calendar" />
|
|
<span>{{ new Date(anoAtual, mesAtual).toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' }) }}</span>
|
|
</div>
|
|
|
|
<template v-if="loading">
|
|
<div class="grid grid-cols-7 mb-0.5">
|
|
<Skeleton v-for="n in 7" :key="n" height="14px" class="mx-0.5" />
|
|
</div>
|
|
<div class="grid grid-cols-7 gap-px mt-1">
|
|
<Skeleton v-for="n in 35" :key="n" height="22px" class="rounded" />
|
|
</div>
|
|
</template>
|
|
|
|
<template v-else>
|
|
<div class="grid grid-cols-7 mb-0.5">
|
|
<span v-for="d in diasSemana" :key="d" class="text-center text-xs font-bold text-[var(--text-color-secondary,#94a3b8)] py-0.5">{{ d }}</span>
|
|
</div>
|
|
<div class="grid grid-cols-7 gap-px">
|
|
<button
|
|
v-for="cell in calCells"
|
|
:key="cell.key"
|
|
class="relative aspect-square flex items-center justify-center text-xs font-medium rounded border-none cursor-pointer transition-colors duration-100"
|
|
:class="{
|
|
'bg-[var(--primary-color,#6366f1)] text-white font-bold rounded-md': cell.isToday,
|
|
'text-[var(--text-color-secondary,#cbd5e1)] cursor-default': cell.isOther,
|
|
'bg-indigo-500/10 text-[var(--primary-color,#6366f1)] font-bold': cell.day === selectedDay && !cell.isToday,
|
|
'text-[var(--text-color,#1e293b)] hover:bg-[var(--surface-hover,#f1f5f9)]': !cell.isToday && !cell.isOther
|
|
}"
|
|
@click="cell.day && onCalDayClick($event, cell.day)"
|
|
>
|
|
<span>{{ cell.day }}</span>
|
|
<span
|
|
v-if="cell.count"
|
|
class="absolute bottom-px right-0.5 w-1 h-1 rounded-full"
|
|
:class="{
|
|
'bg-red-500': cell.urgency === 'urg-alta',
|
|
'bg-amber-500': cell.urgency === 'urg-media',
|
|
'bg-green-500': cell.urgency === 'urg-baixa'
|
|
}"
|
|
/>
|
|
</button>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Eventos do dia (clínica toda) -->
|
|
<div class="p-3.5 pb-2.5 border-b border-[var(--surface-border,#e2e8f0)]">
|
|
<div class="flex items-center gap-1.5 text-xs font-bold uppercase tracking-widest text-[var(--text-color-secondary,#64748b)] mb-2.5">
|
|
<i class="pi pi-clock" /><span>{{ labelDiaSelecionado }}</span>
|
|
</div>
|
|
|
|
<template v-if="loading">
|
|
<div class="flex flex-col gap-2">
|
|
<div v-for="n in 3" :key="n" class="aside-ev aside-ev--skeleton">
|
|
<div class="flex flex-col gap-1 min-w-[36px] items-end">
|
|
<Skeleton width="32px" height="10px" />
|
|
<Skeleton width="24px" height="8px" />
|
|
</div>
|
|
<Skeleton shape="square" size="28px" border-radius="4px" />
|
|
<div class="flex flex-col gap-1.5 flex-1">
|
|
<Skeleton :width="n === 1 ? '75%' : n === 2 ? '60%' : '70%'" height="10px" />
|
|
<Skeleton width="40%" height="8px" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<div v-else class="flex flex-col gap-2 max-h-[260px] overflow-y-auto pr-0.5">
|
|
<div
|
|
v-for="ev in eventosDoDia"
|
|
:key="ev.id"
|
|
class="aside-ev aside-ev--default"
|
|
:class="{
|
|
'aside-ev--reuniao': ev.tipo === 'reuniao',
|
|
'aside-ev--realizado': ev.status === 'realizado'
|
|
}"
|
|
@click="openEvMenu($event, ev)"
|
|
>
|
|
<div class="aside-ev__time">
|
|
<span class="aside-ev__hora">{{ ev.hora }}</span>
|
|
<span class="aside-ev__dur">{{ ev.dur }}</span>
|
|
</div>
|
|
<Avatar :label="initials(ev.nome)" shape="square" size="small" class="shrink-0" />
|
|
<div class="flex-1 min-w-0">
|
|
<span class="block text-[0.8125rem] font-semibold truncate text-[var(--text-color)]">{{ ev.nome }}</span>
|
|
<div class="flex gap-1.5 mt-0.5 items-center">
|
|
<span class="aside-ev__badge">{{ ev.terapeutaNome.split(' ')[0] }}</span>
|
|
<i v-if="ev.recorrente" class="pi pi-sync text-[0.6rem] text-[var(--primary-color,#6366f1)]" title="Recorrente" />
|
|
</div>
|
|
</div>
|
|
<i :class="ev.statusIcon" class="text-xs text-[var(--text-color-secondary)] shrink-0" />
|
|
</div>
|
|
<div v-if="!eventosDoDia.length" class="flex items-center gap-2 py-3 text-[var(--text-color-secondary)] text-sm"><i class="pi pi-sun" /><span>Sem compromissos</span></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Terapeutas com sessões hoje -->
|
|
<div class="p-3.5 pb-2.5">
|
|
<div class="flex items-center gap-1.5 text-xs font-bold uppercase tracking-widest text-[var(--text-color-secondary,#64748b)] mb-2.5">
|
|
<i class="pi pi-users" /><span>Terapeutas hoje</span>
|
|
<span class="ml-auto bg-[var(--primary-color,#6366f1)] text-white rounded-full px-1.5 text-xs font-bold">{{ terapeutas.length }}</span>
|
|
</div>
|
|
|
|
<template v-if="loading">
|
|
<div class="flex flex-col gap-2">
|
|
<div v-for="n in 4" :key="n" class="aside-rec aside-rec--skeleton">
|
|
<Skeleton shape="square" size="30px" border-radius="4px" />
|
|
<div class="flex flex-col gap-1.5 flex-1">
|
|
<Skeleton :width="n % 2 === 0 ? '65%' : '50%'" height="10px" />
|
|
<Skeleton width="75%" height="8px" />
|
|
</div>
|
|
<Skeleton width="28px" height="10px" />
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<div v-else class="flex flex-col gap-1.5 max-h-[220px] overflow-y-auto pr-0.5">
|
|
<div v-for="t in terapeutas" :key="t.id" class="aside-rec">
|
|
<Avatar :label="initials(t.nome)" shape="square" size="normal" class="shrink-0" :style="{ backgroundColor: hashColor(t.id) + '22', color: hashColor(t.id) }" />
|
|
<div class="flex-1 min-w-0">
|
|
<span class="block text-[0.8125rem] font-semibold truncate text-[var(--text-color)]">{{ t.nome.split(' ').slice(0, 2).join(' ') }}</span>
|
|
<span class="block text-xs text-[var(--text-color-secondary)]">{{ t.sessoesHoje }} sessão{{ t.sessoesHoje !== 1 ? 'ões' : '' }}</span>
|
|
</div>
|
|
<div class="aside-rec__prox text-[var(--primary-color,#6366f1)] font-bold">{{ t.sessoesHoje }}</div>
|
|
</div>
|
|
<div v-if="!terapeutas.length" class="flex items-center gap-2 py-3 text-[var(--text-color-secondary)] text-sm"><i class="pi pi-info-circle" /><span>Nenhum terapeuta com sessão hoje</span></div>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
|
|
<!-- ═══════════════════════════════════════
|
|
CONTEÚDO PRINCIPAL
|
|
══════════════════════════════════════════ -->
|
|
<div ref="dashHeroSentinelRef" class="h-px" />
|
|
|
|
<main class="flex-1 min-w-0 p-4 xl:p-[1.125rem_1.375rem] flex flex-col gap-4 overflow-y-auto" :style="{ marginLeft: isMobileLayout ? undefined : '272px' }">
|
|
|
|
<!-- Hero Header — Skeleton -->
|
|
<section v-if="loading" class="relative overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5 mx-3 md:mx-4 mt-4">
|
|
<div class="flex items-center gap-3">
|
|
<Skeleton width="40px" height="40px" border-radius="6px" />
|
|
<div class="flex flex-col gap-1.5 flex-1">
|
|
<Skeleton width="12rem" height="14px" />
|
|
<Skeleton width="18rem" height="11px" />
|
|
</div>
|
|
<Skeleton width="36px" height="36px" border-radius="999px" />
|
|
<Skeleton width="36px" height="36px" border-radius="999px" />
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Quick stats — Skeleton -->
|
|
<section v-if="loading" class="grid grid-cols-2 md:grid-cols-4 gap-2.5 mx-3 md:mx-4 mb-4">
|
|
<div v-for="n in 4" :key="n" class="flex flex-col gap-1.5 px-4 py-2.5 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)]">
|
|
<Skeleton width="2rem" height="20px" />
|
|
<Skeleton width="4rem" height="10px" />
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Hero Header -->
|
|
<section
|
|
v-if="!loading"
|
|
class="sticky mx-3 md:mx-4 mt-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
|
|
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
|
|
:class="{ 'rounded-tl-none rounded-tr-none': heroStuck }"
|
|
>
|
|
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
|
|
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
|
|
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
|
|
<div class="absolute rounded-full blur-[70px] w-72 h-72 -bottom-20 right-24 bg-fuchsia-400/10" />
|
|
</div>
|
|
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
|
|
<div class="flex items-center gap-3 flex-1 min-w-0">
|
|
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0 bg-indigo-500/10 text-indigo-600 dark:text-indigo-400">
|
|
<i class="pi pi-building text-lg" />
|
|
</div>
|
|
<div class="min-w-0">
|
|
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">{{ saudacao }} <span class="text-[var(--primary-color,#6366f1)]">👋</span></div>
|
|
<div class="text-[0.78rem] text-[var(--text-color-secondary)] mt-0.5">{{ resumoHoje }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-2 shrink-0">
|
|
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Recarregar" @click="load" />
|
|
<Button icon="pi pi-cog" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Configurações" @click="$router.push('/configuracoes')" />
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Quick stats (separados do hero) -->
|
|
<section v-if="!loading" class="grid grid-cols-2 md:grid-cols-4 gap-2.5 mx-3 md:mx-4 mb-4">
|
|
<div
|
|
v-for="s in quickStats"
|
|
:key="s.label"
|
|
class="flex flex-col gap-0.5 px-4 py-3 rounded-md border transition-colors duration-150"
|
|
:class="{
|
|
'border-red-500/25 bg-red-500/5': s.cls === 'qs-urgente',
|
|
'border-green-500/25 bg-green-500/5': s.cls === 'qs-ok',
|
|
'border-[var(--surface-border)] bg-[var(--surface-card)]': !s.cls
|
|
}"
|
|
>
|
|
<div
|
|
class="text-[1.35rem] font-bold leading-none"
|
|
:class="{
|
|
'text-red-500': s.cls === 'qs-urgente',
|
|
'text-green-500': s.cls === 'qs-ok',
|
|
'text-[var(--text-color)]': !s.cls
|
|
}"
|
|
>
|
|
{{ s.value }}
|
|
</div>
|
|
<div class="text-[0.7rem] text-[var(--text-color-secondary)] opacity-75 truncate">{{ s.label }}</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ═══════════════════════════════════════
|
|
KPIs financeiros/operacionais (Fase 3a)
|
|
══════════════════════════════════════════ -->
|
|
<section v-if="!loading" class="flex flex-col gap-3">
|
|
<!-- Cards KPI -->
|
|
<div class="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-6 gap-2.5">
|
|
<div class="flex flex-col gap-0.5 px-4 py-3 rounded-md border border-emerald-500/25 bg-emerald-500/5">
|
|
<div class="text-[0.7rem] text-[var(--text-color-secondary)] uppercase tracking-wide">Recebido no mês</div>
|
|
<div class="text-[1.25rem] font-bold text-emerald-600 leading-tight">{{ fmtBRL(kpis.mrrCurrentCents.value) }}</div>
|
|
<div class="text-[0.68rem] text-[var(--text-color-secondary)] opacity-75">
|
|
Ticket médio: {{ fmtBRL(kpis.avgTicket.value) }}
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
class="flex flex-col gap-0.5 px-4 py-3 rounded-md border"
|
|
:class="kpis.overdueCount.value > 0 ? 'border-red-500/25 bg-red-500/5' : 'border-[var(--surface-border)] bg-[var(--surface-ground)]'"
|
|
>
|
|
<div class="text-[0.7rem] text-[var(--text-color-secondary)] uppercase tracking-wide">Inadimplência</div>
|
|
<div class="text-[1.25rem] font-bold leading-tight" :class="kpis.overdueCount.value > 0 ? 'text-red-500' : 'text-[var(--text-color)]'">
|
|
{{ fmtBRL(kpis.overdueCents.value) }}
|
|
</div>
|
|
<div class="text-[0.68rem] text-[var(--text-color-secondary)] opacity-75">
|
|
{{ kpis.overdueCount.value }} recebível(is) em atraso
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-0.5 px-4 py-3 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)]">
|
|
<div class="text-[0.7rem] text-[var(--text-color-secondary)] uppercase tracking-wide">A receber</div>
|
|
<div class="text-[1.25rem] font-bold text-[var(--text-color)] leading-tight">{{ fmtBRL(kpis.pendingCents.value) }}</div>
|
|
<div class="text-[0.68rem] text-[var(--text-color-secondary)] opacity-75">Pendentes no mês</div>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-0.5 px-4 py-3 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)]">
|
|
<div class="text-[0.7rem] text-[var(--text-color-secondary)] uppercase tracking-wide">Pacientes</div>
|
|
<div class="text-[1.25rem] font-bold text-[var(--text-color)] leading-tight">
|
|
{{ kpis.activePatients.value }}
|
|
<span class="text-[0.75rem] text-[var(--text-color-secondary)] font-normal">/ {{ kpis.totalPatients.value }}</span>
|
|
</div>
|
|
<div class="text-[0.68rem] text-[var(--text-color-secondary)] opacity-75">Ativos · {{ kpis.inactivePatients.value }} inativos</div>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-0.5 px-4 py-3 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)]">
|
|
<div class="text-[0.7rem] text-[var(--text-color-secondary)] uppercase tracking-wide">Sessões no mês</div>
|
|
<div class="text-[1.25rem] font-bold text-[var(--text-color)] leading-tight">{{ kpis.sessionsDone.value }}</div>
|
|
<div class="text-[0.68rem] text-[var(--text-color-secondary)] opacity-75">
|
|
de {{ kpis.sessionsScheduled.value }} agendadas
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
class="flex flex-col gap-0.5 px-4 py-3 rounded-md border"
|
|
:class="(kpis.noShowRate.value ?? 0) > 15 ? 'border-amber-500/25 bg-amber-500/5' : 'border-[var(--surface-border)] bg-[var(--surface-ground)]'"
|
|
>
|
|
<div class="text-[0.7rem] text-[var(--text-color-secondary)] uppercase tracking-wide">Taxa de falta</div>
|
|
<div class="text-[1.25rem] font-bold leading-tight" :class="(kpis.noShowRate.value ?? 0) > 15 ? 'text-amber-600' : 'text-[var(--text-color)]'">
|
|
{{ kpis.noShowRate.value !== null ? kpis.noShowRate.value + '%' : '—' }}
|
|
</div>
|
|
<div class="text-[0.68rem] text-[var(--text-color-secondary)] opacity-75">
|
|
{{ kpis.sessionsNoShow.value }} faltas · {{ kpis.sessionsCancelled.value }} cancel.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Grid: gráfico 6 meses + top pacientes -->
|
|
<div class="grid grid-cols-1 xl:grid-cols-[1fr_320px] gap-3">
|
|
<!-- Gráfico de receita (barras SVG simples) -->
|
|
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
|
|
<div class="flex items-center justify-between mb-3">
|
|
<div class="text-sm font-semibold text-[var(--text-color)]">Receita recebida · últimos 6 meses</div>
|
|
<i class="pi pi-chart-bar text-[var(--text-color-secondary)]" />
|
|
</div>
|
|
<div v-if="!kpis.revenueSeries.value.length" class="text-xs text-[var(--text-color-secondary)] py-6 text-center">
|
|
Sem dados no período.
|
|
</div>
|
|
<div v-else class="flex items-end gap-2 h-40">
|
|
<div
|
|
v-for="(m, i) in kpis.revenueSeries.value"
|
|
:key="i"
|
|
class="flex-1 flex flex-col items-center gap-1 min-w-0"
|
|
>
|
|
<div class="text-[0.65rem] text-[var(--text-color-secondary)] truncate w-full text-center">
|
|
{{ fmtBRL(m.received) }}
|
|
</div>
|
|
<div
|
|
class="w-full bg-emerald-500/70 rounded-t-md transition-all duration-300 min-h-[2px]"
|
|
:style="{ height: `${Math.max(2, (m.received / revenueMax) * 130)}px` }"
|
|
/>
|
|
<div class="text-[0.68rem] text-[var(--text-color-secondary)] uppercase">{{ m.label }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Top 5 pacientes -->
|
|
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
|
|
<div class="flex items-center justify-between mb-3">
|
|
<div class="text-sm font-semibold text-[var(--text-color)]">Top pacientes · 6 meses</div>
|
|
<i class="pi pi-users text-[var(--text-color-secondary)]" />
|
|
</div>
|
|
<div v-if="!kpis.topPatients.value.length" class="text-xs text-[var(--text-color-secondary)] py-6 text-center">
|
|
Sem dados.
|
|
</div>
|
|
<ol v-else class="flex flex-col gap-1.5">
|
|
<li
|
|
v-for="(p, i) in kpis.topPatients.value"
|
|
:key="p.patient_id"
|
|
class="flex items-center justify-between gap-2 text-xs"
|
|
>
|
|
<div class="flex items-center gap-2 min-w-0 flex-1">
|
|
<span class="w-5 h-5 rounded-full bg-[var(--surface-ground)] grid place-items-center text-[0.65rem] font-bold shrink-0">{{ i + 1 }}</span>
|
|
<span class="truncate text-[var(--text-color)]">{{ p.nome_completo }}</span>
|
|
</div>
|
|
<span class="font-semibold text-emerald-600 shrink-0">{{ fmtBRL(p.total) }}</span>
|
|
</li>
|
|
</ol>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Toggle aside — só mobile -->
|
|
<button
|
|
class="xl:hidden flex w-full items-center justify-between gap-3 px-4 py-3 bg-[var(--surface-card,#fff)] border-b border-[var(--surface-border,#e2e8f0)] text-sm font-semibold text-[var(--text-color)] cursor-pointer sticky top-0 z-30"
|
|
@click="asideOpen = !asideOpen"
|
|
>
|
|
<div class="flex items-center gap-2 min-w-0">
|
|
<i class="pi pi-calendar-clock text-[var(--primary-color,#6366f1)]" />
|
|
<span class="truncate">Agenda · {{ labelDiaSelecionado }}</span>
|
|
<span v-if="eventosDoDia.length" class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-[var(--primary-color,#6366f1)] text-white text-xs font-bold shrink-0">{{ eventosDoDia.length }}</span>
|
|
</div>
|
|
<i class="pi transition-transform duration-200" :class="asideOpen ? 'pi-chevron-up' : 'pi-chevron-down'" />
|
|
</button>
|
|
|
|
<!-- Linha do tempo — Skeleton -->
|
|
<section v-if="loading" class="bg-[var(--surface-card)] rounded-md border border-[var(--surface-border)] p-2.5">
|
|
<div class="flex items-center justify-between mb-2.5">
|
|
<Skeleton width="12rem" height="12px" />
|
|
<Skeleton width="6rem" height="12px" />
|
|
</div>
|
|
<Skeleton width="100%" height="40px" border-radius="6px" class="mt-2.5" />
|
|
</section>
|
|
|
|
<!-- Linha do tempo -->
|
|
<section v-if="!loading" class="bg-[var(--surface-card,#fff)] rounded-md border border-[var(--surface-border,#e2e8f0)] p-2.5 shadow-[0_0_0_3px_color-mix(in_srgb,var(--primary-color)_7%,transparent)]">
|
|
<div class="flex items-center justify-between mb-2.5">
|
|
<div class="flex items-center gap-2.5">
|
|
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
|
|
<i class="pi pi-chart-bar text-lg" />
|
|
</div>
|
|
<div class="flex flex-col leading-tight">
|
|
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Linha do tempo — Hoje</div>
|
|
<div class="text-xs font-semibold text-[var(--text-color-secondary)] flex items-center gap-1.5">
|
|
<span class="pulse-dot w-[15px] h-[5px] rounded-full bg-red-500"></span>
|
|
Agora: {{ horaAtual }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<Button icon="pi pi-calendar" severity="secondary" outlined class="rounded-full" title="Agenda da Clínica" label="Agenda" @click="$router.push('/admin/agenda/clinica')" />
|
|
</div>
|
|
<div class="mt-2.5 relative">
|
|
<div class="flex justify-between mb-1">
|
|
<div v-for="h in hoursRange" :key="h" class="flex-1 text-left">
|
|
<span class="text-[0.80rem] text-[var(--text-color-secondary)] font-semibold">{{ h }}h</span>
|
|
</div>
|
|
</div>
|
|
<div class="relative h-10 bg-[var(--surface-ground,#f8fafc)] rounded-md overflow-visible">
|
|
<div
|
|
v-for="ev in timelineEvents"
|
|
:key="ev.id"
|
|
class="absolute top-[3px] h-[34px] rounded flex items-center px-1.5 overflow-hidden cursor-default min-w-[32px] hover:brightness-110 transition-[filter] duration-150 z-10"
|
|
:style="ev.style"
|
|
:class="{
|
|
'bg-sky-400': ev.tipo === 'reuniao',
|
|
'bg-green-500': ev.status === 'realizado',
|
|
'bg-[var(--primary-color,#6366f1)]': ev.tipo !== 'reuniao' && ev.status !== 'realizado'
|
|
}"
|
|
:title="ev.tooltip"
|
|
>
|
|
<span class="text-[0.58rem] font-bold text-white truncate">{{ ev.label }}</span>
|
|
<span v-if="ev.badge" class="text-xs ml-auto">{{ ev.badge }}</span>
|
|
</div>
|
|
<div class="absolute top-0 h-full flex flex-col items-center pointer-events-none z-20" :style="{ left: nowCursorLeft }">
|
|
<div class="w-0.5 h-full bg-red-500 opacity-80" />
|
|
<div class="absolute -top-0.5 w-[7px] h-[7px] rounded-full bg-red-500" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Cards de notificação -->
|
|
<section class="grid grid-cols-1 lg:grid-cols-2 gap-3.5">
|
|
<!-- Skeleton -->
|
|
<template v-if="loading">
|
|
<div v-for="n in 4" :key="n" class="flex flex-col bg-[var(--surface-card,#fff)] rounded-md border border-[var(--surface-border,#e2e8f0)]">
|
|
<div class="flex items-center gap-2.5 px-3.5 pt-3 pb-2 border-b border-[var(--surface-border,#f1f5f9)] bg-[var(--surface-ground,#f8fafc)]">
|
|
<Skeleton width="32px" height="32px" border-radius="6px" />
|
|
<div class="flex flex-col gap-1.5 flex-1">
|
|
<Skeleton :width="n % 2 === 0 ? '9rem' : '7rem'" height="12px" />
|
|
<Skeleton :width="n % 3 === 0 ? '13rem' : '10rem'" height="10px" />
|
|
</div>
|
|
</div>
|
|
<div class="flex-1 flex flex-col gap-2 px-3.5 py-3 min-h-[72px]">
|
|
<div v-for="i in 2" :key="i" class="flex items-center gap-2">
|
|
<Skeleton shape="circle" size="26px" />
|
|
<div class="flex flex-col gap-1 flex-1">
|
|
<Skeleton :width="i === 1 ? '65%' : '50%'" height="10px" />
|
|
<Skeleton width="75%" height="9px" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="px-3.5 py-2 border-t border-[var(--surface-border,#f1f5f9)]">
|
|
<Skeleton width="5rem" height="10px" />
|
|
</div>
|
|
</div>
|
|
<div class="lg:col-span-2">
|
|
<AppLoadingPhrases action="Carregando o painel da clínica..." containerClass="py-8" />
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Agendamentos Recebidos -->
|
|
<div v-if="!loading" class="dash-card rounded-md">
|
|
<div class="dash-card__head gap-2.5 p-2.5">
|
|
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
|
|
<i class="pi pi-inbox text-lg" />
|
|
</div>
|
|
<div>
|
|
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Agendamentos Recebidos</div>
|
|
<div class="dash-card__sub">Solicitações vindas do agendador online</div>
|
|
</div>
|
|
<span
|
|
v-if="solicitacoesPendentes > 0"
|
|
class="dash-card__badge"
|
|
style="background: color-mix(in srgb, #ef4444 10%, transparent); color: #ef4444; border: 1px solid color-mix(in srgb, #ef4444 30%, transparent)"
|
|
>{{ solicitacoesPendentes }}</span>
|
|
</div>
|
|
<div class="dash-card__body">
|
|
<div v-for="sol in solicitacoes" :key="sol.id" class="dash-item bg-[color-mix(in_srgb,var(--surface-ground)_50%,transparent)] p-2 gap-2 rounded-md">
|
|
<Avatar :label="sol.initials" shape="square" size="normal" />
|
|
<div class="min-w-0 flex-1">
|
|
<div class="dash-item__name">{{ sol.nome }}</div>
|
|
<div class="dash-item__sub">{{ sol.detalhe }}</div>
|
|
</div>
|
|
<button
|
|
class="w-8 h-8 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] text-[var(--text-color-secondary)] flex items-center justify-center transition-all hover:bg-[var(--primary-color)] hover:text-white hover:border-[var(--primary-color)]"
|
|
@click="$router.push('/admin/agendamentos-recebidos')"
|
|
>
|
|
<i class="pi pi-arrow-right text-xs" />
|
|
</button>
|
|
</div>
|
|
<div v-if="!solicitacoes.length" class="dash-empty"><i class="pi pi-check-circle" /> Nenhuma solicitação pendente</div>
|
|
</div>
|
|
<div class="dash-card__foot" @click="$router.push('/admin/agendamentos-recebidos')">Ver todas →</div>
|
|
</div>
|
|
|
|
<!-- Analytics: tempo de 1ª resposta WhatsApp -->
|
|
<FirstResponseCard v-if="!loading" />
|
|
|
|
<!-- Cadastros externos -->
|
|
<div v-if="!loading" class="dash-card">
|
|
<div class="dash-card__head gap-2.5 p-2.5">
|
|
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0" style="background: color-mix(in srgb, #0ea5e9 15%, transparent); color: #0ea5e9">
|
|
<i class="pi pi-user-plus text-lg" />
|
|
</div>
|
|
<div>
|
|
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Cadastros Externos</div>
|
|
<div class="dash-card__sub">Pacientes que preencheram seus próprios dados</div>
|
|
</div>
|
|
<span
|
|
v-if="cadastrosPendentes > 0"
|
|
class="dash-card__badge"
|
|
style="background: color-mix(in srgb, #0ea5e9 10%, transparent); color: #0ea5e9; border: 1px solid color-mix(in srgb, #0ea5e9 30%, transparent)"
|
|
>{{ cadastrosPendentes }}</span>
|
|
</div>
|
|
<div class="dash-card__body">
|
|
<div v-for="c in cadastros" :key="c.id" class="dash-item bg-[color-mix(in_srgb,var(--surface-ground)_50%,transparent)] p-2 gap-2 rounded-md">
|
|
<Avatar :label="c.initials" shape="square" size="normal" />
|
|
<div class="min-w-0 flex-1">
|
|
<div class="dash-item__name">{{ c.nome }}</div>
|
|
<div class="dash-item__sub">{{ c.detalhe }}</div>
|
|
</div>
|
|
<button
|
|
class="w-8 h-8 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] text-[var(--text-color-secondary)] flex items-center justify-center transition-all hover:bg-[var(--primary-color)] hover:text-white hover:border-[var(--primary-color)]"
|
|
@click="$router.push('/admin/pacientes/cadastro/recebidos')"
|
|
>
|
|
<i class="pi pi-arrow-right text-xs" />
|
|
</button>
|
|
</div>
|
|
<div v-if="!cadastros.length" class="dash-empty"><i class="pi pi-check-circle" /> Nenhum cadastro pendente</div>
|
|
</div>
|
|
<div class="dash-card__foot" @click="$router.push('/admin/pacientes/cadastro/recebidos')">Gerenciar triagem →</div>
|
|
</div>
|
|
|
|
<!-- Ocupação dos terapeutas -->
|
|
<div v-if="!loading" class="dash-card">
|
|
<div class="dash-card__head gap-2.5 p-2.5">
|
|
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0" style="background: color-mix(in srgb, #8b5cf6 15%, transparent); color: #8b5cf6">
|
|
<i class="pi pi-users text-lg" />
|
|
</div>
|
|
<div>
|
|
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Ocupação dos Terapeutas</div>
|
|
<div class="dash-card__sub">Sessões este mês por profissional</div>
|
|
</div>
|
|
<span class="dash-card__badge" style="background: color-mix(in srgb, #8b5cf6 10%, transparent); color: #8b5cf6; border: 1px solid color-mix(in srgb, #8b5cf6 30%, transparent)">
|
|
{{ ocupacaoTerapeutas.length }}
|
|
</span>
|
|
</div>
|
|
<div class="dash-card__body" style="min-height: auto">
|
|
<div v-if="ocupacaoTerapeutas.length" class="flex flex-col gap-2.5 py-1">
|
|
<div v-for="t in ocupacaoTerapeutas" :key="t.id" class="flex items-center gap-2.5">
|
|
<Avatar
|
|
:label="t.initials"
|
|
shape="square"
|
|
size="small"
|
|
class="shrink-0"
|
|
:style="{ backgroundColor: t.color + '22', color: t.color }"
|
|
/>
|
|
<div class="flex-1 min-w-0">
|
|
<div class="text-xs font-semibold text-[var(--text-color)] truncate mb-1">{{ t.nome.split(' ').slice(0, 2).join(' ') }}</div>
|
|
<div class="h-1.5 rounded-full bg-[var(--surface-border)] overflow-hidden">
|
|
<div class="h-full rounded-full transition-all duration-500" :style="{ width: t.pct + '%', backgroundColor: t.color }" />
|
|
</div>
|
|
</div>
|
|
<span class="text-xs font-bold text-[var(--text-color-secondary)] shrink-0 min-w-[2rem] text-right">{{ t.sessoesHoje }}</span>
|
|
</div>
|
|
</div>
|
|
<div v-else class="dash-empty"><i class="pi pi-info-circle" /> Nenhum terapeuta com sessão hoje</div>
|
|
</div>
|
|
<div class="dash-card__foot" @click="$router.push('/admin/clinic/professionals')">Ver profissionais →</div>
|
|
</div>
|
|
|
|
<!-- Radar da semana -->
|
|
<div v-if="!loading" class="dash-card">
|
|
<div class="dash-card__head gap-2.5 p-2.5">
|
|
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0" style="background: color-mix(in srgb, #6366f1 15%, transparent); color: #6366f1">
|
|
<i class="pi pi-chart-pie text-lg" />
|
|
</div>
|
|
<div>
|
|
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Radar da Semana</div>
|
|
<div class="dash-card__sub">Sessões e faltas da clínica</div>
|
|
</div>
|
|
</div>
|
|
<div class="dash-card__body" style="min-height: auto">
|
|
<div class="flex gap-1.5 items-end h-[88px] mt-6">
|
|
<div v-for="d in radarSemana" :key="d.dia" class="flex-1 flex flex-col items-center gap-1">
|
|
<div class="flex-1 w-full flex items-end">
|
|
<div class="w-full h-[68px] bg-[var(--surface-ground)] rounded overflow-hidden flex items-end">
|
|
<div
|
|
class="w-full rounded transition-all duration-500 ease-in-out"
|
|
:style="{ height: d.pct + '%' }"
|
|
:class="{
|
|
'bg-gradient-to-t from-indigo-500 to-indigo-400': d.status === 'ok',
|
|
'bg-gradient-to-t from-red-500 to-red-300': d.status === 'falta'
|
|
}"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<span class="text-sm font-semibold" :class="d.isToday ? 'text-[var(--primary-color)] font-extrabold' : 'text-[var(--text-color-secondary)]'">{{ d.dia }}</span>
|
|
<span class="text-xs text-[var(--text-color-secondary)]">{{ d.total }}</span>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-3 mt-2 flex-wrap">
|
|
<span class="flex items-center gap-1.5 text-sm text-[var(--text-color-secondary)] font-semibold"><span class="w-4 h-2.5 rounded-sm bg-gradient-to-r from-indigo-500 to-indigo-400 shrink-0" />Sessões</span>
|
|
<span class="flex items-center gap-1.5 text-sm text-[var(--text-color-secondary)] font-semibold"><span class="w-4 h-2.5 rounded-sm bg-gradient-to-r from-red-500 to-red-300 shrink-0" />Faltas</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<LoadedPhraseBlock v-if="!loading" />
|
|
</main>
|
|
|
|
<!-- Menus de contexto -->
|
|
<Menu ref="calDayMenuRef" :model="calDayMenuItems" :popup="true" />
|
|
<Menu ref="evMenuRef" :model="evMenuItems" :popup="true" />
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.aside-drawer {
|
|
position: fixed;
|
|
top: calc(56px + var(--notice-banner-height, 0px));
|
|
left: 0;
|
|
height: calc(100dvh - 56px - var(--notice-banner-height, 0px));
|
|
width: min(300px, 85vw);
|
|
z-index: 40;
|
|
overflow-y: auto;
|
|
transition:
|
|
transform 0.25s cubic-bezier(0.4, 0, 0.2, 1),
|
|
visibility 0.25s;
|
|
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.15);
|
|
}
|
|
@media (min-width: 1280px) {
|
|
.aside-drawer {
|
|
position: fixed;
|
|
top: calc(56px + var(--notice-banner-height, 0px));
|
|
height: calc(100vh - 56px - var(--notice-banner-height, 0px));
|
|
width: 272px;
|
|
transform: none;
|
|
visibility: visible;
|
|
box-shadow: none;
|
|
z-index: auto;
|
|
overflow-y: auto;
|
|
}
|
|
}
|
|
|
|
.pulse-dot {
|
|
animation: pulse-red 1.5s infinite;
|
|
}
|
|
@keyframes pulse-red {
|
|
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); }
|
|
50% { box-shadow: 0 0 0 4px rgba(239, 68, 68, 0); }
|
|
}
|
|
|
|
/* ── Aside: Eventos do dia ───────────────────────── */
|
|
.aside-ev {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.625rem;
|
|
padding: 0.5rem 0.625rem;
|
|
border-radius: 8px;
|
|
border-left: 3px solid var(--ev-color, var(--primary-color, #6366f1));
|
|
background: var(--surface-ground, #f8fafc);
|
|
cursor: pointer;
|
|
transition: background 0.12s, box-shadow 0.12s;
|
|
}
|
|
.aside-ev:hover {
|
|
background: var(--surface-hover, #f1f5f9);
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
|
}
|
|
.aside-ev--reuniao { --ev-color: #38bdf8; }
|
|
.aside-ev--realizado { --ev-color: #22c55e; }
|
|
.aside-ev--default { --ev-color: var(--primary-color, #6366f1); }
|
|
.aside-ev--skeleton { cursor: default; pointer-events: none; }
|
|
|
|
.aside-ev__time {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-end;
|
|
min-width: 36px;
|
|
flex-shrink: 0;
|
|
gap: 1px;
|
|
}
|
|
.aside-ev__hora {
|
|
font-size: 0.78rem;
|
|
font-weight: 800;
|
|
color: var(--text-color);
|
|
letter-spacing: -0.01em;
|
|
line-height: 1;
|
|
}
|
|
.aside-ev__dur {
|
|
font-size: 0.68rem;
|
|
color: var(--text-color-secondary);
|
|
line-height: 1;
|
|
}
|
|
.aside-ev__badge {
|
|
font-size: 0.68rem;
|
|
font-weight: 600;
|
|
padding: 1px 6px;
|
|
border-radius: 4px;
|
|
background: var(--surface-border, #e2e8f0);
|
|
color: var(--text-color-secondary);
|
|
line-height: 1.5;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
/* ── Aside: Terapeutas ───────────────────────────── */
|
|
.aside-rec {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.625rem;
|
|
padding: 0.45rem 0.5rem;
|
|
border-radius: 8px;
|
|
border: 1px solid transparent;
|
|
cursor: default;
|
|
transition: background 0.12s, border-color 0.12s;
|
|
}
|
|
.aside-rec:hover {
|
|
background: var(--surface-ground, #f8fafc);
|
|
border-color: var(--surface-border, #e2e8f0);
|
|
}
|
|
.aside-rec--skeleton { cursor: default; pointer-events: none; }
|
|
.aside-rec__prox {
|
|
font-size: 0.72rem;
|
|
white-space: nowrap;
|
|
flex-shrink: 0;
|
|
padding: 2px 7px;
|
|
border-radius: 999px;
|
|
background: color-mix(in srgb, currentColor 10%, transparent);
|
|
}
|
|
|
|
/* ── Cards do dashboard ──────────────────────────── */
|
|
.dash-card {
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: var(--surface-card, #fff);
|
|
border: 1px solid var(--surface-border, #e2e8f0);
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
}
|
|
.dash-card__head {
|
|
display: flex;
|
|
align-items: center;
|
|
border-bottom: 1px solid var(--surface-border, #f1f5f9);
|
|
background: var(--surface-ground, #f8fafc);
|
|
}
|
|
.dash-card__sub {
|
|
font-size: 0.75rem;
|
|
color: var(--text-color-secondary);
|
|
}
|
|
.dash-card__badge {
|
|
margin-left: auto;
|
|
font-size: 0.78rem;
|
|
font-weight: 700;
|
|
padding: 2px 8px;
|
|
border-radius: 999px;
|
|
flex-shrink: 0;
|
|
}
|
|
.dash-card__body {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
padding: 0.75rem 0.875rem;
|
|
min-height: 72px;
|
|
}
|
|
.dash-card__foot {
|
|
padding: 0.5rem 0.875rem;
|
|
border-top: 1px solid var(--surface-border, #f1f5f9);
|
|
font-size: 0.78rem;
|
|
font-weight: 600;
|
|
color: var(--primary-color, #6366f1);
|
|
cursor: pointer;
|
|
transition: background 0.12s;
|
|
}
|
|
.dash-card__foot:hover {
|
|
background: var(--surface-ground, #f8fafc);
|
|
}
|
|
|
|
.dash-item {
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
.dash-item__name {
|
|
font-size: 0.8125rem;
|
|
font-weight: 600;
|
|
color: var(--text-color);
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
.dash-item__sub {
|
|
font-size: 0.72rem;
|
|
color: var(--text-color-secondary);
|
|
}
|
|
.dash-item__actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.375rem;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.dash-empty {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.75rem 0;
|
|
color: var(--text-color-secondary);
|
|
font-size: 0.8125rem;
|
|
}
|
|
</style>
|