feat(portal): módulo de emissão de taxas para o contribuinte
All checks were successful
Dev Build & Deploy Portal / build-deploy (push) Successful in 2m42s
All checks were successful
Dev Build & Deploy Portal / build-deploy (push) Successful in 2m42s
- Páginas taxas/index (listagem com status e ações) e taxas/emitir (wizard) - CamposEmissaoTaxa: campos dinâmicos por tipo (data, numérico, inputmask, texto) - useEmissaoTaxaPortal: composable com fluxo completo — busca catálogo, calcula vencimento/multa-juros, valida e emite taxa via API - taxaService: client HTTP para os endpoints /api/v1/contribuinte/taxas - atributoMascara, formatacao, formulaCalculo: utilitários de suporte - portal.vue: item "Taxas" no menu de navegação - painel.vue: atalho rápido "Emitir Taxa" com ícone pi-file-export Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fa557af73c
commit
a48eea53bc
BIN
public/brasao.png
Normal file
BIN
public/brasao.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
BIN
src/assets/images/brasao.png
Normal file
BIN
src/assets/images/brasao.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
133
src/components/taxas/CamposEmissaoTaxa.vue
Normal file
133
src/components/taxas/CamposEmissaoTaxa.vue
Normal 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>
|
||||||
381
src/composables/useEmissaoTaxaPortal.js
Normal file
381
src/composables/useEmissaoTaxaPortal.js
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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' },
|
||||||
|
|||||||
@ -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' },
|
||||||
|
|||||||
233
src/pages/portal/taxas/emitir.vue
Normal file
233
src/pages/portal/taxas/emitir.vue
Normal 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>
|
||||||
238
src/pages/portal/taxas/index.vue
Normal file
238
src/pages/portal/taxas/index.vue
Normal 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>
|
||||||
67
src/services/taxaService.js
Normal file
67
src/services/taxaService.js
Normal 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',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
42
src/utils/atributoMascara.js
Normal file
42
src/utils/atributoMascara.js
Normal 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
66
src/utils/formatacao.js
Normal 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)
|
||||||
|
}
|
||||||
38
src/utils/formulaCalculo.js
Normal file
38
src/utils/formulaCalculo.js
Normal 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'
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user