Files
agenciapsilmno/src/views/pages/therapist/TherapistDashboard.vue
T

1345 lines
61 KiB
Vue

<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/views/pages/therapist/TherapistDashboard.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<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 flex-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'"
>
<!-- 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>Março 2026</span>
</div>
<!-- Skeleton -->
<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 -->
<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>
<!-- Skeleton -->
<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"
:style="ev.bgColor ? { '--ev-color': ev.bgColor } : {}"
:class="{
'aside-ev--reuniao': !ev.bgColor && ev.tipo === 'reuniao',
'aside-ev--realizado': !ev.bgColor && ev.status === 'realizado',
'aside-ev--default': !ev.bgColor && ev.tipo !== 'reuniao' && 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="flex-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.modalidade }}</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)] flex-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>
<!-- Recorrências ativas -->
<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-sync" /><span>Recorrências ativas</span>
<span class="ml-auto bg-[var(--primary-color,#6366f1)] text-white rounded-full px-1.5 text-xs font-bold">{{ recorrencias.length }}</span>
</div>
<!-- Skeleton -->
<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-[210px] overflow-y-auto pr-0.5">
<div
v-for="r in recorrencias"
:key="r.id"
class="aside-rec"
@click="openRecMenu($event, r)"
>
<Avatar :label="r.initials" shape="square" size="normal" class="flex-shrink-0" />
<div class="flex-1 min-w-0">
<span class="block text-[0.8125rem] font-semibold truncate text-[var(--text-color)]">{{ r.nome }}</span>
<span class="block text-xs text-[var(--text-color-secondary)]">{{ r.freq }}</span>
</div>
<div
class="aside-rec__prox"
:class="r.proxHoje ? 'text-[var(--primary-color,#6366f1)] font-bold' : 'text-[var(--text-color-secondary)]'"
>{{ r.proxLabel }}</div>
</div>
<div v-if="!recorrencias.length" class="flex items-center gap-2 py-3 text-[var(--text-color-secondary)] text-sm">
<i class="pi pi-info-circle" /><span>Nenhuma recorrência ativa</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">
<!-- Hero Header -->
<!-- Skeleton hero -->
<section v-if="loading" class="relative overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-2.5">
<div class="flex items-center gap-3 mb-3">
<Skeleton width="40px" height="40px" border-radius="8px" />
<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>
<div class="flex flex-wrap gap-2.5">
<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)] flex-1 min-w-[90px]">
<Skeleton width="2rem" height="20px" />
<Skeleton width="4rem" height="10px" />
</div>
</div>
</section>
<section
v-if="!loading"
class="relative overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-2.5"
:class="{ 'rounded-tl-none rounded-tr-none': heroStuck }"
>
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute w-72 h-72 -top-16 -right-12 rounded-full blur-[70px] bg-indigo-500/10" />
<div class="absolute w-80 h-80 top-2 -left-20 rounded-full blur-[70px] bg-emerald-400/[0.08]" />
</div>
<!-- Linha 1 -->
<div class="relative z-[1] flex items-center gap-4">
<div class="flex items-center gap-3 flex-1 min-w-0">
<div class="grid place-items-center w-10 h-10 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
<i class="pi pi-home text-lg" />
</div>
<div class="min-w-0">
<div class="text-[1.1rem] font-bold tracking-tight text-[var(--text-color-secondary)]">{{ 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>
<!-- Controles (desktop e mobile mesmo conteúdo, sempre visível) -->
<div class="flex items-center gap-2 flex-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>
<Divider class="hidden xl:block my-2" />
<!-- Quick stats -->
<div class="relative z-[1] mt-2">
<div class="flex flex-wrap gap-2.5">
<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-[90px] text-center xl:text-left 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-ground)]': !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">{{ s.label }}</div>
</div>
</div>
</div>
</section>
<!-- Toggle aside 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 flex-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 -->
<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>
<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">
<i class="pi pi-chart-bar w-10 h-10 rounded-md cfg-subheader__icon flex-shrink-0" />
<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>
<!-- Exemplo: badge ou ação -->
<span class="text-xs font-semibold text-[var(--text-color-secondary)]">
<Button icon="pi pi-cog" severity="secondary" outlined class="rounded-full" title="Ver sua Agenda" @click="$router.push('/therapist/agenda')" label="Agenda" />
</span>
</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, ...(ev.bgColor ? { backgroundColor: ev.bgColor, color: ev.txtColor || '#fff' } : {}) }"
:class="{
'bg-sky-400': !ev.bgColor && ev.tipo === 'reuniao',
'bg-green-500': !ev.bgColor && ev.status === 'realizado',
'bg-[var(--primary-color,#6366f1)]': !ev.bgColor && 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 dos cards -->
<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)]"
>
<!-- header -->
<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>
<!-- body -->
<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>
<!-- footer -->
<div class="px-3.5 py-2 border-t border-[var(--surface-border,#f1f5f9)]">
<Skeleton width="5rem" height="10px" />
</div>
</div>
<!-- frase durante carregamento -->
<div class="lg:col-span-2">
<AppLoadingPhrases action="Carregando seu dashboard..." containerClass="py-8" />
</div>
</template>
<!-- Agendador Online -->
<div
v-if="!loading"
id="card-agendador"
class="dash-card rounded-md"
:class="{ '': solicitacoesPendentes > 0 }"
>
<div class="dash-card__head gap-2.5 p-2.5">
<i class="pi pi-inbox w-10 h-10 rounded-md cfg-subheader__icon" />
<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>
<div class="dash-item__actions">
<button class="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-green-100 text-green-700 text-sm font-bold hover:bg-green-200 transition-colors" @click="aceitarSol(sol.id)">
<i class="pi pi-check text-xs" /> Aceitar
</button>
<button class="px-2.5 py-1.5 rounded-full bg-red-50 text-red-500 hover:bg-red-100 transition-colors" @click="recusarSol(sol.id)">
<i class="pi pi-times text-xs" />
</button>
</div>
</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('/therapist/agendamentos-recebidos')">
Ver todas
</div>
</div>
<!-- Cadastros externos -->
<div v-if="!loading" id="card-cadastros" class="dash-card">
<div class="dash-card__head gap-2.5 p-2.5">
<i class="pi pi-user-plus w-10 h-10 rounded-md cfg-subheader__icon" style="background:color-mix(in srgb,#0ea5e9 15%,transparent);color:#0ea5e9" />
<div>
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Cadastros Recebidos (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('/therapist/pacientes/triagem/' + c.id)"
>
<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('/therapist/patients/cadastro/recebidos')">
Gerenciar triagem
</div>
</div>
<!-- Recorrências com alerta -->
<div v-if="!loading" id="card-recorrencias" class="dash-card">
<div class="dash-card__head gap-2.5 p-2.5">
<i class="pi pi-refresh w-10 h-10 rounded-md cfg-subheader__icon" style="background:color-mix(in srgb,#f59e0b 15%,transparent);color:#f59e0b" />
<div>
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Recorrências</div>
<div class="dash-card__sub">Atenção necessária</div>
</div>
<span v-if="recAlerta.length" class="dash-card__badge" style="background:color-mix(in srgb,#f59e0b 10%,transparent);color:#f59e0b;border:1px solid color-mix(in srgb,#f59e0b 30%,transparent)">{{ recAlerta.length }}</span>
</div>
<div class="dash-card__body">
<div v-for="r in recAlerta" :key="r.id" class="dash-item bg-[color-mix(in_srgb,var(--surface-ground)_50%,transparent)] p-2 gap-2 rounded-md">
<div class="min-w-0 flex-1">
<div class="dash-item__name">{{ r.nome }}</div>
<div class="dash-item__sub" :class="{ 'text-red-500': r.tipo === 'limite', 'text-amber-500': r.tipo === 'conflito', 'text-sky-500': r.tipo === 'feriado' }">
{{ r.motivo }}
</div>
</div>
<div v-if="r.progresso !== undefined" class="flex items-center gap-2 shrink-0">
<div class="w-14 h-1.5 rounded-full bg-[var(--surface-border)] overflow-hidden">
<div class="h-full rounded-full transition-all duration-300" :style="{ width: r.progresso + '%' }" :class="r.progresso > 75 ? 'bg-red-500' : 'bg-[var(--primary-color)]'" />
</div>
<span class="text-sm text-[var(--text-color-secondary)] whitespace-nowrap">{{ r.sessoesUsadas }}/{{ r.totalSessoes }}</span>
</div>
</div>
<div v-if="!recAlerta.length" class="dash-empty">
<i class="pi pi-check-circle" /> Recorrências em dia
</div>
</div>
<div class="dash-card__foot" @click="$router.push('/therapist/agenda/recorrencias')">
Ver recorrências
</div>
</div>
<!-- Radar da semana -->
<div v-if="!loading" id="card-radar" class="dash-card">
<div class="dash-card__head gap-2.5 p-2.5">
<i class="pi pi-chart-pie w-10 h-10 rounded-md cfg-subheader__icon" style="background:color-mix(in srgb,#6366f1 15%,transparent);color:#6366f1" />
<div>
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Radar da Semana</div>
<div class="dash-card__sub">Presença, faltas e reposições</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',
'bg-gradient-to-t from-amber-500 to-amber-300': d.status === 'repo',
}"
/>
</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 flex-shrink-0" />Presentes
</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 flex-shrink-0" />Faltas
</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-amber-500 to-amber-300 flex-shrink-0" />Reposição
</span>
</div>
</div>
</div>
</section>
<!-- Compromissos especiais -->
<section v-if="loading" class="bg-[var(--surface-card)] rounded-md border border-[var(--surface-border)] px-[1.125rem] py-3.5">
<div class="flex items-center justify-between mb-2.5">
<Skeleton width="10rem" height="12px" />
<Skeleton width="5rem" height="12px" />
</div>
<div class="flex flex-col gap-1.5">
<div v-for="n in 3" :key="n" class="flex items-center gap-2.5 px-2.5 py-2 rounded-md bg-[var(--surface-ground)]">
<Skeleton width="3px" height="28px" border-radius="4px" />
<div class="flex flex-col gap-1 flex-1">
<Skeleton :width="n === 1 ? '12rem' : n === 2 ? '9rem' : '11rem'" height="11px" />
<Skeleton width="6rem" height="10px" />
</div>
<div class="flex flex-col items-end gap-1">
<Skeleton width="4rem" height="11px" />
<Skeleton width="5rem" height="10px" />
</div>
</div>
</div>
</section>
<section v-if="!loading" class="bg-[var(--surface-card,#fff)] rounded-md border border-[var(--surface-border,#e2e8f0)] p-2.5">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5 text-xs font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)]">
<i class="pi pi-briefcase" /> Compromissos especiais (Em breve)
</div>
<span
v-tooltip.top="{
value: 'Em breve!',
showDelay: 0,
hideDelay: 100
}"
>
<button
class="flex items-center gap-1 bg-transparent border-none cursor-pointer text-xs font-semibold text-[var(--primary-color,#6366f1)] p-0"
disabled
>
Ver todos <i class="pi pi-arrow-right" />
</button>
</span>
</div>
<div class="flex flex-col gap-1.5">
<div v-for="c in commitments" :key="c.id" class="flex items-center gap-2.5 px-2.5 py-2 rounded-md bg-[var(--surface-ground,#f8fafc)] hover:bg-[var(--surface-hover,#f1f5f9)] transition-colors duration-100">
<div
class="w-[3px] h-7 rounded-sm flex-shrink-0"
:class="{
'bg-indigo-500': c.cor === 'blue',
'bg-purple-500': c.cor === 'purple',
'bg-green-500': c.cor === 'green',
'bg-orange-500': c.cor === 'orange',
'bg-red-500': c.cor === 'red',
}"
/>
<div class="flex-1">
<span class="block text-xs font-semibold text-[var(--text-color)]">{{ c.titulo }}</span>
<span class="block text-xs text-[var(--text-color-secondary)]">{{ c.sub }}</span>
</div>
<div class="text-right">
<span class="block text-xs font-semibold text-[var(--text-color)]">{{ c.data }}</span>
<span
class="inline-block text-xs font-bold px-1 py-px rounded mt-0.5"
:class="{
'bg-blue-50 text-blue-500': c.status === 'agendado',
'bg-green-50 text-green-600': c.status === 'confirmado',
'bg-amber-50 text-amber-600': c.status === 'pendente',
}"
>{{ c.statusLabel }}</span>
</div>
</div>
</div>
</section>
<LoadedPhraseBlock v-if="!loading" />
</main>
<!-- Menus de contexto (fora do aside para evitar visibility:hidden) -->
<Menu ref="calDayMenuRef" :model="calDayMenuItems" :popup="true" />
<Menu ref="evMenuRef" :model="evMenuItems" :popup="true" />
<Menu ref="recMenuRef" :model="recMenuItems" :popup="true" />
<!-- Dialog: Novo Compromisso (aberto pelo menu de contexto do mini calendário) -->
<AgendaEventDialog
v-if="agendaDialogOpen"
v-model="agendaDialogOpen"
:eventRow="agendaDialogEventRow"
:initialStartISO="agendaDialogStartISO"
:initialEndISO="agendaDialogEndISO"
:ownerId="ownerId"
:tenantId="clinicTenantId"
:commitmentOptions="commitmentOptionsNormalized"
newPatientRoute="/therapist/patients/cadastro"
@save="onAgendaDialogSave"
@delete="() => { agendaDialogOpen = false; load() }"
/>
<!-- Dialog: Prontuário do paciente -->
<PatientProntuario
:key="selectedPatient?.id || 'none'"
v-model="prontuarioOpen"
:patient="selectedPatient"
@close="closeProntuario"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import Menu from 'primevue/menu'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
import { useAgendaEvents } from '@/features/agenda/composables/useAgendaEvents'
import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.vue'
import { useDeterminedCommitments } from '@/features/agenda/composables/useDeterminedCommitments'
import PatientProntuario from '@/features/patients/prontuario/PatientProntuario.vue'
const dashHeroSentinelRef = ref(null)
const heroStuck = ref(false)
let _heroObserver = null
const agora = ref(new Date())
const asideOpen = ref(false)
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()
const toast = useToast()
const { create: createEvento, update: updateEvento } = useAgendaEvents()
// ── Prontuário ────────────────────────────────────────────────
const prontuarioOpen = ref(false)
const selectedPatient = ref(null)
function openProntuario (patientId, patientNome) {
if (!patientId) return
selectedPatient.value = { id: patientId, nome_completo: patientNome || '' }
prontuarioOpen.value = true
}
function closeProntuario () { prontuarioOpen.value = false; selectedPatient.value = null }
// ── Tipos de compromisso (para o dialog) ─────────────────────
const clinicTenantId = computed(() => tenantStore.activeTenantId || tenantStore.tenantId || null)
const { rows: determinedCommitments, load: loadCommitments } = useDeterminedCommitments(clinicTenantId)
const COMMITMENT_PRIORITY = new Map([
['session', 0], ['class', 1], ['study', 2],
['reading', 3], ['supervision', 4], ['content_creation', 5],
])
const commitmentOptionsNormalized = computed(() => {
const list = Array.isArray(determinedCommitments.value) ? determinedCommitments.value : []
return [...list]
.filter(i => i?.id && i?.active !== false)
.sort((a, b) => {
const pa = COMMITMENT_PRIORITY.get(a.native_key) ?? 99
const pb = COMMITMENT_PRIORITY.get(b.native_key) ?? 99
if (pa !== pb) return pa - pb
return String(a.name || '').localeCompare(String(b.name || ''), 'pt-BR')
})
.map(i => ({
id: i.id,
tenant_id: i.tenant_id ?? null,
created_by: i.created_by ?? null,
name: String(i.name || '').trim() || 'Sem nome',
description: i.description || '',
native_key: i.native_key || null,
is_native: !!i.is_native,
is_locked: !!i.is_locked,
active: i.active !== false,
bg_color: i.bg_color || null,
text_color: i.text_color || null,
fields: Array.isArray(i.determined_commitment_fields)
? [...i.determined_commitment_fields].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
: [],
}))
})
// ── Mini calendário: menu de contexto ────────────────────────
const calDayMenuRef = ref(null)
const calDayMenuItems = computed(() => [
{
label: 'Opções do dia',
items: [
{
label: 'Novo Compromisso',
icon: 'pi pi-plus-circle',
command: () => openNovoCompromisso(),
},
{
label: 'Ver dia na agenda',
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(`/therapist/agenda?date=${anoAtual}-${m}-${d}`)
}
// ── Menu de contexto: Eventos do dia ─────────────────────────
const evMenuRef = ref(null)
const _evAtivo = ref(null) // evento clicado
const evMenuItems = computed(() => [
{
label: 'Opções',
items: [
{
label: 'Ver prontuário',
icon: 'pi pi-file-edit',
disabled: !_evAtivo.value?.patientId,
command: () => openProntuario(_evAtivo.value?.patientId, _evAtivo.value?.nome),
},
{
label: 'Ver na agenda',
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(`/therapist/agenda?date=${ds}`)
},
},
],
},
])
function openEvMenu (event, ev) {
_evAtivo.value = ev
evMenuRef.value?.toggle(event)
}
// ── Menu de contexto: Recorrências ativas ────────────────────
const recMenuRef = ref(null)
const _recAtivo = ref(null) // recorrência clicada
const recMenuItems = computed(() => [
{
label: 'Opções',
items: [
{
label: 'Ver prontuário',
icon: 'pi pi-file-edit',
disabled: !_recAtivo.value?.patientId,
command: () => openProntuario(_recAtivo.value?.patientId, _recAtivo.value?.nome),
},
{
label: 'Ver na agenda',
icon: 'pi pi-calendar',
command: () => router.push('/therapist/agenda'),
},
],
},
])
function openRecMenu (event, r) {
_recAtivo.value = r
recMenuRef.value?.toggle(event)
}
// ── Dialog: Novo Compromisso ──────────────────────────────────
const agendaDialogOpen = ref(false)
const agendaDialogEventRow = ref(null)
const agendaDialogStartISO = ref('')
const agendaDialogEndISO = ref('')
function openNovoCompromisso () {
if (!ownerId.value) return
const durMin = 50
const now = new Date()
const base = new Date(anoAtual, mesAtual, selectedDay.value, now.getHours(), now.getMinutes(), 0, 0)
agendaDialogEventRow.value = {
owner_id: ownerId.value,
tipo: 'sessao',
status: 'agendado',
titulo: null,
observacoes: null,
}
agendaDialogStartISO.value = base.toISOString()
agendaDialogEndISO.value = new Date(base.getTime() + durMin * 60000).toISOString()
agendaDialogOpen.value = true
}
function _isUuid (v) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(v || ''))
}
function _pickDbFields (obj) {
const allowed = [
'tenant_id', 'owner_id', 'terapeuta_id', 'patient_id',
'tipo', 'status', 'titulo', 'observacoes',
'inicio_em', 'fim_em', 'visibility_scope',
'determined_commitment_id', 'titulo_custom', 'extra_fields',
'recurrence_id', 'recurrence_date',
'price', 'insurance_plan_id', 'insurance_guide_number',
'insurance_value', 'insurance_plan_service_id',
]
const out = {}
for (const k of allowed) { if (obj[k] !== undefined) out[k] = obj[k] }
return out
}
async function onAgendaDialogSave (arg) {
try {
const isWrapped = !!arg && typeof arg === 'object' && Object.prototype.hasOwnProperty.call(arg, 'payload')
const payload = isWrapped ? arg.payload : arg
const id = isWrapped ? (arg.id ?? null) : (arg?.id ?? null)
const normalized = { ...(payload || {}) }
if (!normalized.owner_id && ownerId.value) normalized.owner_id = ownerId.value
const tid = clinicTenantId.value
if (!tid) throw new Error('tenant_id não encontrado.')
normalized.tenant_id = tid
if (!normalized.visibility_scope) normalized.visibility_scope = 'public'
if (!normalized.status) normalized.status = 'agendado'
if (!normalized.tipo) normalized.tipo = 'sessao'
if (!String(normalized.titulo || '').trim()) normalized.titulo = normalized.tipo === 'bloqueio' ? 'Ocupado' : 'Sessão'
if (!_isUuid(normalized.paciente_id)) normalized.paciente_id = null
if (normalized.determined_commitment_id && !_isUuid(normalized.determined_commitment_id)) normalized.determined_commitment_id = null
const dbPayload = _pickDbFields(normalized)
if (id) {
await updateEvento(id, dbPayload)
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Compromisso atualizado.', life: 2500 })
} else {
await createEvento(dbPayload)
toast.add({ severity: 'success', summary: 'Criado', detail: 'Compromisso criado com sucesso.', life: 2500 })
}
agendaDialogOpen.value = false
await load()
} catch (e) {
const msg = String(e?.message || '')
const isOverlap =
e?.code === '23P01' ||
msg.includes('agenda_eventos_sem_sobreposicao') ||
msg.includes('exclusion constraint')
if (isOverlap) {
toast.add({ severity: 'warn', summary: 'Conflito de horário', detail: 'Já existe um compromisso neste horário.', life: 4000 })
} else {
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: msg || 'Tente novamente.', life: 4000 })
}
}
}
const loading = ref(true)
const ownerId = ref(null)
const eventosDoMes = ref([])
const regraRecorrencias = ref([])
const _solicitacoesBruto = ref([])
const _cadastrosBruto = 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 >= 5 ? 'urg-alta' : c >= 3 ? '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',
}
const commitmentColorMap = computed(() =>
new Map(
commitmentOptionsNormalized.value
.filter(c => c.id && c.bg_color)
.map(c => [c.id, { bg_color: c.bg_color, text_color: c.text_color }])
)
)
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')
const joinColor = ev.determined_commitments
const mapColor = ev.determined_commitment_id ? commitmentColorMap.value.get(ev.determined_commitment_id) : null
const bgColor = joinColor?.bg_color ? `#${joinColor.bg_color}` : mapColor?.bg_color ? `#${mapColor.bg_color}` : null
const txtColor = joinColor?.text_color ? `#${joinColor.text_color}` : mapColor?.text_color ? `#${mapColor.text_color}` : null
return {
id: ev.id, hora: `${h}:${m}`, dur: `${durMin}min`,
nome: ev.patients?.nome_completo || ev.titulo || ev.titulo_custom || '—',
modalidade: ev.modalidade || 'Presencial', recorrente: !!ev.recurrence_id,
status: ev.status || 'agendado', statusIcon: STATUS_ICON[ev.status] || 'pi pi-clock',
tipo: ev.tipo || 'sessao',
patientId: ev.patient_id || null,
inicioISO: ev.inicio_em || null,
bgColor, txtColor,
}
}
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)
)
const FREQ_LABEL = { weekly: 'Semanal', biweekly: 'Quinzenal', monthly: 'Mensal', custom_weekdays: 'Personalizado' }
const DIAS_PT = ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb']
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()
}
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]
}
function nextOccurrenceLabel (rule) {
const todayDow = new Date().getDay()
const weekdays = Array.isArray(rule.weekdays) ? rule.weekdays : []
if (!weekdays.length) return '—'
if (rule.type === 'weekly' || rule.type === 'custom_weekdays') {
if (weekdays.includes(todayDow)) return 'Hoje'
let minDiff = 8
for (const w of weekdays) { let diff = w - todayDow; if (diff <= 0) diff += 7; if (diff < minDiff) minDiff = diff }
if (minDiff === 1) return 'Amanhã'
if (minDiff < 8) return `Em ${minDiff} dias`
}
if (rule.type === 'biweekly') {
if (weekdays.includes(todayDow)) return 'Hoje'
if (weekdays.includes((todayDow + 1) % 7)) return 'Amanhã'
}
return '—'
}
const recorrencias = computed(() =>
regraRecorrencias.value.slice(0, 8).map(r => {
const nome = r._patientNome || '—'
const parts = nome.split(' ')
const nomeAb = parts[0] + (parts[1] ? ' ' + parts[1][0] + '.' : '')
const freq = FREQ_LABEL[r.type] || r.type || '—'
const weekdays = Array.isArray(r.weekdays) ? r.weekdays : []
const diaLabel = weekdays.map(d => DIAS_PT[d]).join(', ')
const hora = r.start_time ? String(r.start_time).slice(0, 5) : ''
const proxLabel = nextOccurrenceLabel(r)
return { id: r.id, nome: nomeAb, freq: `${freq} · ${diaLabel}${hora ? ' ' + hora : ''}`, proxLabel, proxHoje: proxLabel === 'Hoje', color: hashColor(r.patient_id || r.id), initials: initials(nome), patientId: r.patient_id || null }
})
)
// ── Derivados de eventosDoMes — single pass ───────────────────
// Um único computed varre o array uma vez e extrai tudo,
// evitando N loops separados que re-executam a cada reatividade.
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)
const daqui30 = new Date(now); daqui30.setDate(now.getDate() + 30)
let hojeCnt = 0, semanaCnt = 0, realizadosCnt = 0, encerradosCnt = 0
const hojeLista = [], timelineLista = []
const diasSemanaMap = [[], [], [], [], [], [], []]
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 (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 }
})
const eventosHoje = computed(() => _statsDoMes.value.hojeLista)
const eventosSemana = computed(() => ({ length: _statsDoMes.value.semanaCnt }))
const taxaPresenca = computed(() => _statsDoMes.value.taxaPresenca)
const quickStats = computed(() => {
const pendentes = _solicitacoesBruto.value.length + _cadastrosBruto.value.length
const pct = taxaPresenca.value
return [
{ value: String(_statsDoMes.value.hojeCnt), label: 'Hoje', cls: '' },
{ value: String(pendentes), label: 'Pendentes', cls: pendentes > 0 ? 'qs-urgente' : '' },
{ value: String(_statsDoMes.value.semanaCnt), label: 'Semana', cls: '' },
{ 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 parts = []
if (sessoes === 1) parts.push('1 sessão hoje')
else if (sessoes > 1) parts.push(`${sessoes} sessões hoje`)
else parts.push('Nenhuma sessão hoje')
if (sols > 0) parts.push(`${sols} solicitaç${sols === 1 ? 'ão' : 'ões'} aguarda${sols === 1 ? '' : 'm'} sua atenção`)
return parts.join(' · ')
})
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)
async function aceitarSol (id) {
try { await supabase.from('agendador_solicitacoes').update({ status: 'autorizado' }).eq('id', id); _solicitacoesBruto.value = _solicitacoesBruto.value.filter(s => s.id !== id) } catch (e) { console.error('[TherapistDashboard] aceitarSol:', e) }
}
async function recusarSol (id) {
try { await supabase.from('agendador_solicitacoes').update({ status: 'recusado' }).eq('id', id); _solicitacoesBruto.value = _solicitacoesBruto.value.filter(s => s.id !== id) } catch (e) { console.error('[TherapistDashboard] recusarSol:', e) }
}
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)
const recAlerta = computed(() => {
const now = new Date(), daqui30 = new Date(); daqui30.setDate(now.getDate() + 30)
const alerts = []
for (const r of regraRecorrencias.value) {
const nome = (r._patientNome || '—').split(' ').slice(0, 2).join(' ')
if (r.end_date) { const ed = new Date(r.end_date + 'T00:00:00'); if (ed >= now && ed <= daqui30) alerts.push({ id: r.id + '_end', nome, motivo: 'Encerramento próximo', tipo: 'feriado' }) }
if (r.max_occurrences && r._sessionsCount !== undefined) {
const pct = (r._sessionsCount / r.max_occurrences) * 100
if (pct > 75) alerts.push({ id: r.id + '_limit', nome, motivo: 'Limite próximo', tipo: 'limite', sessoesUsadas: r._sessionsCount, totalSessoes: r.max_occurrences, progresso: Math.round(pct) })
}
}
return alerts.slice(0, 5)
})
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
const reposicao = evs.filter(ev => ['reposicao','reposição'].includes(ev.status)).length
let status = 'ok'
if (faltas > 0 && faltas >= presentes) status = 'falta'
else if (reposicao > 0 && reposicao > presentes) status = 'repo'
return { dia, total, pct: total === 0 ? 0 : Math.min(Math.round((total / 6) * 100), 100), status, isToday: i === dow }
})
})
const CHIP_CORES = ['blue', 'purple', 'green', 'orange', 'red']
const STATUS_LABEL_MAP = { agendado: 'Agendado', confirmado: 'Confirmado', cancelado: 'Cancelado', realizado: 'Realizado', pendente: 'Pendente' }
const commitments = computed(() => {
const now = new Date()
return eventosDoMes.value
.filter(ev => ev.tipo === 'bloqueio' && ev.inicio_em && new Date(ev.inicio_em) >= now && (ev.titulo || ev.titulo_custom))
.sort((a, b) => new Date(a.inicio_em) - new Date(b.inicio_em))
.slice(0, 5)
.map((ev, i) => { const d = new Date(ev.inicio_em); return { id: ev.id, titulo: ev.titulo || ev.titulo_custom, sub: ev.modalidade || '—', data: d.toLocaleDateString('pt-BR'), cor: CHIP_CORES[i % CHIP_CORES.length], status: ev.status || 'agendado', statusLabel: STATUS_LABEL_MAP[ev.status] || 'Agendado' } })
})
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.modalidade}`, badge: item.modalidade?.toLowerCase() === 'online' ? '📱' : '', bgColor: item.bgColor, txtColor: item.txtColor, 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) + '%'
})
async function load () {
loading.value = true
const { data: authData } = await supabase.auth.getUser()
ownerId.value = authData?.user?.id || null
if (!ownerId.value) { loading.value = false; return }
await tenantStore.ensureLoaded()
const tid = tenantStore.activeTenantId || tenantStore.tenantId || null
await loadCommitments()
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, recRes, solRes, cadRes] = await Promise.all([
(() => { let q = supabase.from('agenda_eventos').select('id, inicio_em, fim_em, status, modalidade, tipo, titulo, titulo_custom, patient_id, recurrence_id, determined_commitment_id, patients(nome_completo), determined_commitments(bg_color, text_color)').eq('owner_id', ownerId.value).gte('inicio_em', mesInicio).lte('inicio_em', mesFim).order('inicio_em', { ascending: true }); if (tid) q = q.eq('tenant_id', tid); return q })(),
(() => { let q = supabase.from('recurrence_rules').select('id, patient_id, type, interval, weekdays, start_date, end_date, max_occurrences, start_time').eq('owner_id', ownerId.value).eq('status', 'ativo').order('start_date', { ascending: false }); if (tid) q = q.eq('tenant_id', tid); return q })(),
supabase.from('agendador_solicitacoes').select('id, paciente_nome, paciente_sobrenome, tipo, modalidade, data_solicitada, hora_solicitada').eq('owner_id', ownerId.value).eq('status', 'pendente').order('created_at', { ascending: false }).limit(10),
supabase.from('patient_intake_requests').select('id, nome_completo, status, created_at').eq('owner_id', ownerId.value).eq('status', 'new').order('created_at', { ascending: false }).limit(10),
])
eventosDoMes.value = eventosRes.data || []; _solicitacoesBruto.value = solRes.data || []; _cadastrosBruto.value = cadRes.data || []
const rawRules = recRes.data || []
const patientIds = [...new Set(rawRules.map(r => r.patient_id).filter(Boolean))]
if (patientIds.length) {
const { data: pts } = await supabase.from('patients').select('id, nome_completo').in('id', patientIds)
const ptMap = Object.fromEntries((pts || []).map(p => [p.id, p.nome_completo]))
for (const r of rawRules) r._patientNome = ptMap[r.patient_id] || '—'
} else { for (const r of rawRules) r._patientNome = '—' }
const rulesComLimite = rawRules.filter(r => r.max_occurrences)
if (rulesComLimite.length) {
const { data: sessData } = await supabase.from('agenda_eventos').select('id, recurrence_id').in('recurrence_id', rulesComLimite.map(r => r.id)).in('status', ['agendado','realizado'])
const countMap = {}
for (const s of sessData || []) { if (s.recurrence_id) countMap[s.recurrence_id] = (countMap[s.recurrence_id] || 0) + 1 }
for (const r of rawRules) r._sessionsCount = countMap[r.id] || 0
}
regraRecorrencias.value = rawRules
} catch (e) { console.error('[TherapistDashboard] 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>
<style scoped>
/* Aside drawer — transição e comportamento responsivo não cobertos por Tailwind base */
.aside-drawer {
position: fixed;
top: 0; left: 0;
height: 100dvh;
width: min(300px, 85vw);
z-index: 40;
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: sticky;
top: 0;
height: calc(100vh - 4.5rem);
width: 272px;
transform: none;
visibility: visible;
box-shadow: none;
z-index: auto;
}
}
/* Pulse dot — animação não disponível sem JIT */
.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: Recorrências ativas ──────────────────── */
.aside-rec {
display: flex; align-items: center; gap: 0.625rem;
padding: 0.45rem 0.5rem;
border-radius: 8px;
border: 1px solid transparent;
cursor: pointer;
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);
}
</style>