620 lines
25 KiB
Vue
620 lines
25 KiB
Vue
<!--
|
|
|--------------------------------------------------------------------------
|
|
| Agência PSI
|
|
|--------------------------------------------------------------------------
|
|
| Criado e desenvolvido por Leonardo Nohama
|
|
|
|
|
| Tecnologia aplicada à escuta.
|
|
| Estrutura para o cuidado.
|
|
|
|
|
| Arquivo: src/features/agenda/pages/AgendaRecorrenciasPage.vue
|
|
| Data: 2026
|
|
| Local: São Carlos/SP — Brasil
|
|
|--------------------------------------------------------------------------
|
|
| © 2026 — Todos os direitos reservados
|
|
|--------------------------------------------------------------------------
|
|
-->
|
|
<script setup>
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { useRouter, useRoute } from 'vue-router'
|
|
import { supabase } from '@/lib/supabase/client'
|
|
import { useTenantStore } from '@/stores/tenantStore'
|
|
import { useAgendaClinicStaff } from '@/features/agenda/composables/useAgendaClinicStaff'
|
|
import { useToast } from 'primevue/usetoast'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const toast = useToast()
|
|
const tenantStore = useTenantStore()
|
|
|
|
const mode = computed(() => route.meta?.mode || 'therapist')
|
|
const isClinic = computed(() => mode.value === 'clinic')
|
|
const tenantId = computed(() => tenantStore.activeTenantId || tenantStore.tenantId)
|
|
|
|
// ── state ──────────────────────────────────────────────────────────────────────
|
|
const loading = ref(false)
|
|
const userId = ref(null)
|
|
const rules = ref([])
|
|
const exceptionsMap = ref({}) // ruleId → Exception[]
|
|
const sessionsMap = ref({}) // ruleId → AgendaEvento[]
|
|
const expandedId = ref(null)
|
|
|
|
const filterStatus = ref('ativo')
|
|
const filterOwner = ref(null)
|
|
|
|
const { staff, load: loadStaff } = useAgendaClinicStaff()
|
|
|
|
const staffOptions = computed(() =>
|
|
(staff.value || []).map(s => ({
|
|
label: s.full_name || s.nome || s.name || 'Profissional',
|
|
value: s.user_id
|
|
}))
|
|
)
|
|
const staffMap = computed(() => {
|
|
const m = {}
|
|
for (const s of staff.value || []) m[s.user_id] = s.full_name || s.nome || s.name || 'Profissional'
|
|
return m
|
|
})
|
|
|
|
// ── auth / init ────────────────────────────────────────────────────────────────
|
|
async function init () {
|
|
const { data } = await supabase.auth.getUser()
|
|
userId.value = data?.user?.id || null
|
|
if (isClinic.value && tenantId.value) await loadStaff(tenantId.value)
|
|
await load()
|
|
}
|
|
|
|
// ── data load ──────────────────────────────────────────────────────────────────
|
|
async function load () {
|
|
if (!userId.value) return
|
|
loading.value = true
|
|
try {
|
|
let q = supabase.from('recurrence_rules').select('*').order('start_date', { ascending: false })
|
|
|
|
if (isClinic.value) {
|
|
if (!tenantId.value) return
|
|
q = q.eq('tenant_id', tenantId.value)
|
|
if (filterOwner.value) q = q.eq('owner_id', filterOwner.value)
|
|
} else {
|
|
q = q.eq('owner_id', userId.value)
|
|
}
|
|
if (filterStatus.value !== 'all') q = q.eq('status', filterStatus.value)
|
|
|
|
const { data: rData, error: rErr } = await q
|
|
if (rErr) throw rErr
|
|
const rawRules = rData || []
|
|
|
|
// patient names
|
|
const patientIds = [...new Set(rawRules.map(r => r.patient_id).filter(Boolean))]
|
|
const patientMap = {}
|
|
if (patientIds.length) {
|
|
const { data: pts } = await supabase.from('patients').select('id, nome_completo, avatar_url').in('id', patientIds)
|
|
for (const p of pts || []) patientMap[p.id] = p
|
|
}
|
|
for (const r of rawRules) r._patient = patientMap[r.patient_id] || null
|
|
|
|
rules.value = rawRules
|
|
|
|
const ruleIds = rawRules.map(r => r.id)
|
|
if (ruleIds.length) await reloadSessions(ruleIds)
|
|
} catch (e) {
|
|
toast.add({ severity: 'warn', summary: 'Erro ao carregar', detail: e?.message, life: 3500 })
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function reloadSessions (ruleIds) {
|
|
const [exRes, sessRes] = await Promise.all([
|
|
supabase.from('recurrence_exceptions').select('*').in('recurrence_id', ruleIds).order('original_date'),
|
|
supabase.from('agenda_eventos')
|
|
.select('id, recurrence_id, recurrence_date, status, inicio_em, fim_em')
|
|
.in('recurrence_id', ruleIds).order('inicio_em')
|
|
])
|
|
const exm = {}
|
|
for (const ex of exRes.data || []) {
|
|
if (!exm[ex.recurrence_id]) exm[ex.recurrence_id] = []
|
|
exm[ex.recurrence_id].push(ex)
|
|
}
|
|
exceptionsMap.value = { ...exceptionsMap.value, ...exm, ...Object.fromEntries(ruleIds.filter(id => !exm[id]).map(id => [id, []])) }
|
|
|
|
const sm = {}
|
|
for (const s of sessRes.data || []) {
|
|
if (!sm[s.recurrence_id]) sm[s.recurrence_id] = []
|
|
sm[s.recurrence_id].push(s)
|
|
}
|
|
sessionsMap.value = { ...sessionsMap.value, ...sm, ...Object.fromEntries(ruleIds.filter(id => !sm[id]).map(id => [id, []])) }
|
|
}
|
|
|
|
// ── date generation ────────────────────────────────────────────────────────────
|
|
function generateAllDates (rule) {
|
|
const { type, interval = 1, weekdays = [], start_date, end_date, max_occurrences } = rule
|
|
if (!start_date || !Array.isArray(weekdays) || !weekdays.length) return []
|
|
const maxOcc = Math.min(max_occurrences || 500, 500)
|
|
const endLimitISO = end_date || null
|
|
const dates = []
|
|
|
|
if (type === 'custom_weekdays') {
|
|
const cursor = new Date(start_date + 'T12:00:00')
|
|
let safety = 0
|
|
while (dates.length < maxOcc && safety < 3000) {
|
|
safety++
|
|
const iso = cursor.toISOString().slice(0, 10)
|
|
if (endLimitISO && iso > endLimitISO) break
|
|
if (weekdays.includes(cursor.getDay())) dates.push(iso)
|
|
cursor.setDate(cursor.getDate() + 1)
|
|
}
|
|
} else {
|
|
// weekly / biweekly
|
|
const dow = weekdays[0]
|
|
const cursor = new Date(start_date + 'T12:00:00')
|
|
while (cursor.getDay() !== dow) cursor.setDate(cursor.getDate() + 1)
|
|
while (dates.length < maxOcc) {
|
|
const iso = cursor.toISOString().slice(0, 10)
|
|
if (endLimitISO && iso > endLimitISO) break
|
|
dates.push(iso)
|
|
cursor.setDate(cursor.getDate() + 7 * (interval || 1))
|
|
}
|
|
}
|
|
return dates
|
|
}
|
|
|
|
// ── sessions (merged) ──────────────────────────────────────────────────────────
|
|
const TODAY = new Date().toISOString().slice(0, 10)
|
|
|
|
function buildSessions (rule) {
|
|
const exByDate = {}
|
|
for (const ex of exceptionsMap.value[rule.id] || []) exByDate[ex.original_date] = ex
|
|
const sessByDate = {}
|
|
for (const s of sessionsMap.value[rule.id] || []) sessByDate[s.recurrence_date] = s
|
|
|
|
return generateAllDates(rule).map(iso => {
|
|
const real = sessByDate[iso]
|
|
const ex = exByDate[iso]
|
|
let status = 'agendado'
|
|
if (real) {
|
|
status = real.status || 'agendado'
|
|
} else if (ex) {
|
|
if (ex.type === 'cancel_session' || ex.type === 'therapist_canceled') status = 'cancelado'
|
|
else if (ex.type === 'patient_missed') status = 'faltou'
|
|
else if (ex.type === 'reschedule_session') status = 'remarcado'
|
|
}
|
|
return { date: iso, status, real_id: real?.id || null }
|
|
})
|
|
}
|
|
|
|
// ── stats ──────────────────────────────────────────────────────────────────────
|
|
const STATUS_DONE = new Set(['compareceu', 'veio', 'realizado', 'presente'])
|
|
|
|
function ruleStats (rule) {
|
|
const sessions = buildSessions(rule)
|
|
const total = sessions.length
|
|
const done = sessions.filter(s => STATUS_DONE.has(s.status)).length
|
|
const faltou = sessions.filter(s => s.status === 'faltou').length
|
|
const cancelado = sessions.filter(s => s.status === 'cancelado').length
|
|
const pendentes = sessions.filter(s => s.status === 'agendado' || s.status === 'remarcado').length
|
|
const progress = total ? Math.round((done / total) * 100) : 0
|
|
return { total, done, faltou, cancelado, pendentes, progress }
|
|
}
|
|
|
|
// ── formatters ─────────────────────────────────────────────────────────────────
|
|
const DIAS_SHORT = ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb']
|
|
|
|
function fmtDate (iso) {
|
|
if (!iso) return ''
|
|
const [y, m, d] = iso.split('-')
|
|
return `${d}/${m}/${y}`
|
|
}
|
|
function fmtRuleDesc (rule) {
|
|
const days = (rule.weekdays || []).map(d => DIAS_SHORT[d]).join(', ')
|
|
const time = rule.start_time ? rule.start_time.slice(0, 5) : ''
|
|
const freq = rule.interval > 1 ? `a cada ${rule.interval} sem.` : ''
|
|
return [days, time, freq].filter(Boolean).join(' · ')
|
|
}
|
|
function fmtPeriod (rule) {
|
|
const s = fmtDate(rule.start_date)
|
|
if (rule.end_date) return `${s} até ${fmtDate(rule.end_date)}`
|
|
if (rule.max_occurrences) return `${s} · ${rule.max_occurrences} sessões`
|
|
return `A partir de ${s}`
|
|
}
|
|
function fmtPillDate (iso) {
|
|
const [, m, d] = iso.split('-')
|
|
const dow = DIAS_SHORT[new Date(iso + 'T12:00:00').getDay()]
|
|
return `${dow} ${Number(d)}/${Number(m)}`
|
|
}
|
|
|
|
// ── status UI ──────────────────────────────────────────────────────────────────
|
|
const STATUS_OPTS = [
|
|
{ label: 'Agendado', value: 'agendado' },
|
|
{ label: 'Compareceu', value: 'compareceu' },
|
|
{ label: 'Faltou', value: 'faltou' },
|
|
{ label: 'Cancelado', value: 'cancelado' },
|
|
{ label: 'Remarcado', value: 'remarcado' },
|
|
]
|
|
|
|
const PILL_CLASS = {
|
|
agendado: 'pill--pending',
|
|
compareceu: 'pill--done',
|
|
veio: 'pill--done',
|
|
realizado: 'pill--done',
|
|
presente: 'pill--done',
|
|
faltou: 'pill--missed',
|
|
cancelado: 'pill--canceled',
|
|
remarcado: 'pill--rescheduled',
|
|
}
|
|
|
|
// ── actions ────────────────────────────────────────────────────────────────────
|
|
async function onPillStatusChange (rule, s, newStatus) {
|
|
try {
|
|
if (s.real_id) {
|
|
await supabase.from('agenda_eventos').update({ status: newStatus }).eq('id', s.real_id)
|
|
} else {
|
|
const { data: ex } = await supabase
|
|
.from('agenda_eventos').select('id')
|
|
.eq('recurrence_id', rule.id).eq('recurrence_date', s.date).maybeSingle()
|
|
if (ex?.id) {
|
|
await supabase.from('agenda_eventos').update({ status: newStatus }).eq('id', ex.id)
|
|
} else {
|
|
await supabase.from('agenda_eventos').insert({
|
|
recurrence_id: rule.id,
|
|
recurrence_date: s.date,
|
|
owner_id: rule.owner_id,
|
|
tenant_id: rule.tenant_id,
|
|
tipo: 'sessao',
|
|
status: newStatus,
|
|
inicio_em: s.date + 'T' + (rule.start_time || '00:00') + ':00',
|
|
fim_em: s.date + 'T' + (rule.end_time || '01:00') + ':00',
|
|
visibility_scope: 'public',
|
|
titulo: 'Sessão',
|
|
paciente_id: rule.patient_id || null,
|
|
patient_id: rule.patient_id || null,
|
|
})
|
|
}
|
|
}
|
|
toast.add({ severity: 'success', summary: 'Status atualizado', life: 1500 })
|
|
await reloadSessions([rule.id])
|
|
} catch (e) {
|
|
toast.add({ severity: 'warn', summary: 'Erro', detail: e?.message, life: 3500 })
|
|
}
|
|
}
|
|
|
|
async function onCancelRule (rule) {
|
|
const name = rule._patient?.nome_completo || 'paciente'
|
|
if (!confirm(`Encerrar a série de "${name}"?\n\nSessões futuras deixarão de ser geradas. Sessões passadas já registradas são mantidas.`)) return
|
|
try {
|
|
await supabase.from('recurrence_rules')
|
|
.update({ status: 'cancelado', updated_at: new Date().toISOString() })
|
|
.eq('id', rule.id)
|
|
toast.add({ severity: 'success', summary: 'Série encerrada', life: 2000 })
|
|
await load()
|
|
} catch (e) {
|
|
toast.add({ severity: 'warn', summary: 'Erro', detail: e?.message, life: 3500 })
|
|
}
|
|
}
|
|
|
|
async function onReactivateRule (rule) {
|
|
try {
|
|
await supabase.from('recurrence_rules')
|
|
.update({ status: 'ativo', updated_at: new Date().toISOString() })
|
|
.eq('id', rule.id)
|
|
toast.add({ severity: 'success', summary: 'Série reativada', life: 2000 })
|
|
await load()
|
|
} catch (e) {
|
|
toast.add({ severity: 'warn', summary: 'Erro', detail: e?.message, life: 3500 })
|
|
}
|
|
}
|
|
|
|
function toggleExpand (ruleId) {
|
|
expandedId.value = expandedId.value === ruleId ? null : ruleId
|
|
}
|
|
|
|
// ── navigation ─────────────────────────────────────────────────────────────────
|
|
function goBack () {
|
|
if (isClinic.value) router.push({ name: 'admin-agenda-clinica' })
|
|
else router.push({ name: 'therapist-agenda' })
|
|
}
|
|
|
|
onMounted(init)
|
|
</script>
|
|
|
|
<template>
|
|
|
|
<!-- ─── Header ─────────────────────────────────────────────────── -->
|
|
<div class="rr-page mx-3 md:mx-5">
|
|
<div class="rr-header">
|
|
<div class="flex items-center gap-3">
|
|
<Button icon="pi pi-arrow-left" text severity="secondary" class="h-9 w-9 rounded-full shrink-0" v-tooltip.bottom="'Voltar à agenda'" @click="goBack" />
|
|
<div>
|
|
<div class="text-xl font-bold leading-tight">Recorrências</div>
|
|
<div class="text-sm opacity-55">
|
|
{{ isClinic ? 'Todas as séries da clínica' : 'Suas séries de sessões recorrentes' }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2 flex-wrap">
|
|
<!-- Status filter -->
|
|
<SelectButton
|
|
v-model="filterStatus"
|
|
:options="[
|
|
{ label: 'Ativas', value: 'ativo' },
|
|
{ label: 'Encerradas', value: 'cancelado' },
|
|
{ label: 'Todas', value: 'all' }
|
|
]"
|
|
optionLabel="label" optionValue="value" :allowEmpty="false"
|
|
@change="load"
|
|
/>
|
|
|
|
<!-- Therapist filter (clinic only) -->
|
|
<Select
|
|
v-if="isClinic && staffOptions.length"
|
|
v-model="filterOwner"
|
|
:options="[{ label: 'Todos os terapeutas', value: null }, ...staffOptions]"
|
|
optionLabel="label" optionValue="value"
|
|
placeholder="Todos os terapeutas"
|
|
class="w-[220px]"
|
|
@change="load"
|
|
/>
|
|
|
|
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" v-tooltip.bottom="'Recarregar'" @click="load" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ─── Loading ──────────────────────────────────────────────── -->
|
|
<div v-if="loading" class="flex flex-col gap-3 mt-4">
|
|
<Skeleton v-for="i in 4" :key="i" height="130px" class="rounded-2xl" />
|
|
</div>
|
|
|
|
<!-- ─── Empty ────────────────────────────────────────────────── -->
|
|
<div v-else-if="!rules.length" class="rr-empty">
|
|
<i class="pi pi-calendar-times text-5xl opacity-25" />
|
|
<div class="text-lg font-semibold opacity-50">Nenhuma série encontrada</div>
|
|
<div class="text-sm opacity-35">
|
|
{{ filterStatus === 'ativo' ? 'Crie sessões recorrentes na agenda para vê-las aqui.' : 'Altere o filtro de status.' }}
|
|
</div>
|
|
<Button label="Voltar à agenda" icon="pi pi-calendar" outlined severity="secondary" class="rounded-full mt-2" @click="goBack" />
|
|
</div>
|
|
|
|
<!-- ─── Rule cards ───────────────────────────────────────────── -->
|
|
<div v-else class="flex flex-col gap-4 mt-4 pb-8">
|
|
<div v-for="rule in rules" :key="rule.id" class="rr-card">
|
|
|
|
<!-- Card head: patient info + status badge -->
|
|
<div class="rr-card__head">
|
|
<div class="flex items-start gap-3 min-w-0 flex-1">
|
|
<Avatar
|
|
:label="(rule._patient?.nome_completo || '?')[0].toUpperCase()"
|
|
shape="circle" size="large"
|
|
class="shrink-0"
|
|
style="background:var(--primary-100,#e0e7ff);color:var(--primary-700,#3730a3);font-weight:700"
|
|
/>
|
|
<div class="min-w-0 flex-1">
|
|
<div class="font-bold text-base truncate leading-tight">
|
|
{{ rule._patient?.nome_completo || 'Paciente não encontrado' }}
|
|
</div>
|
|
<div v-if="isClinic && staffMap[rule.owner_id]" class="text-xs opacity-55 mt-0.5 truncate">
|
|
<i class="pi pi-user text-xs mr-1" />{{ staffMap[rule.owner_id] }}
|
|
</div>
|
|
<div class="text-sm opacity-65 mt-1">
|
|
<i class="pi pi-clock text-xs mr-1" />{{ fmtRuleDesc(rule) }}
|
|
</div>
|
|
<div class="text-xs opacity-45 mt-0.5">
|
|
<i class="pi pi-calendar text-xs mr-1" />{{ fmtPeriod(rule) }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<Tag
|
|
:value="rule.status === 'ativo' ? 'Ativa' : 'Encerrada'"
|
|
:severity="rule.status === 'ativo' ? 'success' : 'secondary'"
|
|
class="shrink-0 self-start"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Stats + progress -->
|
|
<template v-for="stats in [ruleStats(rule)]" :key="'stats-' + rule.id">
|
|
<div class="rr-stats-row">
|
|
<span class="rr-stat rr-stat--done">{{ stats.done }} compareceu</span>
|
|
<span v-if="stats.faltou" class="rr-stat rr-stat--missed">{{ stats.faltou }} faltou</span>
|
|
<span v-if="stats.cancelado" class="rr-stat rr-stat--canceled">{{ stats.cancelado }} cancelada{{ stats.cancelado !== 1 ? 's' : '' }}</span>
|
|
<span class="rr-stat rr-stat--pending">{{ stats.pendentes }} pendente{{ stats.pendentes !== 1 ? 's' : '' }}</span>
|
|
<span class="rr-stat rr-stat--total ml-auto">{{ stats.total }} sessões</span>
|
|
</div>
|
|
<div class="px-4 pb-1">
|
|
<ProgressBar :value="stats.progress" class="h-1.5 rounded-full" />
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Card footer: actions -->
|
|
<div class="rr-card__foot">
|
|
<Button
|
|
:icon="expandedId === rule.id ? 'pi pi-chevron-up' : 'pi pi-list'"
|
|
:label="expandedId === rule.id ? 'Ocultar sessões' : `Ver sessões (${ruleStats(rule).total})`"
|
|
severity="secondary" outlined size="small" class="rounded-full"
|
|
@click="toggleExpand(rule.id)"
|
|
/>
|
|
<div class="flex gap-2 ml-auto">
|
|
<Button
|
|
v-if="rule.status === 'ativo'"
|
|
label="Encerrar série"
|
|
icon="pi pi-times-circle"
|
|
severity="danger" text size="small" class="rounded-full"
|
|
@click="onCancelRule(rule)"
|
|
/>
|
|
<Button
|
|
v-else
|
|
label="Reativar"
|
|
icon="pi pi-undo"
|
|
severity="success" text size="small" class="rounded-full"
|
|
@click="onReactivateRule(rule)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sessions panel (expanded) -->
|
|
<div v-if="expandedId === rule.id" class="rr-sessions">
|
|
<div class="rr-sessions__grid">
|
|
<div
|
|
v-for="s in buildSessions(rule)"
|
|
:key="s.date"
|
|
class="rr-pill"
|
|
:class="[
|
|
PILL_CLASS[s.status] || 'pill--pending',
|
|
s.date < TODAY ? 'rr-pill--past' : s.date === TODAY ? 'rr-pill--today' : 'rr-pill--future'
|
|
]"
|
|
>
|
|
<div class="rr-pill__date">{{ fmtPillDate(s.date) }}</div>
|
|
<Select
|
|
:modelValue="s.status"
|
|
:options="STATUS_OPTS"
|
|
optionLabel="label" optionValue="value"
|
|
class="rr-pill__sel"
|
|
@change="e => onPillStatusChange(rule, s, e.value)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
/* ── Page ─────────────────────────────────────────────────────────── */
|
|
.rr-page {
|
|
padding-bottom: 2rem;
|
|
}
|
|
|
|
/* ── Header ───────────────────────────────────────────────────────── */
|
|
.rr-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
flex-wrap: wrap;
|
|
gap: 12px;
|
|
padding: 20px 0 16px;
|
|
border-bottom: 1px solid var(--surface-border);
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
/* ── Empty ────────────────────────────────────────────────────────── */
|
|
.rr-empty {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 64px 24px;
|
|
text-align: center;
|
|
}
|
|
|
|
/* ── Card ─────────────────────────────────────────────────────────── */
|
|
.rr-card {
|
|
border-radius: 1.25rem;
|
|
border: 1px solid var(--surface-border);
|
|
background: var(--surface-card);
|
|
overflow: hidden;
|
|
transition: box-shadow 0.15s;
|
|
}
|
|
.rr-card:hover {
|
|
box-shadow: 0 2px 16px color-mix(in srgb, var(--primary-400, #818cf8) 10%, transparent);
|
|
}
|
|
|
|
.rr-card__head {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 12px;
|
|
padding: 16px 16px 12px;
|
|
}
|
|
|
|
.rr-card__foot {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 10px 16px 14px;
|
|
border-top: 1px solid var(--surface-border);
|
|
}
|
|
|
|
/* ── Stats row ────────────────────────────────────────────────────── */
|
|
.rr-stats-row {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 4px 16px 8px;
|
|
}
|
|
|
|
.rr-stat {
|
|
font-size: 0.72rem;
|
|
font-weight: 600;
|
|
border-radius: 999px;
|
|
padding: 2px 8px;
|
|
}
|
|
.rr-stat--done { background: var(--green-100, #dcfce7); color: var(--green-700, #15803d); }
|
|
.rr-stat--missed { background: var(--red-100, #fee2e2); color: var(--red-700, #b91c1c); }
|
|
.rr-stat--canceled { background: var(--orange-100, #ffedd5); color: var(--orange-700, #c2410c); }
|
|
.rr-stat--pending { background: var(--surface-200, #e5e7eb); color: var(--text-color-secondary); }
|
|
.rr-stat--total { background: transparent; color: var(--text-color-secondary); font-weight: 400; }
|
|
|
|
/* ── Sessions panel ───────────────────────────────────────────────── */
|
|
.rr-sessions {
|
|
border-top: 1px solid var(--surface-border);
|
|
background: color-mix(in srgb, var(--surface-ground) 60%, transparent);
|
|
padding: 14px 16px;
|
|
max-height: 420px;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.rr-sessions__grid {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
}
|
|
|
|
/* ── Pill ─────────────────────────────────────────────────────────── */
|
|
.rr-pill {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
border-radius: 999px;
|
|
padding: 4px 4px 4px 10px;
|
|
border: 1px solid transparent;
|
|
min-width: 0;
|
|
}
|
|
|
|
.rr-pill--past { opacity: 0.7; }
|
|
.rr-pill--today { box-shadow: 0 0 0 2px var(--primary-400, #818cf8); opacity: 1 !important; }
|
|
.rr-pill--future { }
|
|
|
|
.pill--pending { background: var(--surface-200, #e5e7eb); border-color: var(--surface-300, #d1d5db); color: var(--text-color-secondary); }
|
|
.pill--done { background: var(--green-50, #f0fdf4); border-color: var(--green-200, #bbf7d0); color: var(--green-800, #166534); }
|
|
.pill--missed { background: var(--red-50, #fff1f2); border-color: var(--red-200, #fecaca); color: var(--red-700, #b91c1c); }
|
|
.pill--canceled { background: var(--orange-50, #fff7ed); border-color: var(--orange-200, #fed7aa); color: var(--orange-700, #c2410c); }
|
|
.pill--rescheduled{ background: var(--blue-50, #eff6ff); border-color: var(--blue-200, #bfdbfe); color: var(--blue-700, #1d4ed8); }
|
|
|
|
.rr-pill__date {
|
|
font-size: 0.72rem;
|
|
font-weight: 700;
|
|
white-space: nowrap;
|
|
letter-spacing: -0.01em;
|
|
}
|
|
|
|
.rr-pill__sel {
|
|
/* shrink the PrimeVue Select to pill size */
|
|
--p-select-padding-x: 6px;
|
|
--p-select-padding-y: 2px;
|
|
font-size: 0.7rem;
|
|
min-width: 0;
|
|
border: none;
|
|
background: transparent !important;
|
|
box-shadow: none !important;
|
|
}
|
|
:deep(.rr-pill__sel .p-select-label) {
|
|
font-size: 0.7rem;
|
|
padding: 2px 4px;
|
|
font-weight: 600;
|
|
}
|
|
:deep(.rr-pill__sel .p-select-dropdown) {
|
|
width: 1.4rem;
|
|
}
|
|
</style>
|