developer #4

Open
gabrielb wants to merge 20 commits from developer into main
8 changed files with 211 additions and 21 deletions
Showing only changes of commit 5e528f234b - Show all commits

View File

@ -16,11 +16,16 @@ export default defineEventHandler(async (event) => {
const { codeVerifier, codeChallenge, state } = await generatePkce() const { codeVerifier, codeChallenge, state } = await generatePkce()
const returnTo = body.data.returnTo ?? '/portal/painel' const returnTo = body.data.returnTo ?? '/portal/painel'
await savePkceState(state, { try {
codeVerifier, await savePkceState(state, {
returnTo, codeVerifier,
createdAt: Date.now(), 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 redirectUri = callbackUrlFromEvent(event)
const authUrl = buildAuthUrl({ const authUrl = buildAuthUrl({

View File

@ -97,6 +97,9 @@ export default defineEventHandler(async (event) => {
setResponseHeader(event, 'content-type', 'application/json; charset=utf-8') setResponseHeader(event, 'content-type', 'application/json; charset=utf-8')
return body 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.' })
} }
}) })

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

@ -108,6 +108,7 @@ function getSegundoNome(texto) {
size="small" size="small"
show-gridlines show-gridlines
selection-mode="multiple" selection-mode="multiple"
:is-row-selectable="({ data }) => data.codigoEstadoConta === 1"
scrollable scrollable
class="mt-2" class="mt-2"
> >
@ -196,7 +197,7 @@ function getSegundoNome(texto) {
:loading="isLoadingExtrato" :loading="isLoadingExtrato"
@click="gerarExtratoPdf(false)" @click="gerarExtratoPdf(false)"
/> />
<Button <Button
v-if="temSelecionado" v-if="temSelecionado"
label="Imprimir Selecionados" label="Imprimir Selecionados"
icon="pi pi-print" icon="pi pi-print"

View File

@ -60,12 +60,12 @@ function aplicarFiltros() {
</div> </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="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 class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div> <div class="lg:col-span-2">
<label class="block text-xs font-semibold text-slate-500 uppercase mb-1.5"> Guia</label> <label class="block text-xs font-semibold text-slate-500 uppercase mb-1.5"> Guia</label>
<InputText v-model="filtro.numeroGuia" class="w-full" size="small" /> <InputText v-model="filtro.numeroGuia" class="w-full" size="small" />
</div> </div>
<div> <div class="lg:col-span-2">
<label class="block text-xs font-semibold text-slate-500 uppercase mb-1.5">Status</label> <label class="block text-xs font-semibold text-slate-500 uppercase mb-1.5">Status</label>
<Select <Select
v-model="filtro.status" v-model="filtro.status"
@ -79,26 +79,26 @@ function aplicarFiltros() {
/> />
</div> </div>
<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" /> <DatePicker v-model="filtro.dataEmissaoInicio" show-icon class="w-full" size="small" date-format="dd/mm/yy" />
</div> </div>
<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" /> <DatePicker v-model="filtro.dataEmissaoFim" show-icon class="w-full" size="small" date-format="dd/mm/yy" />
</div> </div>
<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" /> <DatePicker v-model="filtro.dataVencimentoInicio" show-icon class="w-full" size="small" date-format="dd/mm/yy" />
</div> </div>
<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" /> <DatePicker v-model="filtro.dataVencimentoFim" show-icon class="w-full" size="small" date-format="dd/mm/yy" />
</div> </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> <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" /> <InputNumber v-model="filtro.valorMinimo" mode="currency" currency="BRL" locale="pt-BR" class="w-full" size="small" />
</div> </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> <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" /> <InputNumber v-model="filtro.valorMaximo" mode="currency" currency="BRL" locale="pt-BR" class="w-full" size="small" />
</div> </div>

View File

@ -3,6 +3,7 @@ import { ref, computed, watch } from 'vue'
import { z } from 'zod' import { z } from 'zod'
import { useAuth } from '@/composables/useAuth' import { useAuth } from '@/composables/useAuth'
import { certidaoService } from '@/services/certidaoService' import { certidaoService } from '@/services/certidaoService'
import { validarCpf, validarCnpj } from '@/utils/formatador'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@ -24,6 +25,7 @@ const carregandoModelos = ref(false)
const carregandoConsulta = ref(false) const carregandoConsulta = ref(false)
const carregandoEmissao = ref(false) const carregandoEmissao = ref(false)
const resultado = ref(null) const resultado = ref(null)
const certidaoEmitida = ref(null)
const mensagemErro = ref('') const mensagemErro = ref('')
const erros = ref({}) const erros = ref({})
@ -37,7 +39,16 @@ const schemaEmissao = z.object({
const docValido = computed(() => { const docValido = computed(() => {
const d = documento.value.replace(/\D/g, '') 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(() => const modeloSelecionado = computed(() =>
@ -58,7 +69,7 @@ function resetModelos() {
} }
function extrairErro(e) { function extrairErro(e) {
return e?.data?.description ?? e?.data?.message ?? e?.statusMessage ?? null return e?.data?.description ?? e?.data?.data?.description ?? e?.statusMessage ?? null
} }
async function carregarModelos() { async function carregarModelos() {
@ -147,8 +158,21 @@ async function emitir() {
const a = document.createElement('a') const a = document.createElement('a')
a.href = url a.href = url
a.download = `certidao-${slug}-${documento.value.replace(/\D/g, '')}.pdf` a.download = `certidao-${slug}-${documento.value.replace(/\D/g, '')}.pdf`
document.body.appendChild(a)
a.click() 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) { } catch (e) {
mensagemErro.value = extrairErro(e) ?? 'Erro ao gerar o PDF. Tente novamente.' mensagemErro.value = extrairErro(e) ?? 'Erro ao gerar o PDF. Tente novamente.'
} finally { } finally {
@ -201,6 +225,10 @@ function reiniciar() {
:disabled="isAuthenticated" :disabled="isAuthenticated"
@keyup.enter="consultar" @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"> <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" /> <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> <span>Logado como <strong class="text-slate-700 dark:text-slate-200">{{ nomeUsuario }}</strong> documento bloqueado para segurança.</span>
@ -332,5 +360,61 @@ function reiniciar() {
</div> </div>
</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> </div>
</template> </template>

View File

@ -37,7 +37,7 @@ async function consultar() {
? await iptuService.consultarPorDocumento(documento.value) ? await iptuService.consultarPorDocumento(documento.value)
: await iptuService.consultarPorInscricao(inscricao.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] if (imoveis.value.length === 1) imovelSelecionado.value = imoveis.value[0]
etapa.value = 'resultado' etapa.value = 'resultado'
} catch (e) { } catch (e) {
@ -188,7 +188,19 @@ function formatarMoeda(valor) {
<div v-else-if="etapa === 'resultado'" class="space-y-4"> <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"> <p class="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-3">
{{ imoveis.length }} imóveis encontrados selecione um {{ imoveis.length }} imóveis encontrados selecione um
</p> </p>

View File

@ -47,3 +47,30 @@ export function abrirPdf(buf) {
} }
export { formatarMoeda, baixarPdf } 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])
}