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

View File

@@ -0,0 +1,364 @@
<template>
<div class="saas-support p-4 md:p-6">
<Toast />
<!-- Cabeçalho -->
<div class="flex items-center gap-3 mb-6">
<div class="flex items-center justify-center w-10 h-10 rounded-xl bg-orange-100 dark:bg-orange-900/30">
<i class="pi pi-headphones text-orange-600 dark:text-orange-400 text-lg" />
</div>
<div>
<h1 class="text-xl font-bold text-surface-900 dark:text-surface-0 m-0">Suporte Técnico</h1>
<p class="text-sm text-surface-500 m-0">Gere links seguros para acessar a agenda de um cliente em modo debug</p>
</div>
</div>
<!-- Card: Gerar nova sessão -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div class="card">
<h2 class="text-base font-semibold mb-4 flex items-center gap-2">
<i class="pi pi-plus-circle text-primary" />
Nova Sessão de Suporte
</h2>
<div class="flex flex-col gap-4">
<!-- Seleção de tenant -->
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-surface-700 dark:text-surface-300">Selecionar Cliente (Tenant)</label>
<Select
v-model="selectedTenantId"
:options="tenants"
option-label="label"
option-value="value"
placeholder="Buscar tenant..."
filter
:loading="loadingTenants"
class="w-full"
empty-filter-message="Nenhum tenant encontrado"
/>
</div>
<!-- TTL -->
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-surface-700 dark:text-surface-300">Duração do Acesso</label>
<Select
v-model="ttlMinutes"
:options="ttlOptions"
option-label="label"
option-value="value"
class="w-full"
/>
</div>
<!-- Botão -->
<Button
label="Ativar Modo Suporte"
icon="pi pi-shield"
severity="warning"
:loading="creating"
:disabled="!selectedTenantId"
class="w-full"
@click="handleCreate"
/>
</div>
</div>
<!-- Card: URL Gerada -->
<div class="card">
<h2 class="text-base font-semibold mb-4 flex items-center gap-2">
<i class="pi pi-link text-primary" />
URL de Suporte Gerada
</h2>
<div v-if="generatedUrl" class="flex flex-col gap-3">
<!-- URL -->
<div class="flex flex-col gap-1">
<label class="text-sm font-medium text-surface-700 dark:text-surface-300">Link de Acesso</label>
<div class="flex gap-2">
<InputText
:value="generatedUrl"
readonly
class="flex-1 font-mono text-xs"
/>
<Button
icon="pi pi-copy"
severity="secondary"
outlined
v-tooltip.top="'Copiar URL'"
@click="copyUrl"
/>
</div>
</div>
<!-- Expira em -->
<div class="flex items-center gap-2 text-sm text-surface-500">
<i class="pi pi-clock text-orange-500" />
<span>Expira em: <strong class="text-surface-700 dark:text-surface-300">{{ expiresLabel }}</strong></span>
</div>
<!-- Token (reduzido) -->
<div class="flex items-center gap-2 text-xs text-surface-400 font-mono">
<i class="pi pi-key" />
<span>{{ tokenPreview }}</span>
</div>
<!-- Instruções -->
<Message severity="info" :closable="false" class="text-sm">
Envie este link ao terapeuta ou acesse diretamente para ver os logs da agenda.
O link expira automaticamente.
</Message>
</div>
<div v-else class="flex flex-col items-center justify-center py-10 text-surface-400 gap-2">
<i class="pi pi-shield text-4xl opacity-30" />
<span class="text-sm">Nenhuma sessão gerada ainda</span>
</div>
</div>
</div>
<!-- Sessões ativas -->
<div class="card mt-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-base font-semibold flex items-center gap-2 m-0">
<i class="pi pi-list text-primary" />
Sessões Ativas
</h2>
<Button
icon="pi pi-refresh"
severity="secondary"
outlined
size="small"
:loading="loadingSessions"
@click="loadActiveSessions"
/>
</div>
<DataTable
:value="activeSessions"
:loading="loadingSessions"
empty-message="Nenhuma sessão ativa no momento"
size="small"
striped-rows
>
<Column field="tenant_id" header="Tenant ID">
<template #body="{ data }">
<span class="font-mono text-xs">{{ data.tenant_id }}</span>
</template>
</Column>
<Column header="Token">
<template #body="{ data }">
<span class="font-mono text-xs">{{ data.token.slice(0, 16) }}</span>
</template>
</Column>
<Column header="Expira em">
<template #body="{ data }">
<span :class="isExpiringSoon(data.expires_at) ? 'text-orange-500 font-semibold' : ''">
{{ formatExpires(data.expires_at) }}
</span>
</template>
</Column>
<Column header="Criada">
<template #body="{ data }">
{{ formatDate(data.created_at) }}
</template>
</Column>
<Column header="Ações">
<template #body="{ data }">
<div class="flex gap-2">
<Button
icon="pi pi-copy"
size="small"
severity="secondary"
outlined
v-tooltip.top="'Copiar URL'"
@click="copySessionUrl(data.token)"
/>
<Button
icon="pi pi-trash"
size="small"
severity="danger"
outlined
v-tooltip.top="'Revogar'"
:loading="revokingToken === data.token"
@click="handleRevoke(data.token)"
/>
</div>
</template>
</Column>
</DataTable>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client'
import {
createSupportSession,
listActiveSupportSessions,
revokeSupportSession,
buildSupportUrl,
} from '@/support/supportSessionService'
const toast = useToast()
// ── Estado ─────────────────────────────────────────────────────────────────
const selectedTenantId = ref(null)
const ttlMinutes = ref(60)
const creating = ref(false)
const loadingTenants = ref(false)
const loadingSessions = ref(false)
const revokingToken = ref(null)
const tenants = ref([])
const activeSessions = ref([])
const generatedUrl = ref(null)
const generatedData = ref(null) // { token, expires_at }
// ── Opções de TTL ──────────────────────────────────────────────────────────
const ttlOptions = [
{ label: '30 minutos', value: 30 },
{ label: '60 minutos', value: 60 },
{ label: '2 horas', value: 120 },
]
// ── Computed ───────────────────────────────────────────────────────────────
const expiresLabel = computed(() => {
if (!generatedData.value?.expires_at) return ''
return new Date(generatedData.value.expires_at).toLocaleString('pt-BR')
})
const tokenPreview = computed(() => {
if (!generatedData.value?.token) return ''
const t = generatedData.value.token
return `${t.slice(0, 8)}${t.slice(-8)}`
})
// ── Lifecycle ──────────────────────────────────────────────────────────────
onMounted(() => {
loadTenants()
loadActiveSessions()
})
// ── Métodos ────────────────────────────────────────────────────────────────
async function loadTenants () {
loadingTenants.value = true
try {
const { data, error } = await supabase
.from('tenants')
.select('id, name, kind')
.order('name', { ascending: true })
if (error) throw error
tenants.value = (data || []).map(t => ({
value: t.id,
label: `${t.name} (${t.kind ?? 'tenant'})`,
}))
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 })
} finally {
loadingTenants.value = false
}
}
async function loadActiveSessions () {
loadingSessions.value = true
try {
activeSessions.value = await listActiveSupportSessions()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4000 })
} finally {
loadingSessions.value = false
}
}
async function handleCreate () {
if (!selectedTenantId.value) return
creating.value = true
generatedUrl.value = null
generatedData.value = null
try {
const result = await createSupportSession(selectedTenantId.value, ttlMinutes.value)
generatedData.value = result
generatedUrl.value = buildSupportUrl(result.token)
toast.add({
severity: 'success',
summary: 'Sessão criada',
detail: 'URL de suporte gerada com sucesso.',
life: 4000,
})
await loadActiveSessions()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao criar sessão', detail: e?.message, life: 5000 })
} finally {
creating.value = false
}
}
async function handleRevoke (token) {
revokingToken.value = token
try {
await revokeSupportSession(token)
toast.add({ severity: 'success', summary: 'Sessão revogada', life: 3000 })
if (generatedData.value?.token === token) {
generatedUrl.value = null
generatedData.value = null
}
await loadActiveSessions()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao revogar', detail: e?.message, life: 4000 })
} finally {
revokingToken.value = null
}
}
function copyUrl () {
if (!generatedUrl.value) return
navigator.clipboard.writeText(generatedUrl.value)
toast.add({ severity: 'info', summary: 'Copiado!', detail: 'URL copiada para a área de transferência.', life: 2000 })
}
function copySessionUrl (token) {
const url = buildSupportUrl(token)
navigator.clipboard.writeText(url)
toast.add({ severity: 'info', summary: 'Copiado!', life: 2000 })
}
// ── Formatação ─────────────────────────────────────────────────────────────
function formatDate (iso) {
if (!iso) return '-'
return new Date(iso).toLocaleString('pt-BR')
}
function formatExpires (iso) {
if (!iso) return '-'
const d = new Date(iso)
const now = new Date()
const diffMin = Math.round((d - now) / 60000)
if (diffMin < 0) return 'Expirada'
if (diffMin < 60) return `em ${diffMin} min`
return new Date(iso).toLocaleString('pt-BR')
}
function isExpiringSoon (iso) {
if (!iso) return false
const diffMin = (new Date(iso) - new Date()) / 60000
return diffMin > 0 && diffMin < 15
}
</script>