Substitui o portal Vite+Vue puro por Nuxt 3 com BFF embutido (Nitro server
routes) e fluxo de autenticação Keycloak via token-handler pattern.
Server (BFF):
- server/api/auth/{login,callback,refresh,logout,me}.ts — Keycloak PKCE
- server/api/proxy/[...path].ts — proxy autenticado pro core-api com tenant
- server/utils/{session,keycloak,pkce,redis,tenant,prefeitura}.ts
- server/middleware/csrf.ts — Origin check + header X-Requested-With
Auth (token-handler pattern):
- JWT vive só server-side em Redis; cliente recebe cookie session-id opaco
- Refresh transparente quando access_token expira
- Multi-tenant via hostname → X-Municipio/X-Dominio injetados no proxy
- Realm dedicado: modumfiscal-portal-{env}
Frontend (Nuxt):
- src/pages/** (file-based routing) substitui src/views/
- Plugins SSR: prefeitura (bootstrap pré-hidratação) + auth (hidrata user via /api/auth/me)
- Composables useAuth, useApi, useLoginModal, useFocusLoginInput
- Modal global de login quando middleware /portal/** bloqueia
- Splash overlay no boot esconde flash do preset inicial pro tema dinâmico
- DocumentoInput bloqueia campo quando user autenticado (pré-preenche em certidão/IPTU)
Removidos:
- index.html, vite.config.js, src/main.js, src/router/
- src/config/apiClient.js (substituído por \$fetch via /api/proxy)
- src/services/{auth,prefeitura}Service.js (lógica migrada pra composables/plugins)
- src/mocks/ (não mais usado)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
272 lines
12 KiB
Vue
272 lines
12 KiB
Vue
<script setup>
|
|
import { ref } from 'vue'
|
|
import { useAuth } from '@/composables/useAuth'
|
|
import { iptuService } from '@/services/iptuService'
|
|
|
|
const router = useRouter()
|
|
const { isAuthenticated, documento: docUsuarioLogado, nomeUsuario } = useAuth()
|
|
|
|
const modoConsulta = ref('documento')
|
|
const documento = ref(isAuthenticated.value ? docUsuarioLogado.value : '')
|
|
const inscricao = ref('')
|
|
const etapa = ref('formulario')
|
|
const carregando = ref(false)
|
|
const carregandoPdf = ref(null)
|
|
const imoveis = ref([])
|
|
const imovelSelecionado = ref(null)
|
|
const mensagemErro = ref('')
|
|
|
|
const exercicioAtual = new Date().getFullYear()
|
|
|
|
async function consultar() {
|
|
carregando.value = true
|
|
mensagemErro.value = ''
|
|
imoveis.value = []
|
|
imovelSelecionado.value = null
|
|
|
|
try {
|
|
const res = modoConsulta.value === 'documento'
|
|
? await iptuService.consultarPorDocumento(documento.value)
|
|
: await iptuService.consultarPorInscricao(inscricao.value)
|
|
|
|
imoveis.value = res.data ?? []
|
|
if (imoveis.value.length === 1) imovelSelecionado.value = imoveis.value[0]
|
|
etapa.value = 'resultado'
|
|
} catch (e) {
|
|
mensagemErro.value = e?.data?.description ?? 'Não foi possível localizar os imóveis. Verifique os dados e tente novamente.'
|
|
} finally {
|
|
carregando.value = false
|
|
}
|
|
}
|
|
|
|
async function emitirCarne(imovel) {
|
|
carregandoPdf.value = `carne-${imovel.inscricaoImobiliaria}`
|
|
try {
|
|
const buf = await iptuService.emitirCarne(imovel.inscricaoImobiliaria, exercicioAtual)
|
|
const url = URL.createObjectURL(new Blob([buf], { type: 'application/pdf' }))
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = `carne-iptu-${imovel.inscricaoImobiliaria}-${exercicioAtual}.pdf`
|
|
a.click()
|
|
URL.revokeObjectURL(url)
|
|
} catch {
|
|
mensagemErro.value = 'Erro ao gerar o carnê. Tente novamente.'
|
|
} finally {
|
|
carregandoPdf.value = null
|
|
}
|
|
}
|
|
|
|
async function emitirBoleto(debito) {
|
|
carregandoPdf.value = `boleto-${debito.id}`
|
|
try {
|
|
const buf = await iptuService.emitirBoleto(debito.id)
|
|
const url = URL.createObjectURL(new Blob([buf], { type: 'application/pdf' }))
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = `boleto-iptu-${debito.id}.pdf`
|
|
a.click()
|
|
URL.revokeObjectURL(url)
|
|
} catch {
|
|
mensagemErro.value = 'Erro ao gerar o boleto. Tente novamente.'
|
|
} finally {
|
|
carregandoPdf.value = null
|
|
}
|
|
}
|
|
|
|
function reiniciar() {
|
|
documento.value = ''
|
|
inscricao.value = ''
|
|
imoveis.value = []
|
|
imovelSelecionado.value = null
|
|
mensagemErro.value = ''
|
|
etapa.value = 'formulario'
|
|
}
|
|
|
|
function formatarMoeda(valor) {
|
|
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(valor ?? 0)
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="max-w-2xl mx-auto px-4 sm:px-6 py-12">
|
|
|
|
<button
|
|
class="inline-flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200 transition-colors mb-8 py-1"
|
|
@click="router.push('/servicos')"
|
|
>
|
|
<i class="pi pi-arrow-left text-xs" aria-hidden="true" />
|
|
Voltar aos serviços
|
|
</button>
|
|
|
|
<div class="flex items-center gap-4 mb-8">
|
|
<div class="w-12 h-12 bg-primary/10 dark:bg-primary/20 rounded-xl flex items-center justify-center flex-shrink-0">
|
|
<i class="pi pi-home text-primary text-xl" aria-hidden="true" />
|
|
</div>
|
|
<div>
|
|
<h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100">IPTU — Débitos e Carnê</h1>
|
|
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">Consulte débitos do imóvel e imprima o carnê ou boleto avulso.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="etapa === 'formulario'" class="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 p-8 space-y-6">
|
|
|
|
<div>
|
|
<p class="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-3">Buscar por</p>
|
|
<div class="flex gap-3">
|
|
<button
|
|
:class="[
|
|
'flex-1 py-2.5 rounded-lg border text-sm font-semibold transition-colors',
|
|
modoConsulta === 'documento'
|
|
? 'bg-primary text-white border-primary'
|
|
: 'bg-white dark:bg-slate-700 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-primary/40'
|
|
]"
|
|
@click="modoConsulta = 'documento'"
|
|
>
|
|
CPF / CNPJ
|
|
</button>
|
|
<button
|
|
:class="[
|
|
'flex-1 py-2.5 rounded-lg border text-sm font-semibold transition-colors',
|
|
modoConsulta === 'inscricao'
|
|
? 'bg-primary text-white border-primary'
|
|
: 'bg-white dark:bg-slate-700 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-600 hover:border-primary/40'
|
|
]"
|
|
@click="modoConsulta = 'inscricao'"
|
|
>
|
|
Inscrição Imobiliária
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">
|
|
{{ modoConsulta === 'documento' ? 'CPF ou CNPJ do proprietário' : 'Número da inscrição imobiliária' }}
|
|
</label>
|
|
<DocumentoInput
|
|
v-if="modoConsulta === 'documento'"
|
|
v-model="documento"
|
|
:disabled="isAuthenticated"
|
|
@keyup.enter="consultar"
|
|
/>
|
|
<InputText
|
|
v-else
|
|
v-model="inscricao"
|
|
placeholder="Ex.: 0001.001.0001.001"
|
|
class="w-full"
|
|
size="large"
|
|
@keyup.enter="consultar"
|
|
/>
|
|
<p v-if="modoConsulta === 'documento' && isAuthenticated" class="mt-1.5 text-xs text-slate-500 dark:text-slate-400 flex items-center gap-1.5">
|
|
<i class="pi pi-info-circle text-xs text-primary" aria-hidden="true" />
|
|
Logado como <strong class="text-slate-700 dark:text-slate-200">{{ nomeUsuario }}</strong> — documento bloqueado para segurança.
|
|
</p>
|
|
</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>
|
|
|
|
<Button
|
|
label="Consultar imóveis"
|
|
icon="pi pi-search"
|
|
class="w-full"
|
|
size="large"
|
|
:loading="carregando"
|
|
:disabled="modoConsulta === 'documento' ? documento.replace(/\D/g,'').length < 11 : !inscricao.trim()"
|
|
@click="consultar"
|
|
/>
|
|
</div>
|
|
|
|
<div v-else-if="etapa === 'resultado'" class="space-y-4">
|
|
|
|
<div v-if="imoveis.length > 1" class="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 p-6">
|
|
<p class="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-3">
|
|
{{ imoveis.length }} imóveis encontrados — selecione um
|
|
</p>
|
|
<div class="space-y-2">
|
|
<button
|
|
v-for="imovel in imoveis"
|
|
:key="imovel.inscricaoImobiliaria"
|
|
class="w-full text-left p-4 rounded-xl border transition-colors"
|
|
:class="imovelSelecionado?.inscricaoImobiliaria === imovel.inscricaoImobiliaria
|
|
? 'border-primary bg-primary/5 dark:bg-primary/10'
|
|
: 'border-slate-200 dark:border-slate-600 hover:border-slate-300 dark:hover:border-slate-500'"
|
|
@click="imovelSelecionado = imovel"
|
|
>
|
|
<p class="font-semibold text-slate-800 dark:text-slate-100 text-sm">{{ imovel.enderecoCompleto }}</p>
|
|
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Inscrição: {{ imovel.inscricaoImobiliaria }}</p>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<template v-if="imovelSelecionado">
|
|
<div class="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 p-6">
|
|
<div class="flex items-start justify-between gap-4 mb-4">
|
|
<div>
|
|
<p class="font-bold text-slate-800 dark:text-slate-100">{{ imovelSelecionado.enderecoCompleto }}</p>
|
|
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Inscrição: {{ imovelSelecionado.inscricaoImobiliaria }}</p>
|
|
</div>
|
|
<Button
|
|
:label="`Carnê ${exercicioAtual}`"
|
|
icon="pi pi-download"
|
|
size="small"
|
|
outlined
|
|
:loading="carregandoPdf === `carne-${imovelSelecionado.inscricaoImobiliaria}`"
|
|
@click="emitirCarne(imovelSelecionado)"
|
|
/>
|
|
</div>
|
|
|
|
<div v-if="imovelSelecionado.debitos?.length" class="mt-4">
|
|
<p class="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-3">Débitos em aberto</p>
|
|
<div class="space-y-2">
|
|
<div
|
|
v-for="debito in imovelSelecionado.debitos"
|
|
:key="debito.id"
|
|
class="flex items-center justify-between gap-4 py-3 border-b border-slate-100 dark:border-slate-700 last:border-0"
|
|
>
|
|
<div>
|
|
<p class="text-sm font-semibold text-slate-800 dark:text-slate-100">{{ debito.descricao }}</p>
|
|
<p class="text-xs text-slate-500 dark:text-slate-400">Venc.: {{ debito.vencimento }}</p>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<p class="text-sm font-bold text-slate-800 dark:text-slate-100 whitespace-nowrap">
|
|
{{ formatarMoeda(debito.valor) }}
|
|
</p>
|
|
<Button
|
|
icon="pi pi-download"
|
|
size="small"
|
|
text
|
|
aria-label="Emitir boleto"
|
|
:loading="carregandoPdf === `boleto-${debito.id}`"
|
|
@click="emitirBoleto(debito)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<p v-else class="text-sm text-emerald-600 dark:text-emerald-400 font-medium flex items-center gap-2 mt-2">
|
|
<i class="pi pi-check-circle" aria-hidden="true" />
|
|
Nenhum débito em aberto para este imóvel.
|
|
</p>
|
|
</div>
|
|
</template>
|
|
|
|
<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>
|
|
|
|
<Button
|
|
label="Nova consulta"
|
|
severity="secondary"
|
|
icon="pi pi-arrow-left"
|
|
outlined
|
|
class="w-full"
|
|
@click="reiniciar"
|
|
/>
|
|
</div>
|
|
|
|
</div>
|
|
</template>
|