Merge branch 'developer' of https://git.modumsolucao.com.br/ModumSolucao/portal-modumfiscal-web into developer
Some checks failed
Dev Build & Deploy Portal / build-deploy (push) Has been cancelled

This commit is contained in:
Joao Guilherme de Mesquita Mendes 2026-09-25 10:53:13 -03:00
commit 0ec38639ab
11 changed files with 371 additions and 28 deletions

View File

@ -16,11 +16,16 @@ export default defineEventHandler(async (event) => {
const { codeVerifier, codeChallenge, state } = await generatePkce()
const returnTo = body.data.returnTo ?? '/portal/painel'
await savePkceState(state, {
codeVerifier,
returnTo,
createdAt: Date.now(),
})
try {
await savePkceState(state, {
codeVerifier,
returnTo,
createdAt: Date.now(),
})
} catch (err) {
console.error('[auth/login] falha ao salvar estado PKCE (Redis indisponível?):', (err as Error).message)
throw createError({ statusCode: 503, statusMessage: 'Serviço temporariamente indisponível. Tente novamente em instantes.' })
}
const redirectUri = callbackUrlFromEvent(event)
const authUrl = buildAuthUrl({

View File

@ -97,6 +97,9 @@ export default defineEventHandler(async (event) => {
setResponseHeader(event, 'content-type', 'application/json; charset=utf-8')
return body
}
throw err
// Erro de rede (backend inacessível — ECONNREFUSED, timeout, etc.)
console.error(`[proxy] backend inacessível: ${url}`, (err as Error).message)
throw createError({ statusCode: 503, statusMessage: 'Sistema temporariamente indisponível.' })
}
})

View File

@ -24,10 +24,18 @@ export function useExtratoDebitosPortal() {
{ label: 'Saldo Crédito', value: 3 },
]
const ESTADO_DEBITO = 1
const temSelecionado = computed(() =>
resultados.value.some(t => t.selecionados?.length > 0)
)
const temDebitoSelecionado = computed(() =>
resultados.value.some(t =>
t.selecionados?.some(d => d.codigoEstadoConta === ESTADO_DEBITO)
)
)
const totalizadores = computed(() => {
let principal = 0, multa = 0, juros = 0, valorTotal = 0
resultados.value.forEach(t => {
@ -85,7 +93,13 @@ export function useExtratoDebitosPortal() {
function montarDtoGuia() {
return {
listaDebitos: resultados.value
.filter(t => t.selecionados?.length > 0)
.map(t => ({
...t,
selecionados: (t.selecionados || []).filter(
d => d.codigoEstadoConta === ESTADO_DEBITO
),
}))
.filter(t => t.selecionados.length > 0)
.map(t => ({
idTributo: t.idTributo,
identificador: t.identificador,
@ -139,8 +153,8 @@ export function useExtratoDebitosPortal() {
}
async function gerarGuia() {
if (!temSelecionado.value) {
mensagemErro.value = 'Selecione pelo menos um débito.'
if (!temDebitoSelecionado.value) {
mensagemErro.value = 'Selecione pelo menos um débito em aberto. Parcelas quitadas ou com saldo zerado não geram guia.'
return
}
if (!dataVencimento.value) {
@ -202,6 +216,7 @@ export function useExtratoDebitosPortal() {
filtro,
opcoesEstadoConta,
temSelecionado,
temDebitoSelecionado,
totalizadores,
consultar,
limparFiltros,

58
src/error.vue Normal file
View File

@ -0,0 +1,58 @@
<script setup>
const props = defineProps({
error: {
type: Object,
default: () => ({}),
},
})
const is404 = computed(() => props.error?.statusCode === 404)
function voltar() {
clearError({ redirect: '/' })
}
</script>
<template>
<div class="min-h-screen bg-slate-50 dark:bg-slate-950 flex flex-col items-center justify-center px-4">
<div class="w-full max-w-md text-center">
<div class="w-20 h-20 mx-auto mb-6 rounded-2xl flex items-center justify-center"
:class="is404 ? 'bg-slate-100 dark:bg-slate-800' : 'bg-amber-50 dark:bg-amber-900/20'">
<svg v-if="is404" xmlns="http://www.w3.org/2000/svg" class="w-10 h-10 text-slate-400 dark:text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="w-10 h-10 text-amber-500 dark:text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17 17.25 21A2.652 2.652 0 0 0 21 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 1 1-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 0 0 4.486-6.336l-3.276 3.277a3.004 3.004 0 0 1-2.25-2.25l3.276-3.276a4.5 4.5 0 0 0-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437 1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008Z" />
</svg>
</div>
<h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100 mb-3">
{{ is404 ? 'Página não encontrada' : 'Portal temporariamente indisponível' }}
</h1>
<p class="text-slate-500 dark:text-slate-400 text-sm leading-relaxed mb-8">
<template v-if="is404">
O endereço que você acessou não existe ou foi movido.
Verifique o link e tente novamente.
</template>
<template v-else>
Estamos realizando manutenção ou enfrentando uma instabilidade momentânea.
Aguarde alguns minutos e tente novamente. Se o problema persistir,
entre em contato com a prefeitura.
</template>
</p>
<button
class="inline-flex items-center gap-2 px-6 py-3 rounded-xl bg-primary text-white font-semibold text-sm hover:bg-primary/90 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
@click="voltar"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
</svg>
{{ is404 ? 'Ir para a página inicial' : 'Tentar novamente' }}
</button>
</div>
</div>
</template>

View File

@ -1,6 +1,7 @@
<script setup>
import { ref, onMounted } from 'vue'
import { portalService } from '@/services/portalService'
import { formatDate } from '@/utils/formatador'
definePageMeta({
layout: 'portal',
@ -134,8 +135,8 @@ const statusMap = {
<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>
<p class="hidden sm:block text-sm text-slate-500 dark:text-slate-400 w-28 text-right">{{ formatDate(cert.dataEmissao) }}</p>
<p class="hidden sm:block text-sm text-slate-500 dark:text-slate-400 w-28 text-right">{{ formatDate(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 }}

View File

@ -22,6 +22,7 @@ const {
filtro,
opcoesEstadoConta,
temSelecionado,
temDebitoSelecionado,
totalizadores,
consultar,
limparFiltros,
@ -108,6 +109,7 @@ function getSegundoNome(texto) {
size="small"
show-gridlines
selection-mode="multiple"
:is-row-selectable="({ data }) => data.codigoEstadoConta === 1"
scrollable
class="mt-2"
>
@ -196,7 +198,7 @@ function getSegundoNome(texto) {
:loading="isLoadingExtrato"
@click="gerarExtratoPdf(false)"
/>
<Button
<Button
v-if="temSelecionado"
label="Imprimir Selecionados"
icon="pi pi-print"
@ -208,7 +210,7 @@ function getSegundoNome(texto) {
/>
</div>
<Button
v-if="temSelecionado"
v-if="temDebitoSelecionado"
label="Gerar Guia de Pagamento"
icon="pi pi-file-pdf"
size="small"

View File

@ -60,12 +60,12 @@ function aplicarFiltros() {
</div>
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4 space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="lg:col-span-2">
<label class="block text-xs font-semibold text-slate-500 uppercase mb-1.5">Nº Guia</label>
<InputText v-model="filtro.numeroGuia" class="w-full" size="small" />
</div>
<div>
<div class="lg:col-span-2">
<label class="block text-xs font-semibold text-slate-500 uppercase mb-1.5">Status</label>
<Select
v-model="filtro.status"
@ -79,26 +79,26 @@ function aplicarFiltros() {
/>
</div>
<div>
<label class="block text-xs font-semibold text-slate-500 uppercase mb-1.5">Emissão (início)</label>
<label class="block text-xs font-semibold text-slate-500 uppercase mb-1.5">Emissão início</label>
<DatePicker v-model="filtro.dataEmissaoInicio" show-icon class="w-full" size="small" date-format="dd/mm/yy" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-500 uppercase mb-1.5">Emissão (fim)</label>
<label class="block text-xs font-semibold text-slate-500 uppercase mb-1.5">Emissão fim</label>
<DatePicker v-model="filtro.dataEmissaoFim" show-icon class="w-full" size="small" date-format="dd/mm/yy" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-500 uppercase mb-1.5">Vencimento (início)</label>
<label class="block text-xs font-semibold text-slate-500 uppercase mb-1.5">Vencimento início</label>
<DatePicker v-model="filtro.dataVencimentoInicio" show-icon class="w-full" size="small" date-format="dd/mm/yy" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-500 uppercase mb-1.5">Vencimento (fim)</label>
<label class="block text-xs font-semibold text-slate-500 uppercase mb-1.5">Vencimento fim</label>
<DatePicker v-model="filtro.dataVencimentoFim" show-icon class="w-full" size="small" date-format="dd/mm/yy" />
</div>
<div>
<div class="lg:col-span-2">
<label class="block text-xs font-semibold text-slate-500 uppercase mb-1.5">Valor mínimo</label>
<InputNumber v-model="filtro.valorMinimo" mode="currency" currency="BRL" locale="pt-BR" class="w-full" size="small" />
</div>
<div>
<div class="lg:col-span-2">
<label class="block text-xs font-semibold text-slate-500 uppercase mb-1.5">Valor máximo</label>
<InputNumber v-model="filtro.valorMaximo" mode="currency" currency="BRL" locale="pt-BR" class="w-full" size="small" />
</div>

View File

@ -3,6 +3,7 @@ import { ref, computed, watch } from 'vue'
import { z } from 'zod'
import { useAuth } from '@/composables/useAuth'
import { certidaoService } from '@/services/certidaoService'
import { validarCpf, validarCnpj, formatDate } from '@/utils/formatador'
const router = useRouter()
const route = useRoute()
@ -24,6 +25,9 @@ const carregandoModelos = ref(false)
const carregandoConsulta = ref(false)
const carregandoEmissao = ref(false)
const resultado = ref(null)
const certidaoEmitida = ref(null)
const certidaoExistente = ref(null)
const carregandoDownloadExistente = ref(false)
const mensagemErro = ref('')
const erros = ref({})
@ -37,7 +41,16 @@ const schemaEmissao = z.object({
const docValido = computed(() => {
const d = documento.value.replace(/\D/g, '')
return d.length === 11 || d.length === 14
if (d.length === 11) return validarCpf(d)
if (d.length === 14) return validarCnpj(d)
return false
})
const docInvalido = computed(() => {
const d = documento.value.replace(/\D/g, '')
if (d.length === 11) return !validarCpf(d)
if (d.length === 14) return !validarCnpj(d)
return false
})
const modeloSelecionado = computed(() =>
@ -58,7 +71,23 @@ function resetModelos() {
}
function extrairErro(e) {
return e?.data?.description ?? e?.data?.message ?? e?.statusMessage ?? null
return e?.data?.description ?? e?.data?.data?.description ?? e?.statusMessage ?? null
}
const COD_CERTIDAO_VALIDA_JA_EXISTE = '214'
// Respostas de erro do endpoint de emissão chegam como ArrayBuffer (responseType
// é 'arrayBuffer' para o PDF). Decodifica o envelope JSON para ler o StandardError.
function lerEnvelopeErro(e) {
let body = e?.data
if (body instanceof ArrayBuffer) {
try {
body = JSON.parse(new TextDecoder().decode(body))
} catch {
return null
}
}
return body ?? null
}
async function carregarModelos() {
@ -136,6 +165,21 @@ async function emitir() {
if (!validarFormulario()) return
carregandoEmissao.value = true
mensagemErro.value = ''
try {
const resValida = await certidaoService.verificarValida(documento.value, idModeloSelecionado.value)
const existente = resValida?.data?.data ?? resValida?.data
if (existente?.id != null) {
certidaoExistente.value = existente
etapa.value = 'certidaoExistente'
carregandoEmissao.value = false
return
}
} catch {
// Pré-checagem indisponível — a emissão é bloqueada de forma autoritativa
// pelo backend, que devolve o erro CERTIDAO_VALIDA_JA_EXISTE tratado abaixo.
}
try {
const buf = await certidaoService.emitir(
documento.value,
@ -147,20 +191,63 @@ async function emitir() {
const a = document.createElement('a')
a.href = url
a.download = `certidao-${slug}-${documento.value.replace(/\D/g, '')}.pdf`
document.body.appendChild(a)
a.click()
URL.revokeObjectURL(url)
document.body.removeChild(a)
setTimeout(() => URL.revokeObjectURL(url), 1000)
if (isAuthenticated.value || route.query.from === 'portal') {
router.push('/portal/certidoes')
} else {
certidaoEmitida.value = {
titulo: modeloSelecionado.value?.titulo,
finalidade: finalidade.value.trim(),
validade: validadeLabel.value,
nomeContribuinte: resultado.value?.nomeContribuinte ?? documento.value,
}
etapa.value = 'sucesso'
}
} catch (e) {
mensagemErro.value = extrairErro(e) ?? 'Erro ao gerar o PDF. Tente novamente.'
const erroBackend = lerEnvelopeErro(e)?.data
const existente = erroBackend?.response
if (erroBackend?.internalCode === COD_CERTIDAO_VALIDA_JA_EXISTE && existente?.id != null) {
certidaoExistente.value = existente
etapa.value = 'certidaoExistente'
return
}
mensagemErro.value = erroBackend?.description ?? extrairErro(e) ?? 'Erro ao gerar o PDF. Tente novamente.'
} finally {
carregandoEmissao.value = false
}
}
async function downloadCertidaoExistente() {
carregandoDownloadExistente.value = true
mensagemErro.value = ''
try {
const buf = await certidaoService.downloadExistente(certidaoExistente.value.id)
const slug = (certidaoExistente.value?.tituloModelo ?? 'certidao').replace(/\s+/g, '-').toLowerCase()
const url = URL.createObjectURL(new Blob([buf], { type: 'application/pdf' }))
const a = document.createElement('a')
a.href = url
a.download = `certidao-${slug}-${documento.value.replace(/\D/g, '')}.pdf`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
setTimeout(() => URL.revokeObjectURL(url), 1000)
} catch (e) {
mensagemErro.value = extrairErro(e) ?? 'Erro ao baixar a certidão. Tente novamente.'
} finally {
carregandoDownloadExistente.value = false
}
}
function reiniciar() {
documento.value = isAuthenticated.value ? docUsuarioLogado.value : ''
idModeloSelecionado.value = null
finalidade.value = ''
resultado.value = null
certidaoEmitida.value = null
certidaoExistente.value = null
mensagemErro.value = ''
erros.value = {}
etapa.value = 'formulario'
@ -201,6 +288,10 @@ function reiniciar() {
:disabled="isAuthenticated"
@keyup.enter="consultar"
/>
<p v-if="docInvalido && !isAuthenticated" class="mt-1.5 text-xs text-red-600 dark:text-red-400 flex items-center gap-1.5">
<i class="pi pi-exclamation-circle" aria-hidden="true" />
CPF ou CNPJ inválido.
</p>
<p v-if="isAuthenticated" class="mt-1.5 text-xs text-slate-500 dark:text-slate-400 flex items-start gap-1.5">
<i class="pi pi-info-circle text-xs text-primary mt-0.5 shrink-0" aria-hidden="true" />
<span>Logado como <strong class="text-slate-700 dark:text-slate-200">{{ nomeUsuario }}</strong> — documento bloqueado para segurança.</span>
@ -332,5 +423,120 @@ function reiniciar() {
</div>
</div>
<div v-else-if="etapa === 'certidaoExistente'" class="space-y-4">
<div class="bg-white dark:bg-slate-800 rounded-2xl border border-amber-200 dark:border-amber-700/50 p-8 flex flex-col items-center text-center gap-4">
<div class="w-16 h-16 bg-amber-50 dark:bg-amber-900/20 rounded-2xl flex items-center justify-center">
<i class="pi pi-exclamation-triangle text-amber-500 dark:text-amber-400 text-3xl" aria-hidden="true" />
</div>
<div>
<p class="text-lg font-bold text-slate-800 dark:text-slate-100">Já existe uma certidão válida</p>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">
Existe uma certidão do mesmo tipo ainda dentro do prazo de validade.<br>
Você pode baixar a certidão existente em vez de emitir uma nova.
</p>
</div>
</div>
<div class="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 p-6 space-y-3">
<p class="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">Certidão existente</p>
<div class="space-y-2">
<div class="flex justify-between gap-4">
<span class="text-sm text-slate-500 dark:text-slate-400">Modelo</span>
<span class="text-sm font-medium text-slate-800 dark:text-slate-100 text-right">{{ certidaoExistente?.tituloModelo }}</span>
</div>
<div class="flex justify-between gap-4">
<span class="text-sm text-slate-500 dark:text-slate-400">Nº certidão</span>
<span class="text-sm font-medium text-slate-800 dark:text-slate-100 text-right">{{ certidaoExistente?.numero }}</span>
</div>
<div class="flex justify-between gap-4">
<span class="text-sm text-slate-500 dark:text-slate-400">Válida até</span>
<span class="text-sm font-semibold text-emerald-600 dark:text-emerald-400 text-right">{{ formatDate(certidaoExistente?.dataValidade) }}</span>
</div>
</div>
</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="Nova consulta"
severity="secondary"
icon="pi pi-arrow-left"
outlined
class="flex-1"
@click="reiniciar"
/>
<Button
label="Baixar certidão"
icon="pi pi-download"
class="flex-1"
:loading="carregandoDownloadExistente"
:disabled="carregandoDownloadExistente"
@click="downloadCertidaoExistente"
/>
</div>
</div>
<div v-else-if="etapa === 'sucesso'" class="space-y-4">
<div class="bg-white dark:bg-slate-800 rounded-2xl border border-emerald-200 dark:border-emerald-700/50 p-8 flex flex-col items-center text-center gap-4">
<div class="w-16 h-16 bg-emerald-100 dark:bg-emerald-900/30 rounded-2xl flex items-center justify-center">
<i class="pi pi-check-circle text-emerald-600 dark:text-emerald-400 text-3xl" aria-hidden="true" />
</div>
<div>
<p class="text-lg font-bold text-slate-800 dark:text-slate-100">Certidão emitida com sucesso!</p>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">
O PDF foi baixado automaticamente para o seu dispositivo.
</p>
</div>
</div>
<div class="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 p-6 space-y-3">
<p class="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">Detalhes da certidão emitida</p>
<div class="space-y-2">
<div class="flex justify-between gap-4">
<span class="text-sm text-slate-500 dark:text-slate-400">Contribuinte</span>
<span class="text-sm font-medium text-slate-800 dark:text-slate-100 text-right">{{ certidaoEmitida?.nomeContribuinte }}</span>
</div>
<div class="flex justify-between gap-4">
<span class="text-sm text-slate-500 dark:text-slate-400">Modelo</span>
<span class="text-sm font-medium text-slate-800 dark:text-slate-100 text-right">{{ certidaoEmitida?.titulo }}</span>
</div>
<div v-if="certidaoEmitida?.finalidade" class="flex justify-between gap-4">
<span class="text-sm text-slate-500 dark:text-slate-400">Finalidade</span>
<span class="text-sm font-medium text-slate-800 dark:text-slate-100 text-right">{{ certidaoEmitida.finalidade }}</span>
</div>
<div class="flex justify-between gap-4">
<span class="text-sm text-slate-500 dark:text-slate-400">Validade</span>
<span class="text-sm font-medium text-slate-800 dark:text-slate-100 text-right">{{ certidaoEmitida?.validade }}</span>
</div>
</div>
</div>
<div class="flex gap-3">
<Button
label="Emitir nova certidão"
severity="secondary"
icon="pi pi-refresh"
outlined
class="flex-1"
@click="reiniciar"
/>
<Button
label="Voltar aos serviços"
icon="pi pi-home"
class="flex-1"
severity="secondary"
@click="router.push('/servicos')"
/>
</div>
</div>
</div>
</template>

View File

@ -37,7 +37,7 @@ async function consultar() {
? await iptuService.consultarPorDocumento(documento.value)
: await iptuService.consultarPorInscricao(inscricao.value)
imoveis.value = res.data ?? []
imoveis.value = res.data?.content ?? res.data ?? []
if (imoveis.value.length === 1) imovelSelecionado.value = imoveis.value[0]
etapa.value = 'resultado'
} catch (e) {
@ -188,7 +188,19 @@ function formatarMoeda(valor) {
<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">
<div v-if="imoveis.length === 0" class="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 p-8 text-center">
<div class="w-14 h-14 bg-primary/10 dark:bg-primary/20 rounded-full flex items-center justify-center mx-auto mb-4">
<i class="pi pi-search text-primary text-2xl" aria-hidden="true" />
</div>
<p class="text-base font-semibold text-slate-800 dark:text-slate-100 mb-1">Nenhum imóvel encontrado</p>
<p class="text-sm text-slate-500 dark:text-slate-400">
Não foram encontrados imóveis cadastrados para o
{{ modoConsulta === 'documento' ? 'documento informado' : 'número de inscrição informado' }}.
Verifique os dados e tente novamente.
</p>
</div>
<div v-else-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>

View File

@ -26,4 +26,18 @@ export const certidaoService = {
responseType: 'arrayBuffer',
})
},
verificarValida(documento, idModelo) {
return $fetch(proxyUrl('/publico/certidao/valida'), {
headers: FETCH_HEADERS,
query: { documento, idModelo },
})
},
downloadExistente(idCertidao) {
return $fetch(proxyUrl(`/publico/certidao/emitida/${idCertidao}/arquivo`), {
headers: FETCH_HEADERS,
responseType: 'arrayBuffer',
})
},
}

View File

@ -47,3 +47,30 @@ export function abrirPdf(buf) {
}
export { formatarMoeda, baixarPdf }
export function validarCpf(cpf) {
const d = String(cpf).replace(/\D/g, '')
if (d.length !== 11 || /^(\d)\1{10}$/.test(d)) return false
const calc = (len) => {
let sum = 0
for (let i = 0; i < len; i++) sum += parseInt(d[i]) * (len + 1 - i)
const r = sum % 11
return r < 2 ? 0 : 11 - r
}
return calc(9) === parseInt(d[9]) && calc(10) === parseInt(d[10])
}
export function validarCnpj(cnpj) {
const d = String(cnpj).replace(/\D/g, '')
if (d.length !== 14 || /^(\d)\1{13}$/.test(d)) return false
const calc = (len) => {
const weights = len === 12
? [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]
: [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]
let sum = 0
for (let i = 0; i < len; i++) sum += parseInt(d[i]) * weights[i]
const r = sum % 11
return r < 2 ? 0 : 11 - r
}
return calc(12) === parseInt(d[12]) && calc(13) === parseInt(d[13])
}