feat: portal autenticado — Certidões, Alvarás, Pagamentos e Dados Cadastrais
CertidoesView: lista com status (Ativa/Vencida/Cancelada), reemissão de PDF por blob, skeleton loading, CTA para nova certidão AlvarasView: filtro por status (chip buttons), card por alvará com timeline visual de etapas (concluída/atual/pendente), skeleton PagamentosView: filtro por ano, tabela com forma de pagamento (Boleto/Pix/Cartão), download de comprovante, valor em verde DadosView: seções de Identificação e Endereço (somente leitura), contato editável inline (email + telefone + WhatsApp), feedback de sucesso Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fed6998a6f
commit
f687e650b8
@ -1,12 +1,143 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { portalService } from '@/services/portalService'
|
||||
|
||||
const alvaras = ref([])
|
||||
const carregando = ref(true)
|
||||
const mensagemErro = ref('')
|
||||
const filtroStatus = ref(null)
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'EM_ANALISE', label: 'Em análise' },
|
||||
{ value: 'AGUARDANDO_DOCUMENTOS', label: 'Aguardando documentos' },
|
||||
{ value: 'DEFERIDO', label: 'Deferido' },
|
||||
{ value: 'INDEFERIDO', label: 'Indeferido' },
|
||||
{ value: 'CANCELADO', label: 'Cancelado' },
|
||||
]
|
||||
|
||||
const statusMap = {
|
||||
EM_ANALISE: { label: 'Em análise', classe: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400', icone: 'pi-clock' },
|
||||
AGUARDANDO_DOCUMENTOS: { label: 'Aguard. docs', classe: 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400', icone: 'pi-file-edit' },
|
||||
DEFERIDO: { label: 'Deferido', classe: 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400', icone: 'pi-check-circle' },
|
||||
INDEFERIDO: { label: 'Indeferido', classe: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400', icone: 'pi-times-circle' },
|
||||
CANCELADO: { label: 'Cancelado', classe: 'bg-slate-100 dark:bg-slate-700 text-slate-500 dark:text-slate-400', icone: 'pi-ban' },
|
||||
}
|
||||
|
||||
onMounted(() => carregar())
|
||||
|
||||
async function carregar() {
|
||||
carregando.value = true
|
||||
mensagemErro.value = ''
|
||||
try {
|
||||
const params = filtroStatus.value ? { status: filtroStatus.value } : {}
|
||||
const { data } = await portalService.getAlvaras(params)
|
||||
alvaras.value = data.data?.content ?? []
|
||||
} catch (e) {
|
||||
mensagemErro.value = e.response?.data?.description ?? 'Não foi possível carregar os alvarás.'
|
||||
} finally {
|
||||
carregando.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-2xl font-bold text-slate-800">Alvarás</h1>
|
||||
<div class="bg-white rounded-xl border border-slate-200 p-8 text-center text-slate-400">
|
||||
<i class="pi pi-briefcase text-4xl mb-3 block" />
|
||||
<p>Acompanhamento de alvarás e processos — a ser implementado.</p>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100">Alvarás</h1>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">Acompanhe o andamento dos seus processos de alvará.</p>
|
||||
</div>
|
||||
|
||||
<!-- Filtro -->
|
||||
<div class="flex gap-3 flex-wrap">
|
||||
<button
|
||||
:class="['px-4 py-2 rounded-lg text-sm font-semibold border transition-colors', filtroStatus === null ? 'bg-primary text-white border-primary' : 'bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-700 hover:border-slate-300']"
|
||||
@click="filtroStatus = null; carregar()"
|
||||
>
|
||||
Todos
|
||||
</button>
|
||||
<button
|
||||
v-for="opt in statusOptions"
|
||||
:key="opt.value"
|
||||
:class="['px-4 py-2 rounded-lg text-sm font-semibold border transition-colors', filtroStatus === opt.value ? 'bg-primary text-white border-primary' : 'bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-700 hover:border-slate-300']"
|
||||
@click="filtroStatus = opt.value; carregar()"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||
|
||||
<div v-if="carregando" class="divide-y divide-slate-100 dark:divide-slate-700">
|
||||
<div v-for="i in 3" :key="i" class="p-5 space-y-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-4 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-1/3" />
|
||||
<div class="h-6 bg-slate-200 dark:bg-slate-700 rounded-full animate-pulse w-24 ml-auto" />
|
||||
</div>
|
||||
<div class="h-3 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-2/3" />
|
||||
<div class="h-2.5 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-1/4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="mensagemErro" class="p-8 text-center">
|
||||
<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>
|
||||
|
||||
<div v-else-if="alvaras.length === 0" class="p-12 text-center">
|
||||
<i class="pi pi-briefcase text-slate-300 dark:text-slate-600 text-4xl mb-3 block" aria-hidden="true" />
|
||||
<p class="font-semibold text-slate-700 dark:text-slate-200">Nenhum alvará encontrado</p>
|
||||
<p class="text-sm text-slate-400 dark:text-slate-500 mt-1">Solicitações de alvará aparecem aqui após o protocolo.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="divide-y divide-slate-100 dark:divide-slate-700">
|
||||
<div
|
||||
v-for="alv in alvaras"
|
||||
:key="alv.id"
|
||||
class="p-5 hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4 mb-2">
|
||||
<div>
|
||||
<p class="font-semibold text-slate-800 dark:text-slate-100 text-sm">{{ alv.tipo }}</p>
|
||||
<p class="text-xs text-slate-400 dark:text-slate-500 mt-0.5">Processo nº {{ alv.numeroProcesso }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<i :class="['pi', statusMap[alv.status]?.icone ?? 'pi-circle', 'text-xs', statusMap[alv.status]?.classe?.split(' ').find(c => c.startsWith('text'))]" aria-hidden="true" />
|
||||
<span :class="['text-xs font-semibold px-2 py-1 rounded-full whitespace-nowrap', statusMap[alv.status]?.classe ?? 'bg-slate-100 text-slate-500']">
|
||||
{{ statusMap[alv.status]?.label ?? alv.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timeline de etapas -->
|
||||
<div v-if="alv.etapas?.length" class="mt-4 flex items-center gap-1 overflow-x-auto pb-1">
|
||||
<template v-for="(etapa, idx) in alv.etapas" :key="idx">
|
||||
<div class="flex flex-col items-center gap-1 min-w-[72px]">
|
||||
<div
|
||||
class="w-6 h-6 rounded-full flex items-center justify-center text-xs"
|
||||
:class="etapa.concluida
|
||||
? 'bg-primary text-white'
|
||||
: etapa.atual
|
||||
? 'bg-primary/20 dark:bg-primary/30 text-primary border-2 border-primary'
|
||||
: 'bg-slate-100 dark:bg-slate-700 text-slate-400'"
|
||||
>
|
||||
<i v-if="etapa.concluida" class="pi pi-check text-xs" aria-hidden="true" />
|
||||
<span v-else class="text-xs font-bold">{{ idx + 1 }}</span>
|
||||
</div>
|
||||
<span class="text-xs text-slate-500 dark:text-slate-400 text-center leading-tight w-16">{{ etapa.nome }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="idx < alv.etapas.length - 1"
|
||||
class="flex-1 h-px min-w-[12px]"
|
||||
:class="alv.etapas[idx + 1].concluida || alv.etapas[idx + 1].atual ? 'bg-primary/40' : 'bg-slate-200 dark:bg-slate-700'"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<p v-if="alv.ultimaAtualizacao" class="text-xs text-slate-400 dark:text-slate-500 mt-3">
|
||||
Última atualização: {{ alv.ultimaAtualizacao }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -1,12 +1,127 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { portalService } from '@/services/portalService'
|
||||
|
||||
const router = useRouter()
|
||||
const certidoes = ref([])
|
||||
const carregando = ref(true)
|
||||
const carregandoPdf = ref(null)
|
||||
const mensagemErro = ref('')
|
||||
|
||||
onMounted(carregar)
|
||||
|
||||
async function carregar() {
|
||||
carregando.value = true
|
||||
mensagemErro.value = ''
|
||||
try {
|
||||
const { data } = await portalService.getCertidoes()
|
||||
certidoes.value = data.data?.content ?? []
|
||||
} catch (e) {
|
||||
mensagemErro.value = e.response?.data?.description ?? 'Não foi possível carregar as certidões.'
|
||||
} finally {
|
||||
carregando.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reemitir(cert) {
|
||||
carregandoPdf.value = cert.id
|
||||
try {
|
||||
const { data } = await portalService.reemitirCertidao(cert.id)
|
||||
const url = URL.createObjectURL(new Blob([data], { type: 'application/pdf' }))
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `certidao-${cert.numero}.pdf`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
mensagemErro.value = 'Erro ao reemitir a certidão.'
|
||||
} finally {
|
||||
carregandoPdf.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const statusMap = {
|
||||
ATIVA: { label: 'Ativa', classe: 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400' },
|
||||
VENCIDA: { label: 'Vencida', classe: 'bg-slate-100 dark:bg-slate-700 text-slate-500 dark:text-slate-400' },
|
||||
CANCELADA: { label: 'Cancelada', classe: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400' },
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-2xl font-bold text-slate-800">Certidões</h1>
|
||||
<div class="bg-white rounded-xl border border-slate-200 p-8 text-center text-slate-400">
|
||||
<i class="pi pi-file-edit text-4xl mb-3 block" />
|
||||
<p>Certidões ativas e reemissão — a ser implementado.</p>
|
||||
<div class="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100">Certidões</h1>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">Suas certidões emitidas e disponíveis para reemissão.</p>
|
||||
</div>
|
||||
<Button label="Nova certidão" icon="pi pi-plus" size="small" @click="router.push({ name: 'certidao' })" />
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||
|
||||
<div v-if="carregando" class="divide-y divide-slate-100 dark:divide-slate-700">
|
||||
<div v-for="i in 4" :key="i" class="p-5 flex items-center gap-4">
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="h-3.5 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-2/5" />
|
||||
<div class="h-3 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-1/4" />
|
||||
</div>
|
||||
<div class="h-6 bg-slate-200 dark:bg-slate-700 rounded-full animate-pulse w-14" />
|
||||
<div class="h-8 bg-slate-200 dark:bg-slate-700 rounded-lg animate-pulse w-24" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="mensagemErro" class="p-8 text-center">
|
||||
<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>
|
||||
|
||||
<div v-else-if="certidoes.length === 0" class="p-12 text-center">
|
||||
<i class="pi pi-file text-slate-300 dark:text-slate-600 text-4xl mb-3 block" aria-hidden="true" />
|
||||
<p class="font-semibold text-slate-700 dark:text-slate-200">Nenhuma certidão emitida</p>
|
||||
<p class="text-sm text-slate-400 dark:text-slate-500 mt-1 mb-4">Emita sua primeira certidão pelo portal público.</p>
|
||||
<Button label="Emitir certidão" size="small" @click="router.push({ name: 'certidao' })" />
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<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">
|
||||
<span class="flex-1">Certidão</span>
|
||||
<span class="hidden sm:block w-28 text-right">Emissão</span>
|
||||
<span class="hidden sm:block w-28 text-right">Validade</span>
|
||||
<span class="w-20 text-center">Status</span>
|
||||
<span class="w-24" />
|
||||
</div>
|
||||
<div class="divide-y divide-slate-100 dark:divide-slate-700">
|
||||
<div
|
||||
v-for="cert in certidoes"
|
||||
:key="cert.id"
|
||||
class="flex items-center gap-4 px-5 py-4 hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-semibold text-slate-800 dark:text-slate-100 truncate">{{ cert.tipo }}</p>
|
||||
<p class="text-xs text-slate-400 dark:text-slate-500 mt-0.5">Nº {{ cert.numero }}</p>
|
||||
</div>
|
||||
<p class="hidden sm:block text-sm text-slate-500 dark:text-slate-400 w-28 text-right">{{ cert.dataEmissao }}</p>
|
||||
<p class="hidden sm:block text-sm text-slate-500 dark:text-slate-400 w-28 text-right">{{ cert.dataValidade }}</p>
|
||||
<div class="w-20 flex justify-center">
|
||||
<span :class="['text-xs font-semibold px-2 py-1 rounded-full', statusMap[cert.status]?.classe ?? 'bg-slate-100 text-slate-500']">
|
||||
{{ statusMap[cert.status]?.label ?? cert.status }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-24 flex justify-end">
|
||||
<Button
|
||||
icon="pi pi-download"
|
||||
label="PDF"
|
||||
size="small"
|
||||
outlined
|
||||
:loading="carregandoPdf === cert.id"
|
||||
:disabled="cert.status === 'CANCELADA'"
|
||||
@click="reemitir(cert)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -1,12 +1,194 @@
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { portalService } from '@/services/portalService'
|
||||
|
||||
const carregando = ref(true)
|
||||
const salvando = ref(false)
|
||||
const modoEditar = ref(false)
|
||||
const mensagemErro = ref('')
|
||||
const mensagemSucesso = ref('')
|
||||
|
||||
const dados = ref(null)
|
||||
const contato = reactive({ email: '', telefone: '', whatsapp: false })
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const { data } = await portalService.getDadosCadastrais()
|
||||
dados.value = data.data
|
||||
contato.email = dados.value?.email ?? ''
|
||||
contato.telefone = dados.value?.telefone ?? ''
|
||||
contato.whatsapp = dados.value?.whatsapp ?? false
|
||||
} catch (e) {
|
||||
mensagemErro.value = e.response?.data?.description ?? 'Não foi possível carregar os dados cadastrais.'
|
||||
} finally {
|
||||
carregando.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function salvarContato() {
|
||||
salvando.value = true
|
||||
mensagemErro.value = ''
|
||||
mensagemSucesso.value = ''
|
||||
try {
|
||||
await portalService.atualizarContato({
|
||||
email: contato.email,
|
||||
telefone: contato.telefone.replace(/\D/g, ''),
|
||||
whatsapp: contato.whatsapp,
|
||||
})
|
||||
dados.value.email = contato.email
|
||||
dados.value.telefone = contato.telefone
|
||||
dados.value.whatsapp = contato.whatsapp
|
||||
mensagemSucesso.value = 'Dados de contato atualizados com sucesso!'
|
||||
modoEditar.value = false
|
||||
} catch (e) {
|
||||
mensagemErro.value = e.response?.data?.description ?? 'Erro ao salvar. Tente novamente.'
|
||||
} finally {
|
||||
salvando.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancelarEdicao() {
|
||||
contato.email = dados.value?.email ?? ''
|
||||
contato.telefone = dados.value?.telefone ?? ''
|
||||
contato.whatsapp = dados.value?.whatsapp ?? false
|
||||
modoEditar.value = false
|
||||
mensagemErro.value = ''
|
||||
}
|
||||
|
||||
function formatarTelefone(e) {
|
||||
const d = e.target.value.replace(/\D/g, '').slice(0, 11)
|
||||
contato.telefone = d
|
||||
.replace(/(\d{2})(\d)/, '($1) $2')
|
||||
.replace(/(\d{5})(\d{1,4})$/, '$1-$2')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-2xl font-bold text-slate-800">Dados Cadastrais</h1>
|
||||
<div class="bg-white rounded-xl border border-slate-200 p-8 text-center text-slate-400">
|
||||
<i class="pi pi-user text-4xl mb-3 block" />
|
||||
<p>Visualização e atualização de dados cadastrais — a ser implementado.</p>
|
||||
<div class="space-y-6 max-w-2xl">
|
||||
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100">Dados Cadastrais</h1>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">Visualize seus dados e mantenha o contato atualizado.</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="carregando" class="space-y-4">
|
||||
<div v-for="i in 2" :key="i" class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6 space-y-4">
|
||||
<div class="h-4 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-1/4" />
|
||||
<div class="space-y-3">
|
||||
<div class="h-3.5 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-3/4" />
|
||||
<div class="h-3.5 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else-if="dados">
|
||||
|
||||
<!-- Dados gerais (somente leitura) -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6">
|
||||
<p class="text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-4">Identificação</p>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-xs text-slate-400 dark:text-slate-500">{{ dados.tipoPessoa === 'JURIDICA' ? 'CNPJ' : 'CPF' }}</p>
|
||||
<p class="text-sm font-semibold text-slate-800 dark:text-slate-100 mt-0.5 font-mono">{{ dados.documento }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-slate-400 dark:text-slate-500">{{ dados.tipoPessoa === 'JURIDICA' ? 'Razão Social' : 'Nome completo' }}</p>
|
||||
<p class="text-sm font-semibold text-slate-800 dark:text-slate-100 mt-0.5">{{ dados.nomeCompleto }}</p>
|
||||
</div>
|
||||
<div v-if="dados.nomeFantasia">
|
||||
<p class="text-xs text-slate-400 dark:text-slate-500">Nome fantasia</p>
|
||||
<p class="text-sm font-semibold text-slate-800 dark:text-slate-100 mt-0.5">{{ dados.nomeFantasia }}</p>
|
||||
</div>
|
||||
<div v-if="dados.dataNascimento">
|
||||
<p class="text-xs text-slate-400 dark:text-slate-500">Data de nascimento</p>
|
||||
<p class="text-sm font-semibold text-slate-800 dark:text-slate-100 mt-0.5">{{ dados.dataNascimento }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Endereço (somente leitura) -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6">
|
||||
<p class="text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-4">Endereço</p>
|
||||
<p class="text-sm text-slate-800 dark:text-slate-100">
|
||||
{{ dados.endereco?.logradouro }}, {{ dados.endereco?.numero }}
|
||||
<template v-if="dados.endereco?.complemento"> — {{ dados.endereco.complemento }}</template>
|
||||
</p>
|
||||
<p class="text-sm text-slate-600 dark:text-slate-300 mt-0.5">
|
||||
{{ dados.endereco?.bairro }} — {{ dados.endereco?.cidade }}/{{ dados.endereco?.uf }}
|
||||
</p>
|
||||
<p class="text-xs text-slate-400 dark:text-slate-500 mt-1">CEP: {{ dados.endereco?.cep }}</p>
|
||||
<p class="text-xs text-slate-400 dark:text-slate-500 mt-3">
|
||||
Para alterar o endereço, compareça ao setor de atendimento da Prefeitura.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Contato (editável) -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<p class="text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-wide">Contato</p>
|
||||
<Button
|
||||
v-if="!modoEditar"
|
||||
label="Editar"
|
||||
icon="pi pi-pencil"
|
||||
size="small"
|
||||
text
|
||||
@click="modoEditar = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Modo visualização -->
|
||||
<div v-if="!modoEditar" class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-xs text-slate-400 dark:text-slate-500">E-mail</p>
|
||||
<p class="text-sm font-semibold text-slate-800 dark:text-slate-100 mt-0.5">{{ dados.email }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-slate-400 dark:text-slate-500">Telefone</p>
|
||||
<p class="text-sm font-semibold text-slate-800 dark:text-slate-100 mt-0.5">
|
||||
{{ dados.telefone }}
|
||||
<span v-if="dados.whatsapp" class="ml-1.5 text-xs text-emerald-600 dark:text-emerald-400 font-normal">(WhatsApp)</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modo edição -->
|
||||
<div v-else class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">E-mail</label>
|
||||
<InputText v-model="contato.email" type="email" class="w-full" size="large" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">Telefone / Celular</label>
|
||||
<InputText :value="contato.telefone" class="w-full" size="large" inputmode="numeric" @input="formatarTelefone" />
|
||||
</div>
|
||||
<div class="flex items-center gap-3 p-4 bg-slate-50 dark:bg-slate-700/50 rounded-xl">
|
||||
<Checkbox v-model="contato.whatsapp" :binary="true" input-id="whatsapp-dados" />
|
||||
<label for="whatsapp-dados" class="text-sm text-slate-700 dark:text-slate-200 cursor-pointer">
|
||||
Este número também recebe WhatsApp
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="mensagemErro" role="alert" class="text-sm text-red-600 dark:text-red-400 flex items-center gap-1.5">
|
||||
<i class="pi pi-exclamation-circle" aria-hidden="true" /> {{ mensagemErro }}
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<Button label="Cancelar" severity="secondary" outlined class="flex-1" @click="cancelarEdicao" />
|
||||
<Button label="Salvar" icon="pi pi-check" class="flex-1" :loading="salvando" @click="salvarContato" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sucesso -->
|
||||
<p v-if="mensagemSucesso && !modoEditar" role="status" class="mt-4 text-sm text-emerald-600 dark:text-emerald-400 flex items-center gap-1.5">
|
||||
<i class="pi pi-check-circle" aria-hidden="true" /> {{ mensagemSucesso }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<!-- Erro global -->
|
||||
<div v-else-if="mensagemErro" class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-8 text-center">
|
||||
<p class="text-sm text-slate-600 dark:text-slate-300">{{ mensagemErro }}</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -1,12 +1,143 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { portalService } from '@/services/portalService'
|
||||
|
||||
const pagamentos = ref([])
|
||||
const carregando = ref(true)
|
||||
const carregandoComprovante = ref(null)
|
||||
const mensagemErro = ref('')
|
||||
const filtroAno = ref(new Date().getFullYear())
|
||||
|
||||
const anosDisponiveis = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i)
|
||||
|
||||
onMounted(() => carregar())
|
||||
|
||||
async function carregar() {
|
||||
carregando.value = true
|
||||
mensagemErro.value = ''
|
||||
try {
|
||||
const { data } = await portalService.getPagamentos({ ano: filtroAno.value })
|
||||
pagamentos.value = data.data?.content ?? []
|
||||
} catch (e) {
|
||||
mensagemErro.value = e.response?.data?.description ?? 'Não foi possível carregar os pagamentos.'
|
||||
} finally {
|
||||
carregando.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function baixarComprovante(pag) {
|
||||
carregandoComprovante.value = pag.id
|
||||
try {
|
||||
const { data } = await portalService.getComprovante(pag.id)
|
||||
const url = URL.createObjectURL(new Blob([data], { type: 'application/pdf' }))
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `comprovante-${pag.id}.pdf`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
mensagemErro.value = 'Erro ao baixar o comprovante.'
|
||||
} finally {
|
||||
carregandoComprovante.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function formatarMoeda(valor) {
|
||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(valor ?? 0)
|
||||
}
|
||||
|
||||
const formaPagMap = {
|
||||
BOLETO: { label: 'Boleto', icone: 'pi-barcode' },
|
||||
PIX: { label: 'Pix', icone: 'pi-qrcode' },
|
||||
CARTAO: { label: 'Cartão', icone: 'pi-credit-card' },
|
||||
TRANSFERENCIA: { label: 'Transferência', icone: 'pi-arrow-right-arrow-left' },
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-2xl font-bold text-slate-800">Histórico de Pagamentos</h1>
|
||||
<div class="bg-white rounded-xl border border-slate-200 p-8 text-center text-slate-400">
|
||||
<i class="pi pi-credit-card text-4xl mb-3 block" />
|
||||
<p>Histórico de pagamentos com comprovantes — a ser implementado.</p>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100">Histórico de Pagamentos</h1>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">Todos os seus pagamentos com comprovantes para download.</p>
|
||||
</div>
|
||||
|
||||
<!-- Filtro de ano -->
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<button
|
||||
v-for="ano in anosDisponiveis"
|
||||
:key="ano"
|
||||
:class="['px-4 py-2 rounded-lg text-sm font-semibold border transition-colors', filtroAno === ano ? 'bg-primary text-white border-primary' : 'bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-700 hover:border-slate-300']"
|
||||
@click="filtroAno = ano; carregar()"
|
||||
>
|
||||
{{ ano }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||
|
||||
<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="flex-1 space-y-2">
|
||||
<div class="h-3.5 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-2/5" />
|
||||
<div class="h-3 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-1/4" />
|
||||
</div>
|
||||
<div class="h-4 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-20" />
|
||||
<div class="h-8 bg-slate-200 dark:bg-slate-700 rounded-lg animate-pulse w-28" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="mensagemErro" class="p-8 text-center">
|
||||
<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>
|
||||
|
||||
<div v-else-if="pagamentos.length === 0" class="p-12 text-center">
|
||||
<i class="pi pi-credit-card text-slate-300 dark:text-slate-600 text-4xl mb-3 block" aria-hidden="true" />
|
||||
<p class="font-semibold text-slate-700 dark:text-slate-200">Nenhum pagamento em {{ filtroAno }}</p>
|
||||
<p class="text-sm text-slate-400 dark:text-slate-500 mt-1">Pagamentos realizados aparecerão aqui.</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<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">
|
||||
<span class="flex-1">Descrição</span>
|
||||
<span class="hidden sm:block w-28 text-right">Data</span>
|
||||
<span class="hidden sm:block w-24 text-center">Forma</span>
|
||||
<span class="w-28 text-right">Valor</span>
|
||||
<span class="w-28" />
|
||||
</div>
|
||||
<div class="divide-y divide-slate-100 dark:divide-slate-700">
|
||||
<div
|
||||
v-for="pag in pagamentos"
|
||||
:key="pag.id"
|
||||
class="flex items-center gap-4 px-5 py-4 hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-semibold text-slate-800 dark:text-slate-100 truncate">{{ pag.descricao }}</p>
|
||||
<p class="text-xs text-slate-400 dark:text-slate-500 mt-0.5">Ref: {{ pag.referencia }}</p>
|
||||
</div>
|
||||
<p class="hidden sm:block text-sm text-slate-500 dark:text-slate-400 w-28 text-right whitespace-nowrap">{{ pag.dataPagamento }}</p>
|
||||
<div class="hidden sm:flex w-24 justify-center">
|
||||
<span class="inline-flex items-center gap-1.5 text-xs text-slate-500 dark:text-slate-400 bg-slate-100 dark:bg-slate-700 px-2 py-1 rounded-full">
|
||||
<i :class="['pi', formaPagMap[pag.formaPagamento]?.icone ?? 'pi-circle', 'text-xs']" aria-hidden="true" />
|
||||
{{ formaPagMap[pag.formaPagamento]?.label ?? pag.formaPagamento }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm font-bold text-emerald-600 dark:text-emerald-400 w-28 text-right whitespace-nowrap">
|
||||
{{ formatarMoeda(pag.valor) }}
|
||||
</p>
|
||||
<div class="w-28 flex justify-end">
|
||||
<Button
|
||||
icon="pi pi-download"
|
||||
label="Comprovante"
|
||||
size="small"
|
||||
text
|
||||
:loading="carregandoComprovante === pag.id"
|
||||
@click="baixarComprovante(pag)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user