Files
agenciapsilmno/src/views/pages/saas/SaasLoginCarousel.vue
2026-03-17 21:08:14 -03:00

565 lines
24 KiB
Vue

<script setup>
import { ref, computed, onMounted } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import Editor from 'primevue/editor'
const toast = useToast()
const confirm = useConfirm()
// ─── Estado ───────────────────────────────────────────────────────────────────
const slides = ref([])
const loading = ref(false)
const saving = ref(false)
const previewIdx = ref(0)
const dialogOpen = ref(false)
const editingSlide = ref(null) // null = novo
const form = ref({ title: '', body: '', icon: '', ordem: 0, ativo: true })
// ─── Ícones disponíveis (subset PrimeIcons relevantes) ────────────────────────
const ICONS = [
{ value: 'pi-calendar-clock', label: 'Agenda' },
{ value: 'pi-users', label: 'Equipe' },
{ value: 'pi-globe', label: 'Online' },
{ value: 'pi-shield', label: 'Segurança' },
{ value: 'pi-heart-fill', label: 'Saúde' },
{ value: 'pi-chart-line', label: 'Estatísticas' },
{ value: 'pi-bell', label: 'Notificações' },
{ value: 'pi-lock', label: 'Privacidade' },
{ value: 'pi-mobile', label: 'Mobile' },
{ value: 'pi-sync', label: 'Sincronização' },
{ value: 'pi-star', label: 'Destaque' },
{ value: 'pi-check-circle', label: 'Aprovação' },
{ value: 'pi-comments', label: 'Comunicação' },
{ value: 'pi-file-edit', label: 'Prontuário' },
{ value: 'pi-briefcase', label: 'Profissional' },
{ value: 'pi-bolt', label: 'Performance' },
]
// ─── Computed ─────────────────────────────────────────────────────────────────
const slidesAtivos = computed(() => slides.value.filter(s => s.ativo).sort((a, b) => a.ordem - b.ordem))
const previewSlide = computed(() => slidesAtivos.value[previewIdx.value] ?? slidesAtivos.value[0] ?? null)
// ─── Supabase ─────────────────────────────────────────────────────────────────
async function load () {
loading.value = true
try {
const { data, error } = await supabase
.from('login_carousel_slides')
.select('*')
.order('ordem', { ascending: true })
if (error) throw error
slides.value = data || []
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar slides.', life: 4000 })
} finally {
loading.value = false
}
}
function stripHtml (s) {
return String(s || '').replace(/<[^>]+>/g, '').trim()
}
async function saveSlide () {
if (!stripHtml(form.value.title) || !stripHtml(form.value.body)) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Título e conteúdo são obrigatórios.', life: 3000 })
return
}
saving.value = true
try {
const payload = {
title: form.value.title,
body: form.value.body,
icon: form.value.icon || 'pi-star',
ordem: form.value.ordem,
ativo: form.value.ativo,
}
if (editingSlide.value) {
const { error } = await supabase
.from('login_carousel_slides')
.update(payload)
.eq('id', editingSlide.value.id)
if (error) throw error
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Slide atualizado com sucesso.', life: 3000 })
} else {
const maxOrdem = slides.value.length ? Math.max(...slides.value.map(s => s.ordem)) + 1 : 0
payload.ordem = maxOrdem
const { error } = await supabase
.from('login_carousel_slides')
.insert(payload)
if (error) throw error
toast.add({ severity: 'success', summary: 'Criado', detail: 'Slide adicionado com sucesso.', life: 3000 })
}
dialogOpen.value = false
await load()
previewIdx.value = 0
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao salvar.', life: 4000 })
} finally {
saving.value = false
}
}
async function toggleAtivo (slide) {
try {
const { error } = await supabase
.from('login_carousel_slides')
.update({ ativo: !slide.ativo })
.eq('id', slide.id)
if (error) throw error
slide.ativo = !slide.ativo
toast.add({ severity: 'info', summary: slide.ativo ? 'Ativado' : 'Desativado', detail: `"${slide.title}"`, life: 2500 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3000 })
}
}
async function deleteSlide (slide) {
confirm.require({
message: `Remover o slide "${slide.title}"? Esta ação não pode ser desfeita.`,
header: 'Confirmar remoção',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
acceptLabel: 'Remover',
rejectLabel: 'Cancelar',
accept: async () => {
try {
const { error } = await supabase.from('login_carousel_slides').delete().eq('id', slide.id)
if (error) throw error
toast.add({ severity: 'success', summary: 'Removido', detail: `Slide removido.`, life: 2500 })
await load()
previewIdx.value = 0
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3000 })
}
},
})
}
async function moveSlide (slide, dir) {
const sorted = [...slides.value].sort((a, b) => a.ordem - b.ordem)
const idx = sorted.findIndex(s => s.id === slide.id)
const swapIdx = idx + dir
if (swapIdx < 0 || swapIdx >= sorted.length) return
const a = sorted[idx]
const b = sorted[swapIdx]
const tempOrdem = a.ordem
try {
await supabase.from('login_carousel_slides').update({ ordem: b.ordem }).eq('id', a.id)
await supabase.from('login_carousel_slides').update({ ordem: tempOrdem }).eq('id', b.id)
await load()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3000 })
}
}
// ─── Dialog helpers ───────────────────────────────────────────────────────────
function openNew () {
editingSlide.value = null
form.value = { title: '', body: '', icon: 'pi-calendar-clock', ordem: 0, ativo: true }
dialogOpen.value = true
}
function openEdit (slide) {
editingSlide.value = slide
form.value = { title: slide.title, body: slide.body, icon: slide.icon || 'pi-star', ordem: slide.ordem, ativo: slide.ativo }
dialogOpen.value = true
}
onMounted(load)
</script>
<template>
<Toast />
<ConfirmDialog />
<!-- Sentinel -->
<div class="h-px" />
<!-- Hero sticky -->
<div
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-72 h-72 -bottom-20 right-24 bg-fuchsia-400/10" />
</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)] flex items-center gap-2">
<i class="pi pi-images text-indigo-500" />
Carrossel do Login
</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">
Gerencie os slides exibidos na tela de login do sistema
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<Button
icon="pi pi-refresh"
severity="secondary"
outlined
title="Recarregar"
:loading="loading"
@click="load"
/>
<Button
icon="pi pi-plus"
label="Novo slide"
@click="openNew"
/>
</div>
</div>
</div>
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<div class="grid grid-cols-1 xl:grid-cols-[1fr_340px] gap-6 items-start">
<!-- Tabela de slides -->
<div class="bg-[var(--surface-card,#fff)] rounded-md border border-[var(--surface-border)] overflow-hidden">
<!-- Loading skeleton -->
<div v-if="loading" class="flex flex-col divide-y divide-[var(--surface-border)]">
<div v-for="i in 4" :key="i" class="flex items-center gap-4 px-5 py-4 animate-pulse">
<div class="w-10 h-10 rounded-md bg-[var(--surface-ground)] flex-shrink-0" />
<div class="flex-1 space-y-2">
<div class="h-3.5 w-40 rounded bg-[var(--surface-ground)]" />
<div class="h-3 w-64 rounded bg-[var(--surface-ground)]" />
</div>
</div>
</div>
<!-- Lista vazia -->
<div v-else-if="!slides.length" class="flex flex-col items-center justify-center py-16 gap-3 text-[var(--text-color-secondary)]">
<i class="pi pi-images text-4xl opacity-30" />
<span class="text-[1rem]">Nenhum slide cadastrado ainda.</span>
<Button label="Criar primeiro slide" size="small" @click="openNew" />
</div>
<!-- Rows -->
<div v-else class="divide-y divide-[var(--surface-border)]">
<!-- Header -->
<div class="grid grid-cols-[auto_1fr_auto_auto] items-center gap-4 px-5 py-2.5 bg-[var(--surface-ground)] text-xs font-bold uppercase tracking-widest text-[var(--text-color-secondary)]">
<span class="w-10" />
<span>Slide</span>
<span class="text-center w-[60px]">Status</span>
<span class="w-[96px]" />
</div>
<div
v-for="(slide, i) in [...slides].sort((a,b) => a.ordem - b.ordem)"
:key="slide.id"
class="grid grid-cols-[auto_1fr_auto_auto] items-center gap-4 px-5 py-3.5 transition-colors duration-100 hover:bg-[var(--surface-ground,#f8fafc)] group"
>
<!-- Ícone + ordem -->
<div class="relative flex-shrink-0">
<div
class="w-10 h-10 rounded-md flex items-center justify-center text-lg"
:class="slide.ativo ? 'bg-indigo-500/10 text-indigo-500' : 'bg-[var(--surface-ground)] text-[var(--text-color-secondary)]'"
>
<i :class="['pi', slide.icon || 'pi-star']" />
</div>
<span class="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-[var(--surface-border)] text-[0.58rem] font-bold flex items-center justify-center text-[var(--text-color-secondary)]">
{{ slide.ordem + 1 }}
</span>
</div>
<!-- Conteúdo -->
<div class="min-w-0">
<div class="text-[1rem] font-semibold text-[var(--text-color)] truncate [&_*]:inline" :class="!slide.ativo && 'opacity-40 line-through'" v-html="slide.title" />
<div class="text-[1rem] text-[var(--text-color-secondary)] truncate mt-0.5 [&_*]:inline" :class="!slide.ativo && 'opacity-40'" v-html="slide.body" />
</div>
<!-- Toggle ativo -->
<div class="flex justify-center w-[60px]">
<InputSwitch
:modelValue="slide.ativo"
@update:modelValue="() => toggleAtivo(slide)"
/>
</div>
<!-- Ações -->
<div class="flex items-center gap-1 w-[96px] justify-end opacity-0 group-hover:opacity-100 transition-opacity duration-150">
<button
class="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-color-secondary)] hover:bg-[var(--surface-border)] hover:text-[var(--text-color)] transition-colors duration-100 disabled:opacity-30"
:disabled="i === 0"
title="Mover para cima"
@click="moveSlide(slide, -1)"
>
<i class="pi pi-chevron-up text-xs" />
</button>
<button
class="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-color-secondary)] hover:bg-[var(--surface-border)] hover:text-[var(--text-color)] transition-colors duration-100 disabled:opacity-30"
:disabled="i === slides.length - 1"
title="Mover para baixo"
@click="moveSlide(slide, 1)"
>
<i class="pi pi-chevron-down text-xs" />
</button>
<button
class="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-color-secondary)] hover:bg-indigo-50 hover:text-indigo-600 transition-colors duration-100"
title="Editar"
@click="openEdit(slide)"
>
<i class="pi pi-pencil text-xs" />
</button>
<button
class="w-7 h-7 rounded-md flex items-center justify-center text-[var(--text-color-secondary)] hover:bg-red-50 hover:text-red-500 transition-colors duration-100"
title="Remover"
@click="deleteSlide(slide)"
>
<i class="pi pi-trash text-xs" />
</button>
</div>
</div>
</div>
</div>
<!-- Preview -->
<div class="sticky top-6 flex flex-col gap-3">
<div class="text-xs font-bold uppercase tracking-widest text-[var(--text-color-secondary)] flex items-center gap-1.5 px-1">
<i class="pi pi-eye" /> Pré-visualização
</div>
<!-- Mock da tela de login lado esquerdo -->
<div class="relative overflow-hidden rounded-md aspect-[9/16] max-h-[480px] w-full select-none shadow-xl">
<div class="absolute inset-0 bg-gradient-to-br from-indigo-600 via-violet-600 to-purple-700" />
<!-- Grade decorativa -->
<div
class="absolute inset-0 opacity-[0.08]"
style="background-image: linear-gradient(to right, white 1px, transparent 1px), linear-gradient(to bottom, white 1px, transparent 1px); background-size: 32px 32px;"
/>
<!-- Orbs -->
<div class="absolute -top-20 -left-20 h-64 w-64 rounded-full bg-white/10 blur-3xl pointer-events-none" />
<div class="absolute bottom-0 right-0 h-48 w-48 rounded-full bg-violet-300/20 blur-3xl pointer-events-none" />
<div class="relative z-10 flex flex-col h-full p-6">
<!-- Brand mock -->
<div class="flex items-center gap-2">
<div class="grid h-7 w-7 place-items-center rounded-lg bg-white/20 border border-white/20">
<i class="pi pi-heart-fill text-white text-[0.6rem]" />
</div>
<span class="text-white/90 font-bold text-xs tracking-tight">Agência PSI</span>
</div>
<!-- Slide content -->
<div class="flex-1 flex flex-col justify-center gap-4">
<Transition name="prev-fade" mode="out-in">
<div v-if="previewSlide" :key="previewSlide.id ?? previewIdx" class="space-y-4">
<div class="grid h-11 w-11 place-items-center rounded-md bg-white/15 border border-white/20 shadow-lg">
<i :class="['pi', previewSlide.icon || 'pi-star', 'text-white text-lg']" />
</div>
<div class="space-y-2">
<div class="text-xl font-bold text-white leading-tight prose prose-invert prose-sm max-w-none" v-html="previewSlide.title" />
<div class="text-[1rem] text-white/70 leading-relaxed prose prose-invert prose-sm max-w-none" v-html="previewSlide.body" />
</div>
</div>
<div v-else class="flex flex-col items-center justify-center gap-2 text-white/30 text-xs">
<i class="pi pi-ban text-2xl" />
Nenhum slide ativo
</div>
</Transition>
</div>
<!-- Dots -->
<div class="flex items-center gap-1.5">
<button
v-for="(s, i) in slidesAtivos"
:key="s.id"
class="transition-all duration-300 rounded-full"
:class="i === previewIdx ? 'w-5 h-1.5 bg-white shadow' : 'w-1.5 h-1.5 bg-white/35 hover:bg-white/60'"
@click="previewIdx = i"
/>
<span v-if="slidesAtivos.length" class="ml-2 text-[0.6rem] text-white/40 tabular-nums">
{{ previewIdx + 1 }}/{{ slidesAtivos.length }}
</span>
</div>
</div>
</div>
<!-- Info -->
<div class="rounded-lg border border-[var(--surface-border)] bg-[var(--surface-card)] px-4 py-3 text-xs text-[var(--text-color-secondary)] flex items-start gap-2">
<i class="pi pi-info-circle text-indigo-500 mt-px flex-shrink-0" />
<span>Clique nos pontos para navegar entre os slides ativos. A ordem e visibilidade refletem o que o usuário verá no login.</span>
</div>
</div>
</div>
<!-- SQL Helper -->
<div class="bg-[var(--surface-card)] rounded-md border border-[var(--surface-border)] px-5 py-4">
<div class="flex items-center gap-2 mb-3">
<i class="pi pi-database text-amber-500 text-[1rem]" />
<span class="text-xs font-bold text-[var(--text-color)] uppercase tracking-widest">SQL de referência</span>
<span class="ml-auto text-xs text-[var(--text-color-secondary)]">Execute no Supabase caso a tabela não exista</span>
</div>
<pre class="text-[0.7rem] bg-[var(--surface-ground)] rounded-lg p-3.5 overflow-x-auto text-[var(--text-color-secondary)] leading-relaxed whitespace-pre-wrap"><code>create table if not exists public.login_carousel_slides (
id uuid primary key default gen_random_uuid(),
title text not null,
body text not null,
icon text not null default 'pi-star',
ordem integer not null default 0,
ativo boolean not null default true,
created_at timestamptz default now(),
updated_at timestamptz default now()
);
-- RLS: apenas saas_admin pode gerenciar
alter table public.login_carousel_slides enable row level security;
create policy "saas_admin_full" on public.login_carousel_slides
for all using (
exists (
select 1 from public.profiles
where id = auth.uid() and role = 'saas_admin'
)
);
-- Leitura pública (login não tem usuário autenticado)
create policy "public_read" on public.login_carousel_slides
for select using (ativo = true);</code></pre>
</div>
</div>
<!-- /px-3 content wrapper -->
<!-- Dialog: Criar / Editar slide -->
<Dialog
v-model:visible="dialogOpen"
modal
:header="editingSlide ? 'Editar slide' : 'Novo slide'"
:draggable="false"
:style="{ width: '46rem', maxWidth: '96vw' }"
>
<div class="flex flex-col gap-4 pt-1">
<!-- Título -->
<div class="flex flex-col gap-1.5">
<label class="text-[1rem] font-semibold text-[var(--text-color)]">Título <span class="text-red-500">*</span></label>
<Editor
v-model="form.title"
:pt="{ toolbar: { style: 'display:none' } }"
style="height: 72px"
editorStyle="font-size: 1rem; font-weight: 600;"
placeholder="Ex: Gestão clínica simplificada"
>
<template #toolbar>
<span class="ql-formats">
<button class="ql-bold" />
<button class="ql-italic" />
<button class="ql-underline" />
</span>
</template>
</Editor>
</div>
<!-- Conteúdo -->
<div class="flex flex-col gap-1.5">
<label class="text-[1rem] font-semibold text-[var(--text-color)]">Conteúdo <span class="text-red-500">*</span></label>
<Editor
v-model="form.body"
style="height: 160px"
editorStyle="font-size: 1rem;"
>
<template #toolbar>
<span class="ql-formats">
<button class="ql-bold" />
<button class="ql-italic" />
<button class="ql-underline" />
</span>
<span class="ql-formats">
<button class="ql-list" value="ordered" />
<button class="ql-list" value="bullet" />
</span>
<span class="ql-formats">
<button class="ql-link" />
<button class="ql-clean" />
</span>
</template>
</Editor>
</div>
<!-- Ícone -->
<div class="flex flex-col gap-1.5">
<label class="text-[1rem] font-semibold text-[var(--text-color)]">Ícone</label>
<div class="grid grid-cols-4 sm:grid-cols-8 gap-1.5">
<button
v-for="ic in ICONS"
:key="ic.value"
type="button"
class="flex flex-col items-center justify-center gap-1 py-2 rounded-lg border text-xs transition-all duration-100"
:class="form.icon === ic.value
? 'border-indigo-500 bg-indigo-50 text-indigo-600 shadow-sm'
: 'border-[var(--surface-border)] bg-[var(--surface-ground)] text-[var(--text-color-secondary)] hover:border-indigo-300 hover:text-indigo-500'"
:title="ic.label"
@click="form.icon = ic.value"
>
<i :class="['pi', ic.value, 'text-base']" />
<span class="text-[0.6rem] leading-none">{{ ic.label }}</span>
</button>
</div>
</div>
<!-- Ativo -->
<div class="flex items-center gap-3">
<InputSwitch v-model="form.ativo" inputId="slide-ativo" />
<label for="slide-ativo" class="text-[1rem] text-[var(--text-color)] cursor-pointer select-none">
Slide ativo (visível no carrossel)
</label>
</div>
<!-- Mini preview -->
<div
class="relative overflow-hidden rounded-md p-5 flex items-center gap-4"
style="background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%)"
>
<div class="grid h-12 w-12 flex-shrink-0 place-items-center rounded-md bg-white/15 border border-white/20 shadow">
<i :class="['pi', form.icon || 'pi-star', 'text-white text-xl']" />
</div>
<div class="min-w-0 overflow-hidden">
<div class="text-[1rem] font-bold text-white line-clamp-2 prose prose-invert prose-sm max-w-none" v-html="stripHtml(form.title) ? form.title : 'Título do slide'" />
<div class="text-[1rem] text-white/70 mt-0.5 line-clamp-3 prose prose-invert prose-sm max-w-none" v-html="stripHtml(form.body) ? form.body : 'Conteúdo descritivo...'" />
</div>
</div>
<!-- Ações -->
<div class="flex justify-end gap-2 pt-1">
<Button label="Cancelar" severity="secondary" outlined :disabled="saving" @click="dialogOpen = false" />
<Button
:label="editingSlide ? 'Salvar alterações' : 'Criar slide'"
icon="pi pi-check"
:loading="saving"
@click="saveSlide"
/>
</div>
</div>
</Dialog>
</template>
<style scoped>
.prev-fade-enter-active,
.prev-fade-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.prev-fade-enter-from {
opacity: 0;
transform: translateY(12px);
}
.prev-fade-leave-to {
opacity: 0;
transform: translateY(-8px);
}
</style>