feat: portal autenticado — Painel e Débitos com UI real

portalService: endpoints para todas as rotas /portal/* (débitos, certidões, alvarás, pagamentos, dados)

PainelView:
- Cards de resumo (totalDebitos, certidoesAtivas, alvarasAndamento, ultimoPagamento) com skeleton loading
- Alerta destacado quando há débitos vencidos
- Grid de acesso rápido (4 atalhos)
- Timeline de atividade recente com skeleton

DebitosView:
- Filtros por tipo (IPTU, ISS, TAXA...) e status (VENCIDO, A_VENCER, PARCELADO)
- Lista com checkbox de seleção múltipla + barra de total selecionado
- Chips de status coloridos por situação
- Emissão de guia individual por blob PDF
- Skeleton loading e estado vazio/erro

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Gabriel Bezerra 2026-05-18 01:00:04 -03:00
parent 1b2aea444e
commit fed6998a6f
3 changed files with 452 additions and 33 deletions

View File

@ -0,0 +1,61 @@
import apiClient from '@/config/apiClient'
export const portalService = {
// Painel
getPainelResumo() {
return apiClient.get('/contribuinte/painel/resumo')
},
getAtividades(pagina = 0, tamanho = 5) {
return apiClient.get('/contribuinte/painel/atividades', {
params: { pagina, tamanho },
})
},
// Débitos
getDebitos(params = {}) {
return apiClient.get('/contribuinte/debitos', { params })
},
emitirGuia(idDebito) {
return apiClient.get(`/contribuinte/debitos/${idDebito}/guia`, {
responseType: 'blob',
})
},
// Certidões
getCertidoes() {
return apiClient.get('/contribuinte/certidoes')
},
reemitirCertidao(idCertidao) {
return apiClient.get(`/contribuinte/certidoes/${idCertidao}/pdf`, {
responseType: 'blob',
})
},
// Alvarás
getAlvaras(params = {}) {
return apiClient.get('/contribuinte/alvaras', { params })
},
// Pagamentos
getPagamentos(params = {}) {
return apiClient.get('/contribuinte/pagamentos', { params })
},
getComprovante(idPagamento) {
return apiClient.get(`/contribuinte/pagamentos/${idPagamento}/comprovante`, {
responseType: 'blob',
})
},
// Dados cadastrais
getDadosCadastrais() {
return apiClient.get('/contribuinte/dados')
},
atualizarContato(payload) {
return apiClient.put('/contribuinte/dados/contato', payload)
},
}

View File

@ -1,12 +1,237 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue'
import { portalService } from '@/services/portalService'
const debitos = ref([])
const carregando = ref(true)
const carregandoGuia = ref(null)
const filtroTipo = ref(null)
const filtroStatus = ref(null)
const mensagemErro = ref('')
const tiposDisponiveis = ['IPTU', 'ISS', 'TAXA', 'MULTA', 'DIVIDA_ATIVA']
const statusDisponiveis = [
{ value: 'VENCIDO', label: 'Vencido', classe: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400' },
{ value: 'A_VENCER', label: 'A vencer', classe: 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400' },
{ value: 'PARCELADO', label: 'Parcelado', classe: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400' },
]
const statusClasse = Object.fromEntries(statusDisponiveis.map(s => [s.value, s.classe]))
const statusLabel = Object.fromEntries(statusDisponiveis.map(s => [s.value, s.label]))
onMounted(() => carregar())
async function carregar() {
carregando.value = true
mensagemErro.value = ''
try {
const params = {}
if (filtroTipo.value) params.tipo = filtroTipo.value
if (filtroStatus.value) params.status = filtroStatus.value
const { data } = await portalService.getDebitos(params)
debitos.value = data.data?.content ?? []
} catch (e) {
mensagemErro.value = e.response?.data?.description ?? 'Não foi possível carregar os débitos.'
} finally {
carregando.value = false
}
}
async function emitirGuia(debito) {
carregandoGuia.value = debito.id
try {
const { data } = await portalService.emitirGuia(debito.id)
const url = URL.createObjectURL(new Blob([data], { type: 'application/pdf' }))
const a = document.createElement('a')
a.href = url
a.download = `guia-${debito.id}.pdf`
a.click()
URL.revokeObjectURL(url)
} catch {
mensagemErro.value = 'Erro ao gerar a guia. Tente novamente.'
} finally {
carregandoGuia.value = null
}
}
const totalSelecionado = computed(() =>
debitos.value
.filter(d => d._selecionado)
.reduce((sum, d) => sum + (d.valorAtualizado ?? d.valor), 0)
)
const temSelecionados = computed(() => debitos.value.some(d => d._selecionado))
function toggleTodos(val) {
debitos.value.forEach(d => (d._selecionado = val))
}
function formatarMoeda(valor) {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(valor ?? 0)
}
function aplicarFiltro() {
carregar()
}
function limparFiltros() {
filtroTipo.value = null
filtroStatus.value = null
carregar()
}
</script> </script>
<template> <template>
<div class="space-y-6"> <div class="space-y-6">
<h1 class="text-2xl font-bold text-slate-800">Débitos e Guias</h1>
<div class="bg-white rounded-xl border border-slate-200 p-8 text-center text-slate-400"> <!-- Cabeçalho -->
<i class="pi pi-receipt text-4xl mb-3 block" /> <div class="flex items-center justify-between gap-4 flex-wrap">
<p>Consulta de débitos e emissão de guias a ser implementado.</p> <div>
<h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100">Débitos e Guias</h1>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">Consulte seus débitos e emita guias de pagamento.</p>
</div>
</div> </div>
<!-- Filtros -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4 flex flex-wrap gap-3 items-end">
<div class="flex-1 min-w-[160px]">
<label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1.5">Tipo</label>
<Select
v-model="filtroTipo"
:options="tiposDisponiveis"
placeholder="Todos"
show-clear
class="w-full"
size="small"
/>
</div>
<div class="flex-1 min-w-[160px]">
<label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1.5">Status</label>
<Select
v-model="filtroStatus"
:options="statusDisponiveis"
option-label="label"
option-value="value"
placeholder="Todos"
show-clear
class="w-full"
size="small"
/>
</div>
<div class="flex gap-2">
<Button label="Filtrar" icon="pi pi-filter" size="small" @click="aplicarFiltro" />
<Button label="Limpar" severity="secondary" size="small" outlined @click="limparFiltros" />
</div>
</div>
<!-- Barra de ação para selecionados -->
<Transition
enter-active-class="transition-all duration-200"
enter-from-class="opacity-0 -translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition-all duration-150"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-2"
>
<div
v-if="temSelecionados"
class="bg-primary/8 dark:bg-primary/15 border border-primary/20 rounded-xl p-4 flex items-center justify-between gap-4"
>
<p class="text-sm font-semibold text-primary">
Total selecionado: {{ formatarMoeda(totalSelecionado) }}
</p>
<Button label="Emitir guia unificada" icon="pi pi-download" size="small" />
</div>
</Transition>
<!-- Tabela -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
<!-- Loading skeleton -->
<div v-if="carregando" class="divide-y divide-slate-100 dark:divide-slate-700">
<div v-for="i in 5" :key="i" class="p-5 flex items-center gap-4">
<div class="w-4 h-4 bg-slate-200 dark:bg-slate-700 rounded animate-pulse" />
<div class="flex-1 space-y-2">
<div class="h-3.5 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-1/3" />
<div class="h-3 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-1/4" />
</div>
<div class="h-3.5 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-20" />
<div class="h-6 bg-slate-200 dark:bg-slate-700 rounded-full animate-pulse w-16" />
<div class="h-8 bg-slate-200 dark:bg-slate-700 rounded-lg animate-pulse w-24" />
</div>
</div>
<!-- Vazio -->
<div v-else-if="debitos.length === 0 && !mensagemErro" class="p-12 text-center">
<i class="pi pi-check-circle text-emerald-400 dark:text-emerald-500 text-4xl mb-3 block" aria-hidden="true" />
<p class="font-semibold text-slate-700 dark:text-slate-200">Nenhum débito encontrado</p>
<p class="text-sm text-slate-400 dark:text-slate-500 mt-1">Sua situação fiscal está regularizada.</p>
</div>
<!-- Erro -->
<div v-else-if="mensagemErro" class="p-8 text-center">
<i class="pi pi-exclamation-triangle text-amber-400 text-3xl mb-3 block" aria-hidden="true" />
<p class="text-sm text-slate-600 dark:text-slate-300">{{ mensagemErro }}</p>
<Button label="Tentar novamente" severity="secondary" size="small" class="mt-4" @click="carregar" />
</div>
<!-- Lista -->
<div v-else>
<!-- Header da lista -->
<div class="flex items-center gap-4 px-5 py-3 bg-slate-50 dark:bg-slate-700/50 border-b border-slate-200 dark:border-slate-700 text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">
<Checkbox :binary="true" @change="toggleTodos($event.target.checked)" />
<span class="flex-1">Descrição</span>
<span class="hidden sm:block w-28 text-right">Vencimento</span>
<span class="w-28 text-right">Valor</span>
<span class="w-20 text-center">Status</span>
<span class="w-28" />
</div>
<div class="divide-y divide-slate-100 dark:divide-slate-700">
<div
v-for="debito in debitos"
:key="debito.id"
class="flex items-center gap-4 px-5 py-4 hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors"
>
<Checkbox v-model="debito._selecionado" :binary="true" />
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-slate-800 dark:text-slate-100 truncate">{{ debito.descricao }}</p>
<p class="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{{ debito.tipo }} · Ref: {{ debito.referencia }}</p>
</div>
<p class="hidden sm:block text-sm text-slate-500 dark:text-slate-400 w-28 text-right whitespace-nowrap">
{{ debito.vencimento }}
</p>
<div class="w-28 text-right">
<p class="text-sm font-bold text-slate-800 dark:text-slate-100">{{ formatarMoeda(debito.valorAtualizado ?? debito.valor) }}</p>
<p v-if="debito.valorAtualizado > debito.valor" class="text-xs text-slate-400 line-through">{{ formatarMoeda(debito.valor) }}</p>
</div>
<div class="w-20 flex justify-center">
<span
:class="['text-xs font-semibold px-2 py-1 rounded-full whitespace-nowrap', statusClasse[debito.status] ?? 'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300']"
>
{{ statusLabel[debito.status] ?? debito.status }}
</span>
</div>
<div class="w-28 flex justify-end">
<Button
label="Emitir guia"
icon="pi pi-download"
size="small"
outlined
class="whitespace-nowrap"
:loading="carregandoGuia === debito.id"
@click="emitirGuia(debito)"
/>
</div>
</div>
</div>
</div>
</div>
</div> </div>
</template> </template>

View File

@ -1,55 +1,188 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/authStore' import { useAuthStore } from '@/stores/authStore'
import { portalService } from '@/services/portalService'
const auth = useAuthStore() const auth = useAuthStore()
const router = useRouter()
const cards = [ const resumo = ref(null)
{ icon: 'pi-receipt', label: 'Débitos em Aberto', valor: '—', cor: 'red' }, const atividades = ref([])
{ icon: 'pi-file-check', label: 'Certidões Ativas', valor: '—', cor: 'green' }, const carregando = ref(true)
{ icon: 'pi-briefcase', label: 'Alvarás em Andamento', valor: '—', cor: 'orange' },
{ icon: 'pi-credit-card', label: 'Último Pagamento', valor: '—', cor: 'blue' }, onMounted(async () => {
try {
const [resResumo, resAtividades] = await Promise.all([
portalService.getPainelResumo(),
portalService.getAtividades(),
])
resumo.value = resResumo.data.data
atividades.value = resAtividades.data.data?.content ?? []
} catch {
// silencioso exibe zeros
} finally {
carregando.value = false
}
})
const acesRapidos = [
{ icon: 'pi-receipt', label: 'Emitir Guia', to: 'debitos', cor: 'text-primary' },
{ icon: 'pi-file-check', label: 'Nova Certidão', to: 'certidoes-portal', cor: 'text-emerald-600 dark:text-emerald-400' },
{ icon: 'pi-briefcase', label: 'Acompanhar Alvará', to: 'alvaras', cor: 'text-amber-600 dark:text-amber-400' },
{ icon: 'pi-user', label: 'Meus Dados', to: 'dados', cor: 'text-violet-600 dark:text-violet-400' },
] ]
const corMap = { function formatarMoeda(valor) {
red: 'bg-red-50 text-red-700', return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(valor ?? 0)
green: 'bg-green-50 text-green-700', }
orange: 'bg-orange-50 text-orange-700',
blue: 'bg-blue-50 text-blue-700', const iconeAtividade = {
DEBITO: 'pi-receipt',
CERTIDAO: 'pi-file-check',
ALVARA: 'pi-briefcase',
PAGAMENTO: 'pi-credit-card',
CADASTRO: 'pi-user',
} }
</script> </script>
<template> <template>
<div class="space-y-8"> <div class="space-y-8">
<!-- Saudação -->
<div> <div>
<h1 class="text-2xl font-bold text-slate-800"> <h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100">
Olá, {{ auth.nomeUsuario || 'Contribuinte' }} Olá, {{ auth.nomeUsuario || 'Contribuinte' }} 👋
</h1> </h1>
<p class="text-slate-500 mt-1">Bem-vindo ao seu painel de gestão fiscal.</p> <p class="text-slate-500 dark:text-slate-400 mt-1">Bem-vindo ao seu painel de gestão fiscal.</p>
</div> </div>
<!-- Cards resumo --> <!-- Cards de resumo -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4"> <div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div
v-for="card in cards" <div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5 space-y-3">
:key="card.label" <div class="w-10 h-10 bg-red-50 dark:bg-red-900/20 rounded-lg flex items-center justify-center">
class="bg-white rounded-xl border border-slate-200 p-5 space-y-3" <i class="pi pi-receipt text-red-600 dark:text-red-400 text-sm" aria-hidden="true" />
>
<div :class="['w-10 h-10 rounded-lg flex items-center justify-center', corMap[card.cor]]">
<i :class="['pi', card.icon]" />
</div> </div>
<div> <div>
<p class="text-2xl font-bold text-slate-800">{{ card.valor }}</p> <p class="text-2xl font-bold text-slate-800 dark:text-slate-100">
<p class="text-xs text-slate-500 mt-0.5">{{ card.label }}</p> <span v-if="carregando" class="inline-block w-12 h-7 bg-slate-200 dark:bg-slate-700 rounded animate-pulse" />
<template v-else>{{ formatarMoeda(resumo?.totalDebitos) }}</template>
</p>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Débitos em aberto</p>
</div> </div>
</div> </div>
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5 space-y-3">
<div class="w-10 h-10 bg-emerald-50 dark:bg-emerald-900/20 rounded-lg flex items-center justify-center">
<i class="pi pi-file-check text-emerald-600 dark:text-emerald-400 text-sm" aria-hidden="true" />
</div>
<div>
<p class="text-2xl font-bold text-slate-800 dark:text-slate-100">
<span v-if="carregando" class="inline-block w-8 h-7 bg-slate-200 dark:bg-slate-700 rounded animate-pulse" />
<template v-else>{{ resumo?.certidoesAtivas ?? 0 }}</template>
</p>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Certidões ativas</p>
</div>
</div>
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5 space-y-3">
<div class="w-10 h-10 bg-amber-50 dark:bg-amber-900/20 rounded-lg flex items-center justify-center">
<i class="pi pi-briefcase text-amber-600 dark:text-amber-400 text-sm" aria-hidden="true" />
</div>
<div>
<p class="text-2xl font-bold text-slate-800 dark:text-slate-100">
<span v-if="carregando" class="inline-block w-8 h-7 bg-slate-200 dark:bg-slate-700 rounded animate-pulse" />
<template v-else>{{ resumo?.alvarasAndamento ?? 0 }}</template>
</p>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Alvarás em andamento</p>
</div>
</div>
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5 space-y-3">
<div class="w-10 h-10 bg-primary/10 dark:bg-primary/20 rounded-lg flex items-center justify-center">
<i class="pi pi-credit-card text-primary text-sm" aria-hidden="true" />
</div>
<div>
<p class="text-2xl font-bold text-slate-800 dark:text-slate-100">
<span v-if="carregando" class="inline-block w-16 h-7 bg-slate-200 dark:bg-slate-700 rounded animate-pulse" />
<template v-else>{{ formatarMoeda(resumo?.ultimoPagamento) }}</template>
</p>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Último pagamento</p>
</div>
</div>
</div> </div>
<!-- Placeholder conteúdo --> <!-- Alerta de débitos vencidos -->
<div class="bg-blue-50 border border-blue-100 rounded-xl p-6 text-center"> <div
<i class="pi pi-info-circle text-blue-500 text-2xl mb-3 block" /> v-if="!carregando && resumo?.debitosVencidos > 0"
<p class="text-sm text-slate-600"> class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700/50 rounded-xl p-4 flex items-center gap-4"
Os dados do painel serão carregados quando a integração com a API estiver configurada. >
</p> <i class="pi pi-exclamation-triangle text-red-600 dark:text-red-400 text-xl flex-shrink-0" aria-hidden="true" />
<div class="flex-1">
<p class="font-semibold text-red-800 dark:text-red-300 text-sm">
{{ resumo.debitosVencidos }} débito{{ resumo.debitosVencidos > 1 ? 's' : '' }} vencido{{ resumo.debitosVencidos > 1 ? 's' : '' }}
</p>
<p class="text-xs text-red-600 dark:text-red-400 mt-0.5">Regularize para evitar juros e negativação.</p>
</div>
<Button label="Ver débitos" size="small" severity="danger" @click="router.push({ name: 'debitos' })" />
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Acesso rápido -->
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6">
<p class="text-sm font-bold text-slate-700 dark:text-slate-200 mb-4">Acesso rápido</p>
<div class="grid grid-cols-2 gap-3">
<button
v-for="a in acesRapidos"
:key="a.label"
class="flex flex-col items-center gap-2 p-4 rounded-xl border border-slate-100 dark:border-slate-700 hover:border-primary/40 hover:bg-primary/5 dark:hover:bg-primary/10 transition-colors group"
@click="router.push({ name: a.to })"
>
<i :class="['pi', a.icon, a.cor, 'text-xl']" aria-hidden="true" />
<span class="text-xs font-semibold text-slate-600 dark:text-slate-300 text-center leading-tight group-hover:text-primary transition-colors">{{ a.label }}</span>
</button>
</div>
</div>
<!-- Atividades recentes -->
<div class="lg:col-span-2 bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6">
<p class="text-sm font-bold text-slate-700 dark:text-slate-200 mb-4">Atividade recente</p>
<div v-if="carregando" class="space-y-3">
<div v-for="i in 4" :key="i" class="flex gap-3 items-center">
<div class="w-8 h-8 bg-slate-200 dark:bg-slate-700 rounded-lg animate-pulse flex-shrink-0" />
<div class="flex-1 space-y-1.5">
<div class="h-3 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-3/4" />
<div class="h-2.5 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-1/2" />
</div>
</div>
</div>
<div v-else-if="atividades.length === 0" class="text-center py-6">
<i class="pi pi-inbox text-slate-300 dark:text-slate-600 text-3xl mb-2 block" aria-hidden="true" />
<p class="text-sm text-slate-400 dark:text-slate-500">Nenhuma atividade registrada.</p>
</div>
<div v-else class="space-y-1">
<div
v-for="(ativ, i) in atividades"
:key="i"
class="flex items-center gap-3 py-3 border-b border-slate-100 dark:border-slate-700 last:border-0"
>
<div class="w-8 h-8 bg-primary/8 dark:bg-primary/15 rounded-lg flex items-center justify-center flex-shrink-0">
<i :class="['pi', iconeAtividade[ativ.tipo] ?? 'pi-circle', 'text-primary text-sm']" aria-hidden="true" />
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-slate-800 dark:text-slate-100 truncate">{{ ativ.descricao }}</p>
<p class="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{{ ativ.data }}</p>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>