Agenda, Agendador, Configurações
This commit is contained in:
605
src/features/agenda/pages/AgendaRecorrenciasPage.vue
Normal file
605
src/features/agenda/pages/AgendaRecorrenciasPage.vue
Normal file
@@ -0,0 +1,605 @@
|
||||
<!-- src/features/agenda/pages/AgendaRecorrenciasPage.vue -->
|
||||
<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>
|
||||
<Toast />
|
||||
|
||||
<!-- ─── 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>
|
||||
Reference in New Issue
Block a user