diff --git a/public/brasao.png b/public/brasao.png new file mode 100644 index 0000000..ef58b93 Binary files /dev/null and b/public/brasao.png differ diff --git a/src/assets/images/brasao.png b/src/assets/images/brasao.png new file mode 100644 index 0000000..ef58b93 Binary files /dev/null and b/src/assets/images/brasao.png differ diff --git a/src/components/taxas/CamposEmissaoTaxa.vue b/src/components/taxas/CamposEmissaoTaxa.vue new file mode 100644 index 0000000..5ee0537 --- /dev/null +++ b/src/components/taxas/CamposEmissaoTaxa.vue @@ -0,0 +1,133 @@ + + + diff --git a/src/composables/useEmissaoTaxaPortal.js b/src/composables/useEmissaoTaxaPortal.js new file mode 100644 index 0000000..d7d7159 --- /dev/null +++ b/src/composables/useEmissaoTaxaPortal.js @@ -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, + } +} diff --git a/src/layouts/portal.vue b/src/layouts/portal.vue index b6012ae..25f2769 100644 --- a/src/layouts/portal.vue +++ b/src/layouts/portal.vue @@ -13,6 +13,7 @@ const menuAberto = ref(false) const navItems = [ { path: '/portal/painel', label: 'Painel', icon: 'pi-home' }, { 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/alvaras', label: 'Alvarás', icon: 'pi-briefcase' }, { path: '/portal/pagamentos', label: 'Pagamentos', icon: 'pi-credit-card' }, diff --git a/src/pages/portal/painel.vue b/src/pages/portal/painel.vue index 6add2ca..d3aeb74 100644 --- a/src/pages/portal/painel.vue +++ b/src/pages/portal/painel.vue @@ -63,6 +63,7 @@ async function carregar() { const acesRapidos = [ { 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-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' }, diff --git a/src/pages/portal/taxas/emitir.vue b/src/pages/portal/taxas/emitir.vue new file mode 100644 index 0000000..30a5834 --- /dev/null +++ b/src/pages/portal/taxas/emitir.vue @@ -0,0 +1,233 @@ + + + diff --git a/src/pages/portal/taxas/index.vue b/src/pages/portal/taxas/index.vue new file mode 100644 index 0000000..d108ea6 --- /dev/null +++ b/src/pages/portal/taxas/index.vue @@ -0,0 +1,238 @@ + + + diff --git a/src/services/taxaService.js b/src/services/taxaService.js new file mode 100644 index 0000000..e6d58be --- /dev/null +++ b/src/services/taxaService.js @@ -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', + }) + }, +} diff --git a/src/utils/atributoMascara.js b/src/utils/atributoMascara.js new file mode 100644 index 0000000..2d4492e --- /dev/null +++ b/src/utils/atributoMascara.js @@ -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 } +} diff --git a/src/utils/formatacao.js b/src/utils/formatacao.js new file mode 100644 index 0000000..32b1b4f --- /dev/null +++ b/src/utils/formatacao.js @@ -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) +} diff --git a/src/utils/formulaCalculo.js b/src/utils/formulaCalculo.js new file mode 100644 index 0000000..7f108e3 --- /dev/null +++ b/src/utils/formulaCalculo.js @@ -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' + } +}