Agenda, Agendador, Configurações

This commit is contained in:
Leonardo
2026-03-12 08:58:36 -03:00
parent f733db8436
commit f4b185ae17
197 changed files with 33405 additions and 6507 deletions
@@ -0,0 +1,849 @@
<!-- src/features/agenda/pages/AgendamentosRecebidosPage.vue -->
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useTenantStore } from '@/stores/tenantStore'
import { supabase } from '@/lib/supabase/client'
import { useToast } from 'primevue/usetoast'
import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.vue'
import { useAgendaSettings } from '@/features/agenda/composables/useAgendaSettings'
import { useDeterminedCommitments } from '@/features/agenda/composables/useDeterminedCommitments'
import { useAgendaEvents } from '@/features/agenda/composables/useAgendaEvents'
const toast = useToast()
const router = useRouter()
const tenantStore = useTenantStore()
// ── Identidade do usuário logado ─────────────────────────────────
const isClinic = computed(() => tenantStore.role === 'clinic_admin' || tenantStore.role === 'tenant_admin')
const tenantId = computed(() => tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null)
// owner_id = auth user ID do terapeuta (não é o tenant_id)
const ownerId = ref(null)
async function loadOwnerId () {
const { data } = await supabase.auth.getUser()
ownerId.value = data?.user?.id || null
}
// ── Filtros ──────────────────────────────────────────────────────
const filtroStatus = ref('pendente')
const filtroBusca = ref('')
const statusOpts = [
{ label: 'Pendentes', value: 'pendente', icon: 'pi-clock', sev: 'warn' },
{ label: 'Autorizados', value: 'autorizado', icon: 'pi-check-circle', sev: 'success' },
{ label: 'Convertidos', value: 'convertido', icon: 'pi-calendar-plus', sev: 'info' },
{ label: 'Recusados', value: 'recusado', icon: 'pi-times-circle', sev: 'danger' },
{ label: 'Todos', value: null, icon: 'pi-list', sev: 'secondary' }
]
// ── Lista ────────────────────────────────────────────────────────
const solicitacoes = ref([])
const loading = ref(false)
const totalPendentes = ref(0)
async function load () {
if (!ownerId.value) return
loading.value = true
try {
let q = supabase
.from('agendador_solicitacoes')
.select(`
id, owner_id, tenant_id,
paciente_nome, paciente_sobrenome, paciente_email, paciente_celular, paciente_cpf,
tipo, modalidade, data_solicitada, hora_solicitada,
reservado_ate, motivo, como_conheceu,
status, created_at
`)
.order('data_solicitada', { ascending: false })
.order('hora_solicitada', { ascending: true })
if (isClinic.value) {
q = q.eq('tenant_id', tenantId.value)
} else {
q = q.eq('owner_id', ownerId.value)
}
if (filtroStatus.value) q = q.eq('status', filtroStatus.value)
const { data, error } = await q
if (error) throw error
solicitacoes.value = data || []
// Conta pendentes para badge
if (filtroStatus.value !== 'pendente') {
let qp = supabase
.from('agendador_solicitacoes')
.select('id', { count: 'exact', head: true })
.eq('status', 'pendente')
if (isClinic.value) qp = qp.eq('tenant_id', tenantId.value)
else qp = qp.eq('owner_id', ownerId.value)
const { count } = await qp
totalPendentes.value = count || 0
} else {
totalPendentes.value = solicitacoes.value.length
}
} catch (e) {
console.error('[AgendamentosRecebidos]', e)
toast.add({ severity: 'error', summary: 'Erro', detail: 'Não foi possível carregar as solicitações.', life: 4000 })
} finally {
loading.value = false
}
}
watch(filtroStatus, load)
// ── Filtro de busca local ────────────────────────────────────────
const listaFiltrada = computed(() => {
const q = filtroBusca.value.trim().toLowerCase()
if (!q) return solicitacoes.value
return solicitacoes.value.filter(s =>
`${s.paciente_nome} ${s.paciente_sobrenome}`.toLowerCase().includes(q) ||
(s.paciente_email || '').toLowerCase().includes(q) ||
(s.paciente_celular || '').includes(q)
)
})
// ── Helpers de formatação ────────────────────────────────────────
function fmtData (iso) {
if (!iso) return '—'
const [y, m, d] = iso.split('-')
const dias = ['Dom','Seg','Ter','Qua','Qui','Sex','Sáb']
const dow = new Date(+y, +m - 1, +d).getDay()
return `${dias[dow]}, ${d}/${m}/${y}`
}
function fmtHora (h) { return h ? String(h).slice(0, 5) : '—' }
function nomeCompleto (s) { return `${s.paciente_nome || ''} ${s.paciente_sobrenome || ''}`.trim() || '—' }
const tipoLabel = { primeira: 'Primeira Entrevista', retorno: 'Retorno', reagendar: 'Reagendamento' }
const modalLabel = { presencial: 'Presencial', online: 'Online', ambos: 'Ambos' }
function statusSev (st) {
return { pendente: 'warn', autorizado: 'success', recusado: 'danger', convertido: 'info', expirado: 'secondary' }[st] || 'secondary'
}
function statusLabel (st) {
return { pendente: 'Pendente', autorizado: 'Autorizado', recusado: 'Recusado', convertido: 'Convertido', expirado: 'Expirado' }[st] || st
}
function isExpirada (s) {
if (s.status !== 'pendente') return false
if (!s.reservado_ate) return false
return new Date(s.reservado_ate) < new Date()
}
// ── Detalhe / expandido ──────────────────────────────────────────
const expandedId = ref(null)
function toggleExpand (id) {
expandedId.value = expandedId.value === id ? null : id
}
// ── Aprovar ──────────────────────────────────────────────────────
const aprovando = ref(null)
async function aprovar (s) {
aprovando.value = s.id
try {
const { error } = await supabase
.from('agendador_solicitacoes')
.update({ status: 'autorizado', autorizado_em: new Date().toISOString() })
.eq('id', s.id)
if (error) throw error
toast.add({ severity: 'success', summary: 'Autorizado', detail: `Solicitação de ${nomeCompleto(s)} autorizada.`, life: 3000 })
await load()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 })
} finally {
aprovando.value = null
}
}
// ── Recusar ──────────────────────────────────────────────────────
const recusandoId = ref(null)
const recusaMotivo = ref('')
const recusaDialogOpen = ref(false)
let _recusaTarget = null
function abrirRecusa (s) {
_recusaTarget = s
recusaMotivo.value = ''
recusaDialogOpen.value = true
recusandoId.value = null
}
async function confirmarRecusa () {
const s = _recusaTarget
if (!s) return
recusandoId.value = s.id
try {
const { error } = await supabase
.from('agendador_solicitacoes')
.update({ status: 'recusado', recusado_motivo: recusaMotivo.value || null })
.eq('id', s.id)
if (error) throw error
recusaDialogOpen.value = false
toast.add({ severity: 'info', summary: 'Recusado', detail: `Solicitação de ${nomeCompleto(s)} recusada.`, life: 3000 })
await load()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 })
} finally {
recusandoId.value = null
}
}
// ── Converter em sessão ─────────────────────────────────────────
const { settings, load: loadSettings } = useAgendaSettings()
const { create: createEvento } = useAgendaEvents()
const { rows: commitmentRows, load: loadCommitments } = useDeterminedCommitments(tenantId)
const commitmentOptions = computed(() => (commitmentRows.value || []).filter(c => c.active !== false))
const sessionCommitmentId = computed(() => {
const c = commitmentOptions.value.find(c => c.native_key === 'session')
return c?.id || null
})
const eventDialogOpen = ref(false)
const eventRow = ref(null)
const convertendoId = ref(null)
let _convertTarget = null
async function converterEmSessao (s) {
_convertTarget = s
convertendoId.value = s.id
try {
// 1. Busca ou cria o paciente
const pacienteId = await encontrarOuCriarPaciente(s)
// 2. Monta o eventRow com paciente já vinculado
// inicio_em como ISO local para resetForm() calcular dia e startTime corretamente
const hora = fmtHora(s.hora_solicitada) // "HH:MM"
const inicio_em = `${s.data_solicitada}T${hora}:00`
eventRow.value = {
owner_id: s.owner_id,
tipo: 'sessao',
modalidade: s.modalidade || 'presencial',
inicio_em,
patient_id: pacienteId,
paciente_id: pacienteId, // alias para o dialog pré-preencher o nome
paciente_nome: nomeCompleto(s),
_solicitacaoId: s.id,
}
eventDialogOpen.value = true
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 })
} finally {
convertendoId.value = null
}
}
async function encontrarOuCriarPaciente (s) {
const email = s.paciente_email?.toLowerCase().trim()
// Tenta achar paciente pelo email no tenant
if (email) {
const { data: found } = await supabase
.from('patients')
.select('id, nome_completo')
.eq('tenant_id', tenantId.value)
.ilike('email_principal', email)
.maybeSingle()
if (found?.id) return found.id
}
// Não encontrou → busca o responsible_member_id do usuário logado
const { data: memberData, error: memberErr } = await supabase
.from('tenant_members')
.select('id')
.eq('tenant_id', tenantId.value)
.eq('user_id', ownerId.value)
.eq('status', 'active')
.maybeSingle()
if (memberErr || !memberData?.id) throw new Error('Membro ativo não encontrado para criação do paciente.')
// Cria o paciente com os dados da solicitação
// Se veio pelo link da clínica → scope 'clinic'; pelo link do terapeuta → scope 'therapist'
const scope = isClinic.value ? 'clinic' : 'therapist'
const nomeCompleto_ = [s.paciente_nome, s.paciente_sobrenome].filter(Boolean).join(' ')
const { data: novo, error: criErr } = await supabase
.from('patients')
.insert({
tenant_id: tenantId.value,
responsible_member_id: memberData.id,
owner_id: ownerId.value,
nome_completo: nomeCompleto_,
email_principal: email || null,
telefone: s.paciente_celular?.replace(/\D/g, '') || null,
cpf: s.paciente_cpf?.replace(/\D/g, '') || null,
onde_nos_conheceu: s.como_conheceu || null,
observacoes: s.motivo ? `Motivo da consulta: ${s.motivo}` : null,
patient_scope: scope,
therapist_member_id: scope === 'therapist' ? memberData.id : null,
status: 'Ativo',
})
.select('id')
.single()
if (criErr) throw new Error(`Falha ao criar paciente: ${criErr.message}`)
toast.add({ severity: 'info', summary: 'Paciente criado', detail: `${nomeCompleto_} foi adicionado à sua lista de pacientes.`, life: 3000 })
return novo.id
}
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 || ''))
}
async function onEventSaved (arg) {
eventDialogOpen.value = false
if (!_convertTarget) return
const target = _convertTarget
_convertTarget = null
convertendoId.value = target.id
try {
// 1. Normaliza o payload do dialog (mesmo padrão do AgendaTerapeutaPage)
const isWrapped = !!arg && Object.prototype.hasOwnProperty.call(arg, 'payload')
const raw = isWrapped ? arg.payload : arg
const normalized = { ...raw }
if (!normalized.owner_id) normalized.owner_id = ownerId.value
normalized.tenant_id = tenantId.value
normalized.tipo = 'sessao'
if (!normalized.status) normalized.status = 'agendado'
if (!String(normalized.titulo || '').trim()) normalized.titulo = 'Sessão'
if (!normalized.visibility_scope) normalized.visibility_scope = 'public'
if (!isUuid(normalized.paciente_id)) normalized.paciente_id = null
if (normalized.determined_commitment_id && !isUuid(normalized.determined_commitment_id)) {
normalized.determined_commitment_id = null
}
// 2. Salva o evento na agenda
const dbFields = [
'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','modalidade',
]
const dbPayload = {}
for (const k of dbFields) { if (normalized[k] !== undefined) dbPayload[k] = normalized[k] }
await createEvento(dbPayload)
// 3. Marca solicitação como convertida
const { error } = await supabase
.from('agendador_solicitacoes')
.update({ status: 'convertido' })
.eq('id', target.id)
if (error) throw error
toast.add({
severity: 'success',
summary: 'Convertido!',
detail: `Sessão criada para ${nomeCompleto(target)}.`,
life: 4000,
})
await load()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao converter', detail: e.message, life: 4000 })
} finally {
convertendoId.value = null
}
}
// ── agendaSettings para o dialog ────────────────────────────────
onMounted(async () => {
await loadOwnerId()
await Promise.all([loadSettings(), loadCommitments(), load()])
})
// ── Navegar para a agenda na data do agendamento ─────────────────
function irParaAgenda (s) {
const base = isClinic.value ? '/admin/agenda/clinica' : '/therapist/agenda'
router.push({ path: base, query: { date: s.data_solicitada } })
}
// ── Fechar dialog sem converter ──────────────────────────────────
function onEventDialogClose () {
eventDialogOpen.value = false
_convertTarget = null
eventRow.value = null
}
</script>
<template>
<Toast />
<!-- SENTINEL -->
<div class="ar-sentinel" />
<!-- HERO -->
<div class="ar-hero mx-3 md:mx-5 mb-4">
<!-- blobs decorativos -->
<div class="ar-blobs" aria-hidden="true">
<div class="ar-blob ar-blob--1" />
<div class="ar-blob ar-blob--2" />
<div class="ar-blob ar-blob--3" />
</div>
<!-- Linha principal -->
<div class="ar-hero__row">
<!-- Brand -->
<div class="ar-hero__brand">
<div class="ar-hero__icon">
<i class="pi pi-inbox text-lg" />
</div>
<div class="min-w-0">
<div class="ar-hero__title">
Agendamentos Recebidos
<span v-if="totalPendentes > 0" class="ar-badge-count">{{ totalPendentes }}</span>
</div>
<div class="ar-hero__sub">
{{ isClinic ? 'Toda a clínica' : 'Sua agenda online' }} · Solicitações públicas
</div>
</div>
</div>
<!-- Busca -->
<div class="ar-hero__search">
<IconField>
<InputIcon class="pi pi-search" />
<InputText
v-model="filtroBusca"
placeholder="Buscar por nome, e-mail..."
class="w-full"
autocomplete="off"
/>
</IconField>
</div>
<!-- Atualizar -->
<Button
icon="pi pi-refresh"
severity="secondary"
outlined
class="h-9 w-9 rounded-full shrink-0"
:loading="loading"
title="Atualizar"
@click="load"
/>
</div>
<!-- Chips de filtro -->
<div class="ar-status-chips">
<button
v-for="opt in statusOpts"
:key="opt.value ?? 'all'"
class="ar-chip"
:class="{ 'ar-chip--active': filtroStatus === opt.value }"
@click="filtroStatus = opt.value"
>
<i :class="`pi ${opt.icon} text-xs`" />
{{ opt.label }}
<span v-if="opt.value === 'pendente' && totalPendentes > 0" class="ar-chip-badge">
{{ totalPendentes }}
</span>
</button>
</div>
</div>
<!-- CONTEÚDO -->
<div class="mx-3 md:mx-5">
<!-- Loading skeleton -->
<div v-if="loading" class="flex flex-col gap-3">
<div v-for="n in 4" :key="n" class="ar-card ar-card--skel">
<div class="ar-skel ar-skel--avatar" />
<div class="flex flex-col gap-2 flex-1">
<div class="ar-skel ar-skel--title" />
<div class="ar-skel ar-skel--sub" />
</div>
</div>
</div>
<!-- Vazio -->
<div v-else-if="!listaFiltrada.length" class="ar-empty">
<div class="ar-empty__icon">
<i class="pi pi-inbox text-4xl" />
</div>
<div class="ar-empty__title">Nenhuma solicitação</div>
<div class="ar-empty__sub">
{{ filtroStatus ? `Não há solicitações com status "${statusLabel(filtroStatus)}".` : 'Nenhuma solicitação encontrada.' }}
</div>
</div>
<!-- Lista -->
<div v-else class="flex flex-col gap-3 pb-8">
<div
v-for="s in listaFiltrada"
:key="s.id"
class="ar-card"
:class="{ 'ar-card--expanded': expandedId === s.id, 'ar-card--expirada': isExpirada(s) }"
>
<!-- Linha principal -->
<div class="ar-card__main" @click="toggleExpand(s.id)">
<!-- Avatar inicial -->
<div class="ar-avatar">
{{ (s.paciente_nome || '?')[0].toUpperCase() }}
</div>
<!-- Dados -->
<div class="ar-card__info flex-1 min-w-0">
<div class="ar-card__name">
{{ nomeCompleto(s) }}
<Tag
:value="statusLabel(s.status)"
:severity="statusSev(s.status)"
class="ml-2 text-xs"
/>
<Tag
v-if="isExpirada(s)"
value="Reserva expirada"
severity="secondary"
class="ml-1 text-xs"
/>
</div>
<div class="ar-card__meta">
<span><i class="pi pi-calendar text-xs mr-1" />{{ fmtData(s.data_solicitada) }}</span>
<span><i class="pi pi-clock text-xs mr-1" />{{ fmtHora(s.hora_solicitada) }}</span>
<span><i class="pi pi-tag text-xs mr-1" />{{ tipoLabel[s.tipo] || s.tipo }}</span>
<span v-if="s.modalidade"><i class="pi pi-map-marker text-xs mr-1" />{{ modalLabel[s.modalidade] || s.modalidade }}</span>
</div>
</div>
<!-- Ações rápidas (pendente) -->
<div v-if="s.status === 'pendente'" class="ar-card__actions" @click.stop>
<Button
label="Aprovar"
icon="pi pi-check"
size="small"
severity="success"
class="rounded-full"
:loading="aprovando === s.id"
@click="aprovar(s)"
/>
<Button
label="Recusar"
icon="pi pi-times"
size="small"
severity="danger"
outlined
class="rounded-full"
@click="abrirRecusa(s)"
/>
<Button
label="Converter"
icon="pi pi-calendar-plus"
size="small"
severity="info"
outlined
class="rounded-full"
:loading="convertendoId === s.id"
@click="converterEmSessao(s)"
/>
</div>
<!-- Ações para autorizado (ainda pode converter) -->
<div v-else-if="s.status === 'autorizado'" class="ar-card__actions" @click.stop>
<Button
label="Converter em sessão"
icon="pi pi-calendar-plus"
size="small"
severity="info"
outlined
class="rounded-full"
:loading="convertendoId === s.id"
@click="converterEmSessao(s)"
/>
</div>
<!-- Ações para convertido: ir à agenda -->
<div v-else-if="s.status === 'convertido'" class="ar-card__actions" @click.stop>
<Button
label="Ver na agenda"
icon="pi pi-calendar"
size="small"
severity="secondary"
outlined
class="rounded-full"
@click="irParaAgenda(s)"
/>
</div>
<!-- Chevron -->
<i
class="pi ar-chevron shrink-0"
:class="expandedId === s.id ? 'pi-chevron-up' : 'pi-chevron-down'"
/>
</div>
<!-- Detalhe expandido -->
<Transition name="ar-expand">
<div v-if="expandedId === s.id" class="ar-card__detail">
<div class="ar-detail-grid">
<div class="ar-detail-item">
<span class="ar-detail-label">E-mail</span>
<span class="ar-detail-val">{{ s.paciente_email || '—' }}</span>
</div>
<div class="ar-detail-item">
<span class="ar-detail-label">Celular</span>
<span class="ar-detail-val">{{ s.paciente_celular || '—' }}</span>
</div>
<div class="ar-detail-item">
<span class="ar-detail-label">CPF</span>
<span class="ar-detail-val">{{ s.paciente_cpf || '—' }}</span>
</div>
<div class="ar-detail-item">
<span class="ar-detail-label">Solicitado em</span>
<span class="ar-detail-val">{{ s.created_at ? new Date(s.created_at).toLocaleString('pt-BR') : '—' }}</span>
</div>
<div v-if="s.motivo" class="ar-detail-item col-span-2">
<span class="ar-detail-label">Motivo</span>
<span class="ar-detail-val">{{ s.motivo }}</span>
</div>
<div v-if="s.como_conheceu" class="ar-detail-item">
<span class="ar-detail-label">Como conheceu</span>
<span class="ar-detail-val">{{ s.como_conheceu }}</span>
</div>
</div>
</div>
</Transition>
</div>
</div>
</div>
<!-- DIALOG RECUSAR -->
<Dialog
v-model:visible="recusaDialogOpen"
modal
header="Recusar solicitação"
:draggable="false"
:style="{ width: '440px', maxWidth: '96vw' }"
>
<p class="text-sm text-color-secondary mb-4">
Você pode informar o motivo da recusa. O paciente poderá visualizar isso na sua conta.
</p>
<FloatLabel variant="on">
<Textarea
id="ar-recusa-motivo"
v-model="recusaMotivo"
rows="3"
class="w-full"
autocomplete="off"
/>
<label for="ar-recusa-motivo">Motivo da recusa <span class="text-color-secondary">(opcional)</span></label>
</FloatLabel>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" @click="recusaDialogOpen = false" />
<Button
label="Confirmar recusa"
icon="pi pi-times"
severity="danger"
class="rounded-full"
:loading="!!recusandoId"
@click="confirmarRecusa"
/>
</template>
</Dialog>
<!-- AGENDA EVENT DIALOG (converter) -->
<AgendaEventDialog
v-model="eventDialogOpen"
:event-row="eventRow"
:owner-id="ownerId"
:tenant-id="tenantId"
:agenda-settings="settings"
:commitment-options="commitmentOptions"
:preset-commitment-id="sessionCommitmentId"
:restrict-patients-to-owner="!isClinic"
:patient-scope-owner-id="!isClinic ? ownerId : null"
@save="onEventSaved"
@update:modelValue="v => { if (!v) onEventDialogClose() }"
/>
</template>
<style scoped>
/* ── Sentinel ─────────────────────────────────────────────────── */
.ar-sentinel { height: 1px; }
/* ── Hero ─────────────────────────────────────────────────────── */
.ar-hero {
position: relative;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.25rem 1.5rem 1rem;
display: flex;
flex-direction: column;
gap: .875rem;
}
/* blobs */
.ar-blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.ar-blob { position: absolute; border-radius: 50%; filter: blur(65px); }
.ar-blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(99,102,241,.10); }
.ar-blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(52,211,153,.08); }
.ar-blob--3 { width: 14rem; height: 14rem; bottom: -2rem; right: 22%; background: rgba(251,146,60,.07); }
/* Row principal */
.ar-hero__row {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1rem; flex-wrap: wrap;
}
.ar-hero__brand { display: flex; align-items: center; gap: .75rem; flex-shrink: 0; }
.ar-hero__icon {
display: grid; place-items: center;
width: 2.5rem; height: 2.5rem; border-radius: .875rem; flex-shrink: 0;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
}
.ar-hero__title {
font-size: 1.05rem; font-weight: 700;
letter-spacing: -.02em;
color: var(--text-color);
display: flex; align-items: center; gap: 6px;
}
.ar-hero__sub { font-size: .75rem; color: var(--text-color-secondary); margin-top: 2px; }
.ar-badge-count {
display: inline-flex; align-items: center; justify-content: center;
min-width: 20px; height: 20px; border-radius: 999px; padding: 0 5px;
background: var(--p-orange-500, #f97316); color: #fff;
font-size: .7rem; font-weight: 800;
}
.ar-hero__search { flex: 1; min-width: 200px; max-width: 280px; }
/* Chips de status */
.ar-status-chips {
position: relative; z-index: 1;
display: flex; flex-wrap: wrap; gap: 6px;
}
.ar-chip {
display: inline-flex; align-items: center; gap: 5px;
padding: 5px 14px; border-radius: 999px;
font-size: .78rem; font-weight: 600;
border: 1.5px solid var(--surface-border);
background: var(--surface-ground);
color: var(--text-color-secondary);
cursor: pointer; transition: all .15s;
position: relative;
}
.ar-chip:hover { border-color: var(--p-primary-400, #818cf8); color: var(--text-color); }
.ar-chip--active {
background: var(--p-primary-500, #6366f1);
border-color: var(--p-primary-500, #6366f1);
color: #fff;
}
.ar-chip-badge {
display: inline-flex; align-items: center; justify-content: center;
min-width: 18px; height: 18px; border-radius: 999px;
background: rgba(255,255,255,.3); font-size: .68rem; font-weight: 800;
padding: 0 4px;
}
.ar-chip--active .ar-chip-badge { background: rgba(255,255,255,.25); }
/* ── Cards ────────────────────────────────────────────────────── */
.ar-card {
background: var(--surface-card);
border: 1px solid var(--surface-border);
border-radius: 1.25rem;
overflow: hidden;
transition: box-shadow .15s;
}
.ar-card:hover { box-shadow: 0 4px 20px rgba(0,0,0,.08); }
.ar-card--expirada { opacity: .65; }
.ar-card--skel { padding: 1rem; display: flex; gap: 1rem; align-items: center; }
.ar-card__main {
display: flex; align-items: center; gap: .875rem;
padding: 1rem 1.25rem;
cursor: pointer;
transition: background .12s;
}
.ar-card__main:hover { background: var(--surface-hover); }
.ar-avatar {
width: 42px; height: 42px; border-radius: 50%;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 15%, transparent);
color: var(--p-primary-500, #6366f1);
display: grid; place-items: center;
font-weight: 800; font-size: 1rem;
flex-shrink: 0;
}
.ar-card__name {
font-weight: 700; font-size: .92rem;
color: var(--text-color);
display: flex; align-items: center; flex-wrap: wrap; gap: 4px;
margin-bottom: 4px;
}
.ar-card__meta {
display: flex; flex-wrap: wrap; gap: 10px;
font-size: .75rem; color: var(--text-color-secondary);
}
.ar-card__actions {
display: flex; gap: 6px; flex-wrap: wrap; flex-shrink: 0;
}
.ar-chevron { color: var(--text-color-secondary); font-size: .8rem; transition: transform .2s; }
.ar-card--expanded .ar-chevron { transform: rotate(180deg); }
/* Detalhe expandido */
.ar-card__detail {
padding: .75rem 1.25rem 1rem;
border-top: 1px solid var(--surface-border);
background: var(--surface-ground);
}
.ar-detail-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: .75rem;
}
.ar-detail-item { display: flex; flex-direction: column; gap: 2px; }
.ar-detail-label { font-size: .7rem; font-weight: 700; color: var(--text-color-secondary); text-transform: uppercase; letter-spacing: .06em; }
.ar-detail-val { font-size: .85rem; color: var(--text-color); word-break: break-word; }
/* ── Skeletons ────────────────────────────────────────────────── */
.ar-skel {
border-radius: .5rem;
background: linear-gradient(90deg, var(--surface-border) 25%, var(--surface-hover) 50%, var(--surface-border) 75%);
background-size: 200% 100%;
animation: ar-shimmer 1.2s infinite;
}
.ar-skel--avatar { width: 42px; height: 42px; border-radius: 50%; flex-shrink: 0; }
.ar-skel--title { height: 14px; width: 60%; }
.ar-skel--sub { height: 11px; width: 40%; }
/* ── Empty ────────────────────────────────────────────────────── */
.ar-empty {
display: flex; flex-direction: column; align-items: center;
padding: 4rem 2rem; text-align: center;
}
.ar-empty__icon {
width: 72px; height: 72px; border-radius: 1.5rem;
background: var(--surface-hover); display: grid; place-items: center;
color: var(--text-color-secondary); margin-bottom: 1rem;
}
.ar-empty__title { font-weight: 700; font-size: 1rem; color: var(--text-color); margin-bottom: 4px; }
.ar-empty__sub { font-size: .85rem; color: var(--text-color-secondary); }
/* ── Expand transition ────────────────────────────────────────── */
.ar-expand-enter-active,
.ar-expand-leave-active { transition: all .22s ease; overflow: hidden; }
.ar-expand-enter-from,
.ar-expand-leave-to { opacity: 0; max-height: 0; }
.ar-expand-enter-to,
.ar-expand-leave-from { opacity: 1; max-height: 400px; }
/* ── Responsivo ───────────────────────────────────────────────── */
@media (max-width: 640px) {
.ar-card__actions { display: none; }
.ar-card--expanded .ar-card__actions { display: flex; padding: .75rem 1.25rem; border-top: 1px solid var(--surface-border); }
}
/* ── Animations ───────────────────────────────────────────────── */
@keyframes ar-shimmer { to { background-position: -200% 0; } }
</style>