Agenda, Agendador, Configurações
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user