developer #4

Open
gabrielb wants to merge 20 commits from developer into main
12 changed files with 1200 additions and 0 deletions
Showing only changes of commit a48eea53bc - Show all commits

BIN
public/brasao.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View File

@ -0,0 +1,133 @@
<script setup>
import { resolveCampoInformativo } from '@/utils/atributoMascara'
import { calcularValorTotal } from '@/utils/formulaCalculo'
import { formatarMoedaInput, obterValorNumerico } from '@/utils/formatacao'
const props = defineProps({
itensTributo: { type: Array, default: () => [] },
itensInformativosDoc: { type: Array, default: () => [] },
formulaSelecionada: { type: Object, default: null },
valoresItens: { type: Object, required: true },
valorTaxa: { type: String, default: '' },
erros: { type: Object, default: () => ({}) },
})
const emit = defineEmits(['update:valoresItens', 'update:valorTaxa'])
const itensEntrada = computed(() =>
props.itensTributo.filter(i => i.tipoVariavel === 1).map(i => ({
...i,
campo: resolveCampoInformativo(i),
}))
)
const itensConstantes = computed(() => props.itensTributo.filter(i => i.tipoVariavel === 2))
const todosInformativos = computed(() => {
const formula = props.itensTributo.filter(i => i.tipoVariavel === 3)
const doc = props.itensInformativosDoc.map(i => ({ ...i, campo: resolveCampoInformativo(i) }))
return [...formula.map(i => ({ ...i, campo: resolveCampoInformativo(i) })), ...doc]
})
const temFormula = computed(() => props.itensTributo.length > 0)
const valorTotalCalculado = computed(() => {
if (!props.formulaSelecionada?.formula) return '0,00'
return calcularValorTotal(
props.formulaSelecionada.formula,
props.itensTributo,
props.valoresItens,
obterValorNumerico,
)
})
function updateValorItem(itemId, valor) {
emit('update:valoresItens', { ...props.valoresItens, [itemId]: valor })
}
function onValorTaxaInput(event) {
emit('update:valorTaxa', formatarMoedaInput(event.target.value))
}
</script>
<template>
<div class="space-y-4">
<template v-if="temFormula">
<div v-if="itensConstantes.length" class="grid gap-3 sm:grid-cols-2">
<div v-for="item in itensConstantes" :key="item.id" class="text-sm">
<span class="text-slate-500 dark:text-slate-400">{{ item.descricao }}:</span>
<span class="ml-2 font-medium text-slate-700 dark:text-slate-200">
{{ valoresItens[item.id] || '0,00' }}
</span>
</div>
</div>
<div v-for="item in itensEntrada" :key="item.id">
<label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1.5">
{{ item.descricao }}<span v-if="item.obrigatorio !== false"> *</span>
</label>
<InputText
v-if="item.campo.tipoCampo === 'text'"
:model-value="valoresItens[item.id]"
class="w-full"
size="small"
@update:model-value="updateValorItem(item.id, $event)"
/>
<InputText
v-else
:model-value="valoresItens[item.id]"
class="w-full"
size="small"
inputmode="decimal"
@input="updateValorItem(item.id, formatarMoedaInput($event.target.value))"
/>
<p v-if="erros[`item_${item.id}`]" class="text-xs text-red-500 mt-1">{{ erros[`item_${item.id}`] }}</p>
</div>
<div
v-if="formulaSelecionada?.formula"
class="rounded-lg bg-slate-50 dark:bg-slate-700/50 px-4 py-3 flex justify-between items-center"
>
<span class="text-sm text-slate-600 dark:text-slate-300">Valor calculado</span>
<span class="text-lg font-bold text-slate-800 dark:text-slate-100">R$ {{ valorTotalCalculado }}</span>
</div>
</template>
<div v-else>
<label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1.5">
Valor da taxa *
</label>
<InputText
:model-value="valorTaxa"
class="w-full"
size="small"
inputmode="decimal"
placeholder="0,00"
@input="onValorTaxaInput"
/>
<p v-if="erros.valorTaxa" class="text-xs text-red-500 mt-1">{{ erros.valorTaxa }}</p>
</div>
<div v-for="item in todosInformativos" :key="`info-${item.id}`">
<label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1.5">
{{ item.descricao || item.atributo }}<span v-if="item.obrigatorio !== false"> *</span>
</label>
<InputText
v-if="item.campo.tipoCampo !== 'date'"
:model-value="valoresItens[item.id]"
class="w-full"
size="small"
@update:model-value="updateValorItem(item.id, $event)"
/>
<InputText
v-else
type="date"
:model-value="valoresItens[item.id]"
class="w-full"
size="small"
@update:model-value="updateValorItem(item.id, $event)"
/>
<p v-if="erros[`item_${item.id}`]" class="text-xs text-red-500 mt-1">{{ erros[`item_${item.id}`] }}</p>
</div>
</div>
</template>

View File

@ -0,0 +1,381 @@
import { ref, computed } from 'vue'
import { z } from 'zod'
import { portalService } from '@/services/portalService'
import { taxaService } from '@/services/taxaService'
import { resolveCampoInformativo } from '@/utils/atributoMascara'
import {
formatarMoeda,
formatarDataParaAPI,
obterValorNumerico,
periodoMesParaAPI,
baixarPdf,
} from '@/utils/formatacao'
import { calcularValorTotal } from '@/utils/formulaCalculo'
export function useEmissaoTaxaPortal() {
const contribuinte = ref(null)
const catalogo = ref([])
const tributoSelecionadoId = ref(null)
const tributo = ref(null)
const itensTributo = ref([])
const itensInformativosDoc = ref([])
const formulaSelecionada = ref(null)
const periodoReferencia = ref('')
const vencimentoDebito = ref('')
const vencimentoGuia = ref('')
const valorTaxa = ref('')
const valoresItens = ref({})
const observacao = ref('')
const dadosVencimento = ref(null)
const dadosMultaJuros = ref(null)
const resultadoEmissao = ref(null)
const carregando = ref(false)
const carregandoCatalogo = ref(false)
const carregandoTributo = ref(false)
const carregandoCalculo = ref(false)
const carregandoEmissao = ref(false)
const mensagemErro = ref('')
const erros = ref({})
const temItensCalculo = computed(() => itensTributo.value.length > 0)
const valorTotalCalculado = computed(() =>
calcularValorTotal(
formulaSelecionada.value?.formula,
itensTributo.value,
valoresItens.value,
obterValorNumerico,
)
)
const totalizadores = computed(() => {
if (!dadosMultaJuros.value) return null
const mj = dadosMultaJuros.value
const principal = temItensCalculo.value ? valorTotalCalculado.value : valorTaxa.value
const totalDescontos = (mj.descontoValorPrincipal || 0) + (mj.descontoValorMulta || 0) + (mj.descontoValorJuros || 0)
return {
principal: formatarMoeda(obterValorNumerico(principal)),
principalAtualizado: formatarMoeda(mj.valorPrincipalAtualizado || 0),
multa: formatarMoeda(mj.valorMulta || 0),
juros: formatarMoeda(mj.valorJuros || 0),
desconto: formatarMoeda(totalDescontos),
total: formatarMoeda(mj.valorTotalComDescontos ?? mj.valorTotal ?? 0),
}
})
async function carregarDadosIniciais() {
carregando.value = true
mensagemErro.value = ''
try {
const [resDados, resCatalogo] = await Promise.all([
portalService.getDadosCadastrais(),
taxaService.getCatalogo(),
])
contribuinte.value = resDados.data
catalogo.value = resCatalogo.data ?? []
} catch (e) {
mensagemErro.value = e?.data?.description ?? 'Não foi possível carregar os dados.'
} finally {
carregando.value = false
}
}
function labelCatalogo(item) {
if (!item) return ''
return item.sigla ? `${item.sigla}${item.descricaoResumida || item.descricao}` : item.descricao
}
async function onSelecionarTributo(id) {
tributoSelecionadoId.value = id
tributo.value = null
itensTributo.value = []
itensInformativosDoc.value = []
formulaSelecionada.value = null
valoresItens.value = {}
valorTaxa.value = ''
dadosVencimento.value = null
dadosMultaJuros.value = null
vencimentoDebito.value = ''
vencimentoGuia.value = ''
if (!id) return
carregandoTributo.value = true
try {
const res = await taxaService.getTributo(id)
aplicarTributo(res.data)
} catch (e) {
mensagemErro.value = e?.data?.description ?? 'Erro ao carregar a taxa selecionada.'
} finally {
carregandoTributo.value = false
}
}
function aplicarTributo(taxa) {
tributo.value = taxa
if (taxa.formulasCalculo?.length > 0) {
const formulaAtiva = taxa.formulasCalculo.find(f => !f.dataFim) || taxa.formulasCalculo[0]
formulaSelecionada.value = formulaAtiva || null
itensTributo.value = formulaAtiva?.itensCalculo || []
} else {
formulaSelecionada.value = null
itensTributo.value = []
}
itensInformativosDoc.value = taxa.atributosInformativosDoc || []
const novosValores = {}
itensTributo.value.forEach(item => {
if (item.tipoVariavel === 2) {
novosValores[item.id] = item.valor ? formatarMoeda(item.valor) : '0,00'
} else if (item.tipoVariavel === 1 && item.tipoDado === 2) {
novosValores[item.id] = '0,00'
} else {
novosValores[item.id] = ''
}
})
itensInformativosDoc.value.forEach(item => {
const campo = resolveCampoInformativo(item)
novosValores[item.id] = campo.tipoCampo === 'number' || item.tipoDado === 2 ? '0,00' : ''
})
valoresItens.value = novosValores
}
async function calcularVencimento() {
if (!tributo.value || !periodoReferencia.value) return null
const periodoApi = periodoMesParaAPI(periodoReferencia.value)
if (!periodoApi) return null
carregandoCalculo.value = true
try {
const res = await taxaService.calcularVencimento({
tributoId: tributo.value.idTaxa,
periodoReferencia: periodoApi,
})
dadosVencimento.value = res.data
vencimentoDebito.value = res.data?.dataVencimento ?? ''
if (res.data?.dataVencimentoGuia) {
vencimentoGuia.value = res.data.dataVencimentoGuia
}
return res.data
} catch (e) {
mensagemErro.value = e?.data?.description ?? 'Erro ao calcular vencimento.'
return null
} finally {
carregandoCalculo.value = false
}
}
async function calcularMultaJuros() {
if (!tributo.value || !periodoReferencia.value || !vencimentoDebito.value) return null
const periodoApi = periodoMesParaAPI(periodoReferencia.value)
const valorPrincipal = temItensCalculo.value
? obterValorNumerico(valorTotalCalculado.value)
: obterValorNumerico(valorTaxa.value)
if (valorPrincipal <= 0) return null
carregandoCalculo.value = true
try {
const res = await taxaService.calcularMultaJuros({
tributoId: tributo.value.idTaxa,
periodoReferencia: periodoApi,
dataVencimentoDebito: vencimentoDebito.value,
valorPrincipal,
dataVencimentoGuia: vencimentoGuia.value || vencimentoDebito.value,
})
dadosMultaJuros.value = res.data
return res.data
} catch (e) {
mensagemErro.value = e?.data?.description ?? 'Erro ao calcular multa e juros.'
return null
} finally {
carregandoCalculo.value = false
}
}
async function onPeriodoChange() {
dadosMultaJuros.value = null
await calcularVencimento()
}
async function recalcularTotais() {
if (!periodoReferencia.value || !vencimentoDebito.value) return
await calcularMultaJuros()
}
function montarAtributosPayload() {
const itensEntrada = itensTributo.value.filter(i => i.tipoVariavel === 1)
const informativos = [
...itensTributo.value.filter(i => i.tipoVariavel === 3),
...itensInformativosDoc.value,
]
return [...itensEntrada, ...informativos].map(item => ({
idItemCalculo: item.idTaxaCalc != null ? item.id : null,
idAtributo: item.idAtributo ?? null,
atributo: item.atributo,
valor: String(valoresItens.value[item.id] ?? ''),
tipoDado: item.tipoDado ?? item.tipoVariavel ?? null,
}))
}
function validar() {
erros.value = {}
if (!tributoSelecionadoId.value) {
erros.value.tributo = 'Selecione uma taxa'
}
if (!periodoReferencia.value) {
erros.value.periodoReferencia = 'Período de referência é obrigatório'
}
if (!vencimentoDebito.value) {
erros.value.vencimentoDebito = 'Vencimento do débito é obrigatório'
}
if (!vencimentoGuia.value) {
erros.value.vencimentoGuia = 'Vencimento da guia é obrigatório'
}
if (temItensCalculo.value) {
itensTributo.value.filter(i => i.tipoVariavel === 1).forEach(item => {
if (item.obrigatorio === false) return
const valor = valoresItens.value[item.id]
if (!valor || obterValorNumerico(valor) <= 0) {
erros.value[`item_${item.id}`] = `${item.descricao} deve ser maior que zero`
}
})
} else {
const schema = z.string().refine(v => v.trim() !== '' && obterValorNumerico(v) > 0, {
message: 'Valor da taxa é obrigatório',
})
const r = schema.safeParse(valorTaxa.value || '')
if (!r.success) erros.value.valorTaxa = r.error.issues[0].message
}
const informativos = [
...itensTributo.value.filter(i => i.tipoVariavel === 3),
...itensInformativosDoc.value,
]
informativos.forEach(item => {
if (item.obrigatorio === false) return
const valor = valoresItens.value[item.id]
const label = item.descricao || item.atributo
if (item.tipoDado === 2) {
if (!valor || obterValorNumerico(valor) <= 0) {
erros.value[`item_${item.id}`] = `${label} é obrigatório`
}
} else if (!valor || String(valor).trim() === '') {
erros.value[`item_${item.id}`] = `${label} é obrigatório`
}
})
return Object.keys(erros.value).length === 0
}
async function emitirTaxa() {
mensagemErro.value = ''
if (!validar()) return false
const mj = dadosMultaJuros.value || await calcularMultaJuros()
if (!mj) {
mensagemErro.value = 'Não foi possível calcular os valores da taxa.'
return false
}
const periodoApi = periodoMesParaAPI(periodoReferencia.value)
const valorPrincipal = temItensCalculo.value
? obterValorNumerico(valorTotalCalculado.value)
: obterValorNumerico(valorTaxa.value)
const totalDescontos = (mj.descontoValorPrincipal || 0) + (mj.descontoValorMulta || 0) + (mj.descontoValorJuros || 0)
const payload = {
tributoId: tributo.value.idTaxa,
taxaCalcId: formulaSelecionada.value?.id ?? undefined,
periodoReferencia: periodoApi,
dataVencimentoGuia: formatarDataParaAPI(vencimentoGuia.value),
dataVencimentoDebito: formatarDataParaAPI(vencimentoDebito.value),
observacao: observacao.value?.trim() || undefined,
atributosTaxa: montarAtributosPayload(),
valores: {
valorPrincipal,
valorPrincipalAtualizado: mj.valorPrincipalAtualizado || valorPrincipal,
valorMulta: mj.valorMulta || 0,
valorJuros: mj.valorJuros || 0,
totalDescontos,
valorTotalComDescontos: mj.valorTotalComDescontos ?? mj.valorTotal ?? 0,
},
}
carregandoEmissao.value = true
try {
const res = await taxaService.lancar(payload)
resultadoEmissao.value = res.data
return true
} catch (e) {
mensagemErro.value = e?.data?.description ?? 'Erro ao emitir a taxa.'
return false
} finally {
carregandoEmissao.value = false
}
}
async function imprimirGuia() {
if (!resultadoEmissao.value?.taxaId) return
try {
const buf = await taxaService.baixarGuia(resultadoEmissao.value.taxaId)
baixarPdf(buf, `guia-${resultadoEmissao.value.numeroProtocolo || resultadoEmissao.value.taxaId}.pdf`)
} catch {
mensagemErro.value = 'Erro ao gerar a guia.'
}
}
function reiniciar() {
tributoSelecionadoId.value = null
tributo.value = null
periodoReferencia.value = ''
vencimentoDebito.value = ''
vencimentoGuia.value = ''
valorTaxa.value = ''
valoresItens.value = {}
observacao.value = ''
dadosVencimento.value = null
dadosMultaJuros.value = null
resultadoEmissao.value = null
erros.value = {}
mensagemErro.value = ''
}
return {
contribuinte,
catalogo,
tributoSelecionadoId,
tributo,
itensTributo,
itensInformativosDoc,
formulaSelecionada,
periodoReferencia,
vencimentoDebito,
vencimentoGuia,
valorTaxa,
valoresItens,
observacao,
totalizadores,
resultadoEmissao,
carregando,
carregandoCatalogo,
carregandoTributo,
carregandoCalculo,
carregandoEmissao,
mensagemErro,
erros,
labelCatalogo,
carregarDadosIniciais,
onSelecionarTributo,
onPeriodoChange,
recalcularTotais,
emitirTaxa,
imprimirGuia,
reiniciar,
}
}

View File

@ -13,6 +13,7 @@ const menuAberto = ref(false)
const navItems = [ const navItems = [
{ path: '/portal/painel', label: 'Painel', icon: 'pi-home' }, { path: '/portal/painel', label: 'Painel', icon: 'pi-home' },
{ path: '/portal/debitos', label: 'Débitos', icon: 'pi-receipt' }, { path: '/portal/debitos', label: 'Débitos', icon: 'pi-receipt' },
{ path: '/portal/taxas', label: 'Taxas', icon: 'pi-file-export' },
{ path: '/portal/certidoes', label: 'Certidões', icon: 'pi-file-check' }, { path: '/portal/certidoes', label: 'Certidões', icon: 'pi-file-check' },
{ path: '/portal/alvaras', label: 'Alvarás', icon: 'pi-briefcase' }, { path: '/portal/alvaras', label: 'Alvarás', icon: 'pi-briefcase' },
{ path: '/portal/pagamentos', label: 'Pagamentos', icon: 'pi-credit-card' }, { path: '/portal/pagamentos', label: 'Pagamentos', icon: 'pi-credit-card' },

View File

@ -63,6 +63,7 @@ async function carregar() {
const acesRapidos = [ const acesRapidos = [
{ icon: 'pi-receipt', label: 'Emitir Guia', to: '/portal/debitos', cor: 'text-primary' }, { icon: 'pi-receipt', label: 'Emitir Guia', to: '/portal/debitos', cor: 'text-primary' },
{ icon: 'pi-file-export', label: 'Emitir Taxa', to: '/portal/taxas/emitir', cor: 'text-sky-600 dark:text-sky-400' },
{ icon: 'pi-file-check', label: 'Nova Certidão', to: '/portal/certidoes', cor: 'text-emerald-600 dark:text-emerald-400' }, { icon: 'pi-file-check', label: 'Nova Certidão', to: '/portal/certidoes', cor: 'text-emerald-600 dark:text-emerald-400' },
{ icon: 'pi-briefcase', label: 'Acompanhar Alvará', to: '/portal/alvaras', cor: 'text-amber-600 dark:text-amber-400' }, { icon: 'pi-briefcase', label: 'Acompanhar Alvará', to: '/portal/alvaras', cor: 'text-amber-600 dark:text-amber-400' },
{ icon: 'pi-user', label: 'Meus Dados', to: '/portal/dados', cor: 'text-violet-600 dark:text-violet-400' }, { icon: 'pi-user', label: 'Meus Dados', to: '/portal/dados', cor: 'text-violet-600 dark:text-violet-400' },

View File

@ -0,0 +1,233 @@
<script setup>
import { onMounted, ref } from 'vue'
import CamposEmissaoTaxa from '@/components/taxas/CamposEmissaoTaxa.vue'
import { useEmissaoTaxaPortal } from '@/composables/useEmissaoTaxaPortal'
import { formatarDocumento } from '@/utils/formatacao'
definePageMeta({
layout: 'portal',
middleware: 'auth',
})
const router = useRouter()
const etapa = ref('formulario')
const {
contribuinte,
catalogo,
tributoSelecionadoId,
itensTributo,
itensInformativosDoc,
formulaSelecionada,
periodoReferencia,
vencimentoDebito,
vencimentoGuia,
valorTaxa,
valoresItens,
observacao,
totalizadores,
resultadoEmissao,
carregando,
carregandoTributo,
carregandoCalculo,
carregandoEmissao,
mensagemErro,
erros,
labelCatalogo,
carregarDadosIniciais,
onSelecionarTributo,
onPeriodoChange,
recalcularTotais,
emitirTaxa,
imprimirGuia,
reiniciar,
} = useEmissaoTaxaPortal()
onMounted(carregarDadosIniciais)
async function onTributoChange(id) {
await onSelecionarTributo(id)
}
async function onSubmit() {
const ok = await emitirTaxa()
if (ok) etapa.value = 'resultado'
}
function novaEmissao() {
reiniciar()
etapa.value = 'formulario'
carregarDadosIniciais()
}
</script>
<template>
<div class="space-y-6 max-w-3xl">
<div class="flex items-center gap-4">
<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"
@click="router.push('/portal/taxas')"
>
<i class="pi pi-arrow-left text-xs" aria-hidden="true" />
Voltar
</button>
</div>
<div>
<h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100">Emitir Taxa</h1>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">Emissão simplificada de taxas disponíveis para o seu cadastro.</p>
</div>
<div v-if="carregando" class="space-y-4">
<div v-for="i in 3" :key="i" class="h-20 bg-slate-200 dark:bg-slate-700 rounded-xl animate-pulse" />
</div>
<template v-else-if="etapa === 'formulario'">
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5 space-y-4">
<h2 class="text-sm font-semibold text-slate-700 dark:text-slate-200 uppercase tracking-wide">Contribuinte</h2>
<div class="grid sm:grid-cols-2 gap-4">
<div>
<label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1.5">CPF/CNPJ</label>
<InputText
:model-value="formatarDocumento(contribuinte?.documento)"
class="w-full"
size="small"
disabled
/>
</div>
<div>
<label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1.5">Nome</label>
<InputText
:model-value="contribuinte?.nomeCompleto"
class="w-full"
size="small"
disabled
/>
</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-4">
<h2 class="text-sm font-semibold text-slate-700 dark:text-slate-200 uppercase tracking-wide">Dados da taxa</h2>
<div>
<label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1.5">Taxa *</label>
<Select
:model-value="tributoSelecionadoId"
:options="catalogo"
:option-label="labelCatalogo"
option-value="id"
placeholder="Selecione a taxa"
show-clear
class="w-full"
size="small"
:loading="carregandoTributo"
@update:model-value="onTributoChange"
/>
<p v-if="erros.tributo" class="text-xs text-red-500 mt-1">{{ erros.tributo }}</p>
<p v-if="catalogo.length === 0" class="text-xs text-amber-600 dark:text-amber-400 mt-2">
Nenhuma taxa disponível para emissão no portal. Verifique com a prefeitura.
</p>
</div>
<div class="grid sm:grid-cols-2 gap-4">
<div>
<label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1.5">Período referência *</label>
<InputText
v-model="periodoReferencia"
type="month"
class="w-full"
size="small"
@change="onPeriodoChange"
/>
<p v-if="erros.periodoReferencia" class="text-xs text-red-500 mt-1">{{ erros.periodoReferencia }}</p>
</div>
<div>
<label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1.5">Vencimento débito</label>
<InputText
:model-value="vencimentoDebito"
class="w-full"
size="small"
disabled
/>
</div>
<div>
<label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1.5">Vencimento guia *</label>
<InputText
v-model="vencimentoGuia"
type="date"
class="w-full"
size="small"
/>
<p v-if="erros.vencimentoGuia" class="text-xs text-red-500 mt-1">{{ erros.vencimentoGuia }}</p>
</div>
</div>
<CamposEmissaoTaxa
v-if="tributoSelecionadoId"
:itens-tributo="itensTributo"
:itens-informativos-doc="itensInformativosDoc"
:formula-selecionada="formulaSelecionada"
:valores-itens="valoresItens"
:valor-taxa="valorTaxa"
:erros="erros"
@update:valores-itens="valoresItens = $event"
@update:valor-taxa="valorTaxa = $event"
/>
<div v-if="tributoSelecionadoId" class="flex gap-2">
<Button
label="Calcular valores"
icon="pi pi-calculator"
size="small"
severity="secondary"
outlined
:loading="carregandoCalculo"
@click="recalcularTotais"
/>
</div>
<div v-if="totalizadores" class="rounded-lg border border-slate-200 dark:border-slate-600 divide-y divide-slate-100 dark:divide-slate-700 text-sm">
<div class="flex justify-between px-4 py-2"><span class="text-slate-500">Principal</span><span>R$ {{ totalizadores.principal }}</span></div>
<div class="flex justify-between px-4 py-2"><span class="text-slate-500">Multa</span><span>R$ {{ totalizadores.multa }}</span></div>
<div class="flex justify-between px-4 py-2"><span class="text-slate-500">Juros</span><span>R$ {{ totalizadores.juros }}</span></div>
<div class="flex justify-between px-4 py-3 font-bold text-slate-800 dark:text-slate-100"><span>Total</span><span>R$ {{ totalizadores.total }}</span></div>
</div>
<div>
<label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1.5">Observação</label>
<InputText v-model="observacao" class="w-full" size="small" />
</div>
</div>
<p v-if="mensagemErro" class="text-sm text-red-600 dark:text-red-400">{{ mensagemErro }}</p>
<Button
label="Emitir taxa"
icon="pi pi-check"
:loading="carregandoEmissao"
:disabled="!tributoSelecionadoId"
@click="onSubmit"
/>
</template>
<template v-else>
<div class="bg-white dark:bg-slate-800 rounded-xl border border-emerald-200 dark:border-emerald-800 p-8 text-center space-y-4">
<div class="w-14 h-14 bg-emerald-100 dark:bg-emerald-900/30 rounded-full flex items-center justify-center mx-auto">
<i class="pi pi-check text-emerald-600 dark:text-emerald-400 text-2xl" aria-hidden="true" />
</div>
<div>
<h2 class="text-lg font-bold text-slate-800 dark:text-slate-100">Taxa emitida com sucesso</h2>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">
Protocolo: <strong>{{ resultadoEmissao?.numeroProtocolo || '—' }}</strong>
</p>
</div>
<div class="flex flex-wrap justify-center gap-3">
<Button label="Imprimir guia" icon="pi pi-file-pdf" @click="imprimirGuia" />
<Button label="Ver taxas emitidas" severity="secondary" outlined @click="router.push('/portal/taxas')" />
<Button label="Nova emissão" severity="secondary" text @click="novaEmissao" />
</div>
</div>
</template>
</div>
</template>

View File

@ -0,0 +1,238 @@
<script setup>
import { ref, onMounted } from 'vue'
import { taxaService } from '@/services/taxaService'
import { formatarMoeda, baixarPdf } from '@/utils/formatacao'
definePageMeta({
layout: 'portal',
middleware: 'auth',
})
const router = useRouter()
const taxas = ref([])
const carregando = ref(true)
const carregandoPdf = ref(null)
const mensagemErro = ref('')
const pagina = ref(0)
const totalPaginas = ref(0)
const totalElementos = ref(0)
const filtroStatus = ref(null)
const filtroProtocolo = ref('')
const statusOpcoes = [
{ value: 1, label: 'Emitida', classe: 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400' },
{ value: 2, label: 'Cancelada', classe: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400' },
{ value: 3, label: 'Paga', classe: 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400' },
]
const statusMap = Object.fromEntries(statusOpcoes.map(s => [s.value, s]))
onMounted(carregar)
async function carregar() {
carregando.value = true
mensagemErro.value = ''
try {
const params = { page: pagina.value, size: 15 }
if (filtroStatus.value) params.status = filtroStatus.value
if (filtroProtocolo.value?.trim()) params.numeroProtocolo = filtroProtocolo.value.trim()
const res = await taxaService.listar(params)
const pageData = res.data ?? {}
taxas.value = pageData.data ?? []
totalPaginas.value = pageData.paginasTotais ?? 0
totalElementos.value = pageData.elementosTotais ?? 0
} catch (e) {
mensagemErro.value = e?.data?.description ?? 'Não foi possível carregar as taxas emitidas.'
} finally {
carregando.value = false
}
}
function aplicarFiltros() {
pagina.value = 0
carregar()
}
function limparFiltros() {
filtroStatus.value = null
filtroProtocolo.value = ''
pagina.value = 0
carregar()
}
function paginaAnterior() {
if (pagina.value > 0) {
pagina.value--
carregar()
}
}
function proximaPagina() {
if (pagina.value < totalPaginas.value - 1) {
pagina.value++
carregar()
}
}
async function baixar(taxa, tipo) {
carregandoPdf.value = `${tipo}-${taxa.id}`
try {
let buf
let nome
if (tipo === 'guia') {
buf = await taxaService.baixarGuia(taxa.id)
nome = `guia-${taxa.numeroProtocolo || taxa.id}.pdf`
} else if (tipo === 'comprovante') {
buf = await taxaService.baixarComprovante(taxa.id)
nome = `comprovante-${taxa.numeroProtocolo || taxa.id}.pdf`
} else {
buf = await taxaService.baixarAutorizacao(taxa.id)
nome = `autorizacao-${taxa.numeroProtocolo || taxa.id}.pdf`
}
baixarPdf(buf, nome)
} catch {
mensagemErro.value = 'Erro ao gerar o PDF.'
} finally {
carregandoPdf.value = null
}
}
function formatarData(val) {
if (!val) return '—'
const d = new Date(val)
return d.toLocaleDateString('pt-BR')
}
</script>
<template>
<div class="space-y-6">
<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">Taxas Emitidas</h1>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">Consulte suas taxas e reimprima documentos.</p>
</div>
<Button label="Emitir taxa" icon="pi pi-plus" size="small" @click="router.push('/portal/taxas/emitir')" />
</div>
<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">Status</label>
<Select
v-model="filtroStatus"
:options="statusOpcoes"
option-label="label"
option-value="value"
placeholder="Todos"
show-clear
class="w-full"
size="small"
/>
</div>
<div class="flex-1 min-w-[180px]">
<label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1.5">Protocolo</label>
<InputText v-model="filtroProtocolo" placeholder="Número do protocolo" class="w-full" size="small" />
</div>
<Button label="Filtrar" size="small" @click="aplicarFiltros" />
<Button label="Limpar" severity="secondary" size="small" outlined @click="limparFiltros" />
</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-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="taxas.length === 0" class="p-12 text-center">
<i class="pi pi-receipt 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 taxa emitida</p>
<p class="text-sm text-slate-400 dark:text-slate-500 mt-1 mb-4">Emita sua primeira taxa pelo portal.</p>
<Button label="Emitir taxa" size="small" @click="router.push('/portal/taxas/emitir')" />
</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">Taxa</span>
<span class="hidden md:block w-28 text-right">Vencimento</span>
<span class="hidden sm:block w-24 text-right">Valor</span>
<span class="w-20 text-center">Status</span>
<span class="w-36" />
</div>
<div class="divide-y divide-slate-100 dark:divide-slate-700">
<div
v-for="taxa in taxas"
:key="taxa.id"
class="flex items-center gap-4 px-5 py-4 hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors flex-wrap"
>
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-slate-800 dark:text-slate-100 truncate">{{ taxa.tributoDescricao }}</p>
<p class="text-xs text-slate-400 dark:text-slate-500 mt-0.5">
Protocolo {{ taxa.numeroProtocolo || '—' }} · {{ formatarData(taxa.dataEmissao) }}
</p>
</div>
<p class="hidden md:block text-sm text-slate-500 dark:text-slate-400 w-28 text-right">{{ formatarData(taxa.dataVencimento) }}</p>
<p class="hidden sm:block text-sm font-medium text-slate-700 dark:text-slate-200 w-24 text-right">{{ formatarMoeda(taxa.valor) }}</p>
<div class="w-20 flex justify-center">
<span :class="['text-xs font-semibold px-2 py-1 rounded-full', statusMap[taxa.status]?.classe ?? 'bg-slate-100 text-slate-500']">
{{ statusMap[taxa.status]?.label ?? '—' }}
</span>
</div>
<div class="w-36 flex justify-end gap-1 flex-wrap">
<Button
v-if="taxa.status === 1"
icon="pi pi-file-pdf"
label="Guia"
size="small"
outlined
:loading="carregandoPdf === `guia-${taxa.id}`"
@click="baixar(taxa, 'guia')"
/>
<Button
v-if="taxa.status === 3"
icon="pi pi-check"
label="Compr."
size="small"
outlined
severity="success"
:loading="carregandoPdf === `comprovante-${taxa.id}`"
@click="baixar(taxa, 'comprovante')"
/>
<Button
v-if="taxa.status === 3 && taxa.possuiDocComprobatorio"
icon="pi pi-verified"
label="Autor."
size="small"
outlined
:loading="carregandoPdf === `autorizacao-${taxa.id}`"
@click="baixar(taxa, 'autorizacao')"
/>
</div>
</div>
</div>
<div
v-if="totalPaginas > 1"
class="flex items-center justify-between px-5 py-3 border-t border-slate-200 dark:border-slate-700 text-sm text-slate-500 dark:text-slate-400"
>
<span>{{ totalElementos }} registro(s)</span>
<div class="flex gap-2">
<Button label="Anterior" size="small" severity="secondary" outlined :disabled="pagina === 0" @click="paginaAnterior" />
<span class="px-2 self-center">{{ pagina + 1 }} / {{ totalPaginas }}</span>
<Button label="Próxima" size="small" severity="secondary" outlined :disabled="pagina >= totalPaginas - 1" @click="proximaPagina" />
</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,67 @@
const FETCH_HEADERS = { 'X-Requested-With': 'fetch' }
function proxyUrl(path) {
return `/api/proxy${path}`
}
export const taxaService = {
listar(params = {}) {
return $fetch(proxyUrl('/contribuinte/taxas'), {
headers: FETCH_HEADERS,
query: params,
})
},
getCatalogo() {
return $fetch(proxyUrl('/contribuinte/taxas/catalogo'), { headers: FETCH_HEADERS })
},
getTributo(id) {
return $fetch(proxyUrl(`/contribuinte/taxas/tributo/${id}`), { headers: FETCH_HEADERS })
},
calcularVencimento(payload) {
return $fetch(proxyUrl('/contribuinte/taxas/calcular-vencimento'), {
method: 'POST',
headers: FETCH_HEADERS,
body: payload,
})
},
calcularMultaJuros(payload) {
return $fetch(proxyUrl('/contribuinte/taxas/calcular-multa-juros'), {
method: 'POST',
headers: FETCH_HEADERS,
body: payload,
})
},
lancar(payload) {
return $fetch(proxyUrl('/contribuinte/taxas/lancar'), {
method: 'POST',
headers: FETCH_HEADERS,
body: payload,
})
},
baixarGuia(id) {
return $fetch(proxyUrl(`/contribuinte/taxas/${id}/guia`), {
headers: FETCH_HEADERS,
responseType: 'arrayBuffer',
})
},
baixarComprovante(id) {
return $fetch(proxyUrl(`/contribuinte/taxas/${id}/comprovante`), {
headers: FETCH_HEADERS,
responseType: 'arrayBuffer',
})
},
baixarAutorizacao(id) {
return $fetch(proxyUrl(`/contribuinte/taxas/${id}/autorizacao`), {
headers: FETCH_HEADERS,
responseType: 'arrayBuffer',
})
},
}

View File

@ -0,0 +1,42 @@
export const TIPO_DADO_TEXTO = 1
export const TIPO_DADO_NUMERICO = 2
export const TIPO_DADO_DATA = 3
export function isMascaraNumerica(mascara) {
if (!mascara || typeof mascara !== 'string') return false
const trimmed = mascara.trim()
if (!trimmed) return false
if (/[9a*]/i.test(trimmed) && !/[#]/.test(trimmed)) return false
return /[#0]/.test(trimmed)
}
export function getMaskConfig(mascara) {
if (!mascara) return null
const isPct = mascara.includes('%')
const commaIdx = mascara.lastIndexOf(',')
if (commaIdx !== -1) {
const afterComma = mascara.substring(commaIdx + 1).replace(/[^#0]/g, '')
return { fractionDigits: afterComma.length, suffix: isPct ? ' %' : '' }
}
if (/[#0]/.test(mascara)) return { fractionDigits: 0, suffix: isPct ? ' %' : '' }
return null
}
export function inferirTipoDado(mascara, tipoDadoAtual) {
if (tipoDadoAtual != null) return tipoDadoAtual
if (!mascara) return TIPO_DADO_TEXTO
if (isMascaraNumerica(mascara)) return TIPO_DADO_NUMERICO
return TIPO_DADO_TEXTO
}
export function resolveCampoInformativo(item) {
const tipoDado = inferirTipoDado(item.mascara, item.tipoDado)
const mascara = item.mascara || ''
if (tipoDado === TIPO_DADO_DATA) {
return { tipoCampo: 'date', tipoDado, mascara, maskConfig: null }
}
if (tipoDado === TIPO_DADO_NUMERICO || isMascaraNumerica(mascara)) {
return { tipoCampo: 'number', tipoDado: TIPO_DADO_NUMERICO, mascara, maskConfig: getMaskConfig(mascara) }
}
return { tipoCampo: 'text', tipoDado: TIPO_DADO_TEXTO, mascara, maskConfig: null }
}

66
src/utils/formatacao.js Normal file
View File

@ -0,0 +1,66 @@
export function formatarMoeda(valor) {
if (valor == null || valor === '') return '0,00'
const numero = typeof valor === 'string'
? parseFloat(valor.replace(/\./g, '').replace(',', '.'))
: valor
if (isNaN(numero)) return '0,00'
return numero.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
export function formatarMoedaInput(valor) {
if (!valor) return ''
const apenasNumeros = String(valor).replace(/\D/g, '')
return (Number(apenasNumeros) / 100).toLocaleString('pt-BR', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
}
export function obterValorNumerico(valorFormatado) {
if (!valorFormatado) return 0
return parseFloat(String(valorFormatado).replace(/\./g, '').replace(',', '.')) || 0
}
export function formatarDataParaAPI(data) {
if (!data) return new Date().toISOString().split('T')[0]
if (typeof data === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(data)) return data
try {
const dataObj = typeof data === 'string' ? new Date(data) : data
if (isNaN(dataObj.getTime())) return new Date().toISOString().split('T')[0]
const ano = dataObj.getFullYear()
const mes = String(dataObj.getMonth() + 1).padStart(2, '0')
const dia = String(dataObj.getDate()).padStart(2, '0')
return `${ano}-${mes}-${dia}`
} catch {
return new Date().toISOString().split('T')[0]
}
}
export function periodoMesParaAPI(periodoMes) {
if (!periodoMes) return null
const [ano, mes] = periodoMes.split('-')
if (!ano || !mes) return null
return Number(`${ano}${mes}`)
}
export function formatarDocumento(doc) {
if (!doc) return ''
const d = doc.replace(/\D/g, '')
if (d.length === 11) {
return d.replace(/(\d{3})(\d{3})(\d{3})(\d{2})/, '$1.$2.$3-$4')
}
if (d.length === 14) {
return doc.toUpperCase().replace(/[^A-Z0-9]/g, '')
.replace(/^(.{2})(.{3})(.{3})(.{4})(.{2})$/, '$1.$2.$3/$4-$5')
}
return doc
}
export function baixarPdf(buf, filename) {
const url = URL.createObjectURL(new Blob([buf], { type: 'application/pdf' }))
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}

View File

@ -0,0 +1,38 @@
function avaliarExpressaoMatematica(expr) {
expr = expr.replace(/\s+/g, '')
if (!/^[0-9+\-*/().]+$/.test(expr)) throw new Error('Expressão inválida')
return new Function('return (' + expr + ')')()
}
function validarFormula(formula) {
if (!formula || typeof formula !== 'string') return false
if (!/^[0-9+\-*/().\sA-Z_]+$/.test(formula)) return false
const palavrasPerigosas = ['eval', 'function', 'constructor', 'prototype', 'window', 'document', 'global']
const formulaLower = formula.toLowerCase()
return !palavrasPerigosas.some(p => formulaLower.includes(p))
}
export function avaliarFormula(formula, contexto = {}) {
if (!validarFormula(formula)) throw new Error('Fórmula inválida')
let formulaProcessada = formula
for (const [variavel, valor] of Object.entries(contexto)) {
formulaProcessada = formulaProcessada.replace(new RegExp(variavel, 'g'), valor.toString())
}
const resultado = avaliarExpressaoMatematica(formulaProcessada)
if (typeof resultado !== 'number' || isNaN(resultado)) throw new Error('Resultado inválido')
return resultado
}
export function calcularValorTotal(formula, itensTributo, valoresItens, obterValorNumerico) {
if (!formula || !itensTributo || !valoresItens) return '0,00'
try {
const contexto = {}
itensTributo.forEach(item => {
contexto[item.atributo] = obterValorNumerico(valoresItens[item.id] || '0,00')
})
const resultado = avaliarFormula(formula, contexto)
return resultado.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
} catch {
return '0,00'
}
}