feat(portal): extratos reais, certidão dinâmica e filtros self-scoped
All checks were successful
Dev Build & Deploy Portal / build-deploy (push) Successful in 2m58s

Integra débitos, pagamentos e guias emitidas com API via composables e modais de extrato. Simplifica filtros do portal ao escopo do contribuinte logado. Refatora emissão pública de certidão com modelos dinâmicos e contrato idModelo. Corrige status de taxas (2=Paga, 3=Cancelada) e melhorias no proxy BFF/Keycloak.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Joao Guilherme de Mesquita Mendes 2026-05-22 16:21:59 -03:00
parent 94d49e73a2
commit e4c468e61e
20 changed files with 1631 additions and 585 deletions

View File

@ -86,6 +86,39 @@ Consulta a situação fiscal do contribuinte.
--- ---
#### `GET /publico/certidao/modelos`
Lista modelos de certidão **públicos** disponíveis para o contribuinte, filtrados pelo `cadastro.tipo` (PF=1, PJ=2) — paridade com o combo do core.
**Query params:**
| Param | Tipo | Obrigatório | Descrição |
|---|---|---|---|
| `documento` | string | sim | CPF/CNPJ sem formatação — resolve o destinatário via cadastro |
| `titulo` | string | não | Filtro parcial no título do modelo |
| `page` | int | não | Página (default: 0) |
| `size` | int | não | Tamanho (default: 50) |
**Response `data`:**
```json
{
"paginasTotais": 1,
"elementosTotais": 2,
"data": [
{
"id": 12,
"titulo": "Certidão Negativa de Débitos",
"descricaoTipoCertidao": "Certidão Negativa",
"validadeDias": 180,
"destinatario": 1
}
]
}
```
Filtros aplicados no backend: `destinatario` = tipo do cadastro · `publico=1` · vigência válida · modelo com arquivo DOCX anexado.
---
#### `GET /publico/certidao/emitir` #### `GET /publico/certidao/emitir`
Emite a certidão em PDF. Emite a certidão em PDF.
@ -94,7 +127,8 @@ Emite a certidão em PDF.
| Param | Tipo | Obrigatório | Descrição | | Param | Tipo | Obrigatório | Descrição |
|---|---|---|---| |---|---|---|---|
| `documento` | string | sim | CPF/CNPJ sem formatação | | `documento` | string | sim | CPF/CNPJ sem formatação |
| `tipoCertidao` | string | sim | `NEGATIVA` · `POSITIVA` · `POSITIVA_EFEITOS_NEGATIVA` | | `idModelo` | long | sim | ID do modelo selecionado em `/modelos` |
| `finalidade` | string | não | Finalidade informada pelo contribuinte (default: "Emissão pelo portal público") |
**Response:** `application/pdf` (binário direto, sem envelope) **Response:** `application/pdf` (binário direto, sem envelope)
@ -396,45 +430,59 @@ Lista das últimas atividades do contribuinte.
--- ---
### Débitos ### Débitos (Extrato)
#### `GET /contribuinte/debitos` #### `GET /contribuinte/debitos`
Lista os débitos do contribuinte com filtros opcionais. Lista débitos agrupados por conta/tributo (`ContaCorrenteTributoDTO`), com os mesmos filtros do extrato interno.
**Query params:** **Query params:** `idTaxa`, `idContaTributo`, `periodoIni` (YYYYMM), `periodoFim` (YYYYMM), `inscMunicipal`, `idEstadoConta` (1=débito, 2=zero, 3=crédito)
| Param | Tipo | Obrigatório | Descrição |
|---|---|---|---|
| `tipo` | string | não | `IPTU` · `ISS` · `TAXA` · `MULTA` |
| `status` | string | não | `VENCIDO` · `A_VENCER` · `PARCELADO` |
**Response `data`:** **Response `data`:** array de `ContaCorrenteTributoDTO` com lista `debitos` aninhada.
```json
{
"content": [
{
"id": "deb1",
"descricao": "IPTU 2025 — Cota 4/10",
"tipo": "IPTU",
"referencia": "ABR/2025",
"vencimento": "30/04/2025",
"valor": 125.90,
"valorAtualizado": 138.49,
"status": "VENCIDO"
}
]
}
```
`valorAtualizado` inclui juros e multa (pode ser igual a `valor` se em dia).
--- ---
#### `GET /contribuinte/debitos/{id}/guia` #### `GET /contribuinte/debitos/tributos/{codigo}`
Emite a guia de pagamento (boleto/DAM) de um débito específico em PDF. Busca tributo por código para filtros.
**Path param:** `id` — ID do débito ---
#### `GET /contribuinte/debitos/contas-tributo/{codigo}`
Busca conta tributo por código para filtros.
---
#### `GET /contribuinte/debitos/{idContaCorrente}/transacoes`
Transações da conta corrente (somente se pertencer ao contribuinte logado).
---
#### `POST /contribuinte/debitos/gerar-guia`
Gera guia para débitos selecionados. Body: `GerarGuiaDebitosRequestDTO`.
**Response `data`:** `GerarGuiaResponseDTO` (`idDoctoArr`, `numeroGuia`, etc.)
---
#### `GET /contribuinte/debitos/guia/{idDoctoArr}`
PDF de guia já emitida (validação de ownership).
---
#### `GET /contribuinte/debitos/{idContaCorrente}/guia`
Gera e retorna PDF de guia para um único débito.
---
#### `POST /contribuinte/debitos/extrato-pdf`
Gera PDF do extrato de débitos. Body: `GerarExtratoDebitosRequestDTO`.
**Response:** `application/pdf` **Response:** `application/pdf`
@ -514,47 +562,58 @@ Lista os processos de alvará do contribuinte.
--- ---
### Pagamentos ### Pagamentos (Extrato)
#### `GET /contribuinte/pagamentos` #### `GET /contribuinte/pagamentos`
Histórico de pagamentos do contribuinte, filtrável por ano. Lista pagamentos agrupados por tributo (`ExtratoPagamentoTributoDTO`).
**Query params:** **Query params:** `idTaxa`, `idContaTributo`, `pagInicio`, `pagFim`, `periodoIni`, `periodoFim`, `ano` (atalho para intervalo anual)
| Param | Tipo | Obrigatório | Descrição |
|---|---|---|---|
| `ano` | integer | não | Ano de referência (padrão: ano atual) |
**Response `data`:** **Response `data`:** array com `pagamentos` aninhados (principal, multa, juros, desconto, total).
```json
{
"content": [
{
"id": "pag1",
"descricao": "IPTU 2025 — Cota 3/10",
"referencia": "MAR/2025",
"dataPagamento": "28/03/2025",
"formaPagamento": "PIX",
"valor": 125.90
}
]
}
```
`formaPagamento` possíveis: `BOLETO` · `PIX` · `CARTAO` · `TRANSFERENCIA` · `ESPECIE`
--- ---
#### `GET /contribuinte/pagamentos/{id}/comprovante` #### `POST /contribuinte/pagamentos/extrato-pdf`
Baixa o comprovante de um pagamento em PDF. Gera PDF do extrato de pagamentos. Body: `GerarExtratoPagamentosRequestDTO`.
**Path param:** `id` — ID do pagamento
**Response:** `application/pdf` **Response:** `application/pdf`
--- ---
#### `GET /contribuinte/pagamentos/{idContaCorrente}/comprovante`
Baixa comprovante de pagamento em PDF (quando houver `LancamentoTaxa` vinculado).
**Response:** `application/pdf` ou `404`
---
### Guias Emitidas
#### `GET /contribuinte/guias`
Lista guias do contribuinte logado (CPF/CNPJ injetado no backend). Paginado.
**Query params:** `numeroGuia`, `dataEmissaoInicio/Fim`, `dataVencimentoInicio/Fim`, `status`, `valorMinimo`, `valorMaximo`, `page`, `size`
**Response `data`:** `PageDTO<GuiaConsultaDTO>`
---
#### `GET /contribuinte/guias/{id}`
Detalhes completos da guia (`GuiaPagamentoDTO`).
---
#### `GET /contribuinte/guias/{id}/pdf`
PDF da guia.
---
### Dados Cadastrais ### Dados Cadastrais
#### `GET /contribuinte/dados` #### `GET /contribuinte/dados`
@ -673,8 +732,9 @@ O `preferred_username` do JWT é o CPF/CNPJ sem formatação — usado como iden
| Endpoint | Frontend pronto | Backend pronto | Mock | | Endpoint | Frontend pronto | Backend pronto | Mock |
|---|---|---|---| |---|---|---|---|
| `GET /publico/prefeitura/{dominio}` | ✓ | ✓ | — | | `GET /publico/prefeitura/{dominio}` | ✓ | ✓ | — |
| `GET /publico/certidao/consultar` | ✓ | pendente | ✓ | | `GET /publico/certidao/consultar` | ✓ | ✓ | — |
| `GET /publico/certidao/emitir` | ✓ | pendente | ✓ | | `GET /publico/certidao/modelos` | ✓ | ✓ | — |
| `GET /publico/certidao/emitir` | ✓ | ✓ | — |
| `GET /publico/iptu/consultar` | ✓ | pendente | ✓ | | `GET /publico/iptu/consultar` | ✓ | pendente | ✓ |
| `GET /publico/iptu/carne` | ✓ | pendente | ✓ | | `GET /publico/iptu/carne` | ✓ | pendente | ✓ |
| `GET /publico/iptu/boleto` | ✓ | pendente | ✓ | | `GET /publico/iptu/boleto` | ✓ | pendente | ✓ |

40
package-lock.json generated
View File

@ -1686,24 +1686,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/@nuxt/schema": {
"version": "4.4.6",
"resolved": "https://registry.npmjs.org/@nuxt/schema/-/schema-4.4.6.tgz",
"integrity": "sha512-7FDMuD+skbFMgfF2ORYKEAKEuEFbu2oS60dln5uVtn94c8DHWCseJSrT3FUHzVUlVwyhztPU6stzB44dEoWAzw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@vue/shared": "^3.5.34",
"defu": "^6.1.7",
"pathe": "^2.0.3",
"pkg-types": "^2.3.1",
"std-env": "^4.1.0"
},
"engines": {
"node": "^14.18.0 || >=16.10.0"
}
},
"node_modules/@nuxt/telemetry": { "node_modules/@nuxt/telemetry": {
"version": "2.8.0", "version": "2.8.0",
"resolved": "https://registry.npmjs.org/@nuxt/telemetry/-/telemetry-2.8.0.tgz", "resolved": "https://registry.npmjs.org/@nuxt/telemetry/-/telemetry-2.8.0.tgz",
@ -5587,17 +5569,6 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/commander": {
"version": "13.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz",
"integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=18"
}
},
"node_modules/commondir": { "node_modules/commondir": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
@ -7940,7 +7911,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -7961,7 +7931,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -7982,7 +7951,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -8003,7 +7971,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -8024,7 +7991,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -8045,7 +8011,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -8066,7 +8031,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -8087,7 +8051,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -8108,7 +8071,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -8129,7 +8091,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -8150,7 +8111,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [

View File

@ -58,8 +58,9 @@ export default defineEventHandler(async (event) => {
}) })
setResponseStatus(event, res.status) setResponseStatus(event, res.status)
const skipResponseHeaders = new Set(['transfer-encoding', 'content-encoding', 'content-length'])
for (const [name, value] of res.headers.entries()) { for (const [name, value] of res.headers.entries()) {
if (name === 'transfer-encoding') continue if (skipResponseHeaders.has(name.toLowerCase())) continue
setResponseHeader(event, name, value) setResponseHeader(event, name, value)
} }
return res._data return res._data

View File

@ -51,6 +51,7 @@ export async function exchangeCodeForTokens(opts: {
redirect_uri: opts.redirectUri, redirect_uri: opts.redirectUri,
code_verifier: opts.codeVerifier, code_verifier: opts.codeVerifier,
}) })
try {
return await $fetch<TokenResponse>( return await $fetch<TokenResponse>(
`${realmBase()}/protocol/openid-connect/token`, `${realmBase()}/protocol/openid-connect/token`,
{ {
@ -59,6 +60,13 @@ export async function exchangeCodeForTokens(opts: {
body: body.toString(), body: body.toString(),
}, },
) )
} catch (err: unknown) {
const fetchErr = err as { data?: { error?: string; error_description?: string }; message?: string }
const kcError = fetchErr.data?.error
const kcDesc = fetchErr.data?.error_description
const detail = kcDesc ?? kcError ?? fetchErr.message ?? 'erro desconhecido'
throw new Error(`Keycloak token: ${detail}`)
}
} }
export async function refreshTokens(refreshToken: string): Promise<TokenResponse> { export async function refreshTokens(refreshToken: string): Promise<TokenResponse> {

View File

@ -0,0 +1,124 @@
<script setup>
import { ref, watch } from 'vue'
import { portalService } from '@/services/portalService'
import { formatCurrency, formatDate, abrirPdf } from '@/utils/formatador'
const props = defineProps({
visible: { type: Boolean, default: false },
guiaId: { type: Number, default: null },
statusCodigo: { type: Number, default: null },
})
const emit = defineEmits(['update:visible'])
const isLoading = ref(false)
const isLoadingPdf = ref(false)
const guia = ref(null)
watch(
() => [props.visible, props.guiaId],
([vis, id]) => {
if (vis && id) carregarDetalhes()
},
{ immediate: true }
)
async function carregarDetalhes() {
if (!props.guiaId) return
isLoading.value = true
guia.value = null
try {
const res = await portalService.buscarGuia(props.guiaId)
guia.value = res.data
} finally {
isLoading.value = false
}
}
function fechar() {
emit('update:visible', false)
guia.value = null
}
function formatarNumeroGuia(numero) {
return numero ? String(numero).padStart(10, '0') : '—'
}
function getSeverityStatus(codigo) {
return { 0: 'danger', 1: 'info', 2: 'success', 3: 'warn' }[codigo ?? props.statusCodigo] ?? 'secondary'
}
async function visualizarPdf() {
if (!props.guiaId) return
isLoadingPdf.value = true
try {
const buf = await portalService.baixarGuiaEmitidaPdf(props.guiaId)
abrirPdf(buf)
} finally {
isLoadingPdf.value = false
}
}
</script>
<template>
<Dialog
:visible="visible"
modal
:header="guia ? `Detalhes da Guia #${formatarNumeroGuia(guia.numGuia)}` : 'Detalhes da Guia'"
:style="{ width: 'min(95vw, 56rem)' }"
@update:visible="(v) => emit('update:visible', v)"
>
<div v-if="isLoading" class="flex justify-center p-8">
<ProgressSpinner />
</div>
<div v-else-if="guia" class="space-y-4">
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3 text-sm">
<div>
<p class="text-xs text-slate-500">Número</p>
<p class="font-bold">{{ formatarNumeroGuia(guia.numGuia) }}</p>
</div>
<div>
<p class="text-xs text-slate-500">Status</p>
<Tag :value="guia.statusDescricao ?? 'Emitida'" :severity="getSeverityStatus()" />
</div>
<div>
<p class="text-xs text-slate-500">Emissão</p>
<p>{{ formatDate(guia.dataEmissao) }}</p>
</div>
<div>
<p class="text-xs text-slate-500">Vencimento</p>
<p>{{ formatDate(guia.dataVencimento) }}</p>
</div>
<div>
<p class="text-xs text-slate-500">Valor Total</p>
<p class="font-bold">{{ formatCurrency(guia.valorTotal) }}</p>
</div>
<div v-if="guia.linhaDigitavel" class="sm:col-span-3">
<p class="text-xs text-slate-500">Linha Digitável</p>
<p class="font-mono text-xs break-all">{{ guia.linhaDigitavel }}</p>
</div>
</div>
<DataTable :value="guia.itens ?? []" size="small" show-gridlines>
<Column field="tributo" header="Tributo" />
<Column field="periodoRef" header="Período" />
<Column field="doc" header="Documento" />
<Column field="valorTotal" header="Total" body-class="text-right">
<template #body="{ data }">{{ formatCurrency(data.valorTotal) }}</template>
</Column>
</DataTable>
</div>
<template #footer>
<Button label="Fechar" severity="secondary" outlined @click="fechar" />
<Button label="Visualizar PDF" icon="pi pi-file-pdf" :loading="isLoadingPdf" @click="visualizarPdf" />
</template>
</Dialog>
</template>
<style scoped>
.font-mono {
font-family: 'Courier New', monospace;
}
</style>

View File

@ -0,0 +1,71 @@
<script setup>
import { ref } from 'vue'
import { portalService } from '@/services/portalService'
import { formatCurrency, formatDate } from '@/utils/formatador'
const visivel = ref(false)
const isLoading = ref(false)
const transacoes = ref([])
const mensagemErro = ref('')
async function abrir(idContaCorrente) {
visivel.value = true
isLoading.value = true
transacoes.value = []
mensagemErro.value = ''
try {
const res = await portalService.getTransacoes(idContaCorrente)
transacoes.value = res.data ?? []
} catch (e) {
mensagemErro.value = e?.data?.description ?? 'Erro ao buscar transações.'
} finally {
isLoading.value = false
}
}
defineExpose({ abrir })
</script>
<template>
<Dialog
v-model:visible="visivel"
modal
header="Transações da Conta Corrente"
:style="{ width: 'min(95vw, 56rem)' }"
>
<div v-if="isLoading" class="flex justify-center p-8">
<ProgressSpinner />
</div>
<p v-else-if="mensagemErro" class="text-sm text-red-600">{{ mensagemErro }}</p>
<DataTable
v-else
:value="transacoes"
show-gridlines
size="small"
scrollable
scroll-height="400px"
empty-message="Nenhuma transação encontrada."
>
<Column field="id" header="ID" style="width: 60px" />
<Column field="data" header="Data" style="width: 100px">
<template #body="{ data }">{{ formatDate(data.data) }}</template>
</Column>
<Column field="tipoTransacao" header="Tipo" />
<Column field="numDocOrigem" header="Doc. Origem" style="width: 110px" />
<Column field="valorPrincipal" header="Principal" body-class="text-right">
<template #body="{ data }">{{ formatCurrency(data.valorPrincipal) }}</template>
</Column>
<Column field="valorMulta" header="Multa" body-class="text-right">
<template #body="{ data }">{{ formatCurrency(data.valorMulta) }}</template>
</Column>
<Column field="valorJuros" header="Juros" body-class="text-right">
<template #body="{ data }">{{ formatCurrency(data.valorJuros) }}</template>
</Column>
<Column field="valorTotal" header="Total" body-class="text-right">
<template #body="{ data }">
<strong>{{ formatCurrency(data.valorTotal) }}</strong>
</template>
</Column>
</DataTable>
</Dialog>
</template>

View File

@ -0,0 +1,212 @@
import { ref, computed } from 'vue'
import { portalService } from '@/services/portalService'
import { formatDateISO, dateToYYYYMM, abrirPdf, baixarPdf } from '@/utils/formatador'
export function useExtratoDebitosPortal() {
const resultados = ref([])
const isLoading = ref(false)
const isLoadingGuia = ref(false)
const isLoadingExtrato = ref(false)
const mensagemErro = ref('')
const dataVencimento = ref(null)
const erros = ref({})
const filtro = ref({
periodoIni: null,
periodoFim: null,
estadoConta: null,
})
const opcoesEstadoConta = [
{ label: 'Todos', value: null },
{ label: 'Saldo Débito', value: 1 },
{ label: 'Saldo Zero', value: 2 },
{ label: 'Saldo Crédito', value: 3 },
]
const temSelecionado = computed(() =>
resultados.value.some(t => t.selecionados?.length > 0)
)
const totalizadores = computed(() => {
let principal = 0, multa = 0, juros = 0, valorTotal = 0
resultados.value.forEach(t => {
t.selecionados?.forEach(d => {
principal += d.valorAtulPrincipal ?? d.valorPrincipal ?? 0
multa += d.valorAtulMulta ?? d.valorMulta ?? 0
juros += d.valorAtulJuros ?? d.valorJuros ?? 0
valorTotal += d.valorTotal ?? 0
})
})
return { principal, multa, juros, valorTotal }
})
function mapearDebitos(dados) {
return dados.map(item => ({
...item,
debitos: (item.debitos || []).map((debito, idx) => ({
...debito,
id: `${item.idContaTributo}_${idx}`,
})),
selecionados: [],
}))
}
async function consultar() {
isLoading.value = true
mensagemErro.value = ''
resultados.value = []
try {
const params = {
periodoIni: filtro.value.periodoIni ? dateToYYYYMM(filtro.value.periodoIni) : undefined,
periodoFim: filtro.value.periodoFim ? dateToYYYYMM(filtro.value.periodoFim) : undefined,
idEstadoConta: filtro.value.estadoConta,
}
const res = await portalService.getDebitosExtrato(params)
const dados = res.data ?? []
resultados.value = mapearDebitos(dados)
} catch (e) {
mensagemErro.value = e?.data?.description ?? 'Erro ao consultar débitos.'
} finally {
isLoading.value = false
}
}
function limparFiltros() {
filtro.value = {
periodoIni: null,
periodoFim: null,
estadoConta: null,
}
resultados.value = []
mensagemErro.value = ''
}
function montarDtoGuia() {
return {
listaDebitos: resultados.value
.filter(t => t.selecionados?.length > 0)
.map(t => ({
idTributo: t.idTributo,
identificador: t.identificador,
descricaoTributo: t.descricaoTributo,
siglaTributo: t.siglaTributo,
descricaoContaTributo: t.descricaoContaTributo,
idContaTributo: t.idContaTributo,
totalPagamentos: t.totalPagamentos,
debitos: t.selecionados.map(mapDebitoPayload),
})),
dataVencimento: formatDateISO(dataVencimento.value),
}
}
function montarDtoExtrato(usarSelecionados) {
return {
listaDebitos: resultados.value
.filter(t => (usarSelecionados ? t.selecionados?.length > 0 : t.debitos?.length > 0))
.map(t => ({
idTributo: t.idTributo,
identificador: t.identificador,
descricaoTributo: t.descricaoTributo,
siglaTributo: t.siglaTributo,
descricaoContaTributo: t.descricaoContaTributo,
idContaTributo: t.idContaTributo,
totalPagamentos: t.totalPagamentos,
debitos: (usarSelecionados ? t.selecionados : t.debitos).map(mapDebitoPayload),
})),
}
}
function mapDebitoPayload(debito) {
return {
idContaCorrente: debito.idContaCorrente,
numDoc: debito.numDoc,
codigoEstadoConta: debito.codigoEstadoConta,
estadoConta: debito.estadoConta,
dataVencimento: debito.dataVencimento,
periodoRef: debito.periodoRef,
numParcela: debito.numParcela,
valorPrincipal: debito.valorPrincipal,
valorJuros: debito.valorJuros,
valorMulta: debito.valorMulta,
valorCorrecao: debito.valorCorrecao,
valorAtulPrincipal: debito.valorAtulPrincipal,
valorAtulJuros: debito.valorAtulJuros,
valorAtulMulta: debito.valorAtulMulta,
valorAtulCorrecao: debito.valorAtulCorrecao,
valorTotal: debito.valorTotal,
}
}
async function gerarGuia() {
if (!temSelecionado.value) {
mensagemErro.value = 'Selecione pelo menos um débito.'
return
}
if (!dataVencimento.value) {
erros.value.dataVencimento = 'Informe a data de vencimento.'
return
}
isLoadingGuia.value = true
mensagemErro.value = ''
try {
const res = await portalService.gerarGuiaDebitos(montarDtoGuia())
const idDoctoArr = res.data?.idDoctoArr ?? res.data?.idDoctoarr
const buf = await portalService.baixarGuiaPdf(idDoctoArr)
abrirPdf(buf)
} catch (e) {
mensagemErro.value = e?.data?.description ?? 'Erro ao gerar guia de pagamento.'
} finally {
isLoadingGuia.value = false
}
}
async function gerarExtratoPdf(selecionados = true) {
const dto = montarDtoExtrato(selecionados)
const total = dto.listaDebitos.reduce((s, t) => s + (t.debitos?.length ?? 0), 0)
if (total === 0) {
mensagemErro.value = selecionados
? 'Selecione pelo menos um débito.'
: 'Não há débitos para gerar o extrato.'
return
}
isLoadingExtrato.value = true
try {
const buf = await portalService.gerarExtratoDebitosPdf(dto)
baixarPdf(buf, selecionados ? 'extrato-debitos.pdf' : 'extrato-todos-debitos.pdf')
} catch (e) {
mensagemErro.value = e?.data?.description ?? 'Erro ao gerar extrato.'
} finally {
isLoadingExtrato.value = false
}
}
function getEstadoSeverity(codigo) {
switch (codigo) {
case 0: return 'warning'
case 1: return 'danger'
case 2: return 'success'
case 3: return 'danger'
default: return 'info'
}
}
return {
resultados,
isLoading,
isLoadingGuia,
isLoadingExtrato,
mensagemErro,
dataVencimento,
erros,
filtro,
opcoesEstadoConta,
temSelecionado,
totalizadores,
consultar,
limparFiltros,
gerarGuia,
gerarExtratoPdf,
getEstadoSeverity,
}
}

View File

@ -0,0 +1,197 @@
import { ref, computed } from 'vue'
import { portalService } from '@/services/portalService'
import { formatDateISO, baixarPdf } from '@/utils/formatador'
function formatDateForDisplay(date, monthOnly = false) {
if (!date) return '-'
const d = new Date(date)
if (isNaN(d.getTime())) return '-'
if (monthOnly) {
return `${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`
}
return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`
}
function montarParametrosTributoConta(lista) {
if (!lista?.length) {
return {
codTributo: '-',
descricaoTributo: '-',
codContaTributo: '-',
descricaoContaTributo: '-',
}
}
const chaves = new Map()
for (const item of lista) {
const key = `${item.idTributo ?? ''}_${item.idContaTributo ?? ''}`
if (!chaves.has(key)) chaves.set(key, item)
}
if (chaves.size === 1) {
const t = [...chaves.values()][0]
return {
codTributo: t.idTributo != null ? String(t.idTributo) : (t.identificador ?? '-'),
descricaoTributo: t.descricaoTributo || '-',
codContaTributo: t.idContaTributo != null ? String(t.idContaTributo) : '-',
descricaoContaTributo: t.descricaoContaTributo || '-',
}
}
const tributos = [...new Set(lista.map(t => t.descricaoTributo).filter(Boolean))]
return {
codTributo: 'Diversos',
descricaoTributo: tributos.length ? tributos.join(', ') : 'Diversos tributos',
codContaTributo: '-',
descricaoContaTributo: '-',
}
}
export function useExtratoPagamentosPortal() {
const tributos = ref([])
const isLoading = ref(false)
const isLoadingPdf = ref(false)
const mensagemErro = ref('')
const filtro = ref({
debitosInicio: null,
debitosFim: null,
pagInicio: null,
pagFim: null,
})
const totais = computed(() => {
let principal = 0, multa = 0, juros = 0, desconto = 0, total = 0
for (const t of tributos.value) {
for (const p of t.pagamentos || []) {
principal += p.lancado ?? 0
multa += p.multa ?? 0
juros += p.juros ?? 0
desconto += p.desconto ?? 0
total += p.total ?? 0
}
}
return { principal, multa, juros, desconto, total }
})
async function consultar() {
isLoading.value = true
mensagemErro.value = ''
tributos.value = []
try {
const params = {
pagInicio: formatDateISO(filtro.value.pagInicio) || undefined,
pagFim: formatDateISO(filtro.value.pagFim) || undefined,
periodoIni: formatDateISO(filtro.value.debitosInicio) || undefined,
periodoFim: formatDateISO(filtro.value.debitosFim) || undefined,
}
const res = await portalService.getPagamentosExtrato(params)
const dados = res.data ?? []
tributos.value = dados.map((item, idx) => ({
idTributo: item.idTributo,
idContaTributo: item.idContaTributo,
identificador: item.identificador,
descricaoTributo: item.descricaoTributo,
descricaoContaTributo: item.descricaoContaTributo,
totalPagamentos: item.totalPagamentos ?? 0,
pagamentos: (item.pagamentos || []).map((p, i) => ({
id: `${item.idContaTributo}_${i}_${p.idContaCorrente ?? i}`,
idContaCorrente: p.idContaCorrente,
ndoc: p.numDoc,
nguia: p.numGuia,
vencimento: p.dataVencimento,
pagamento: p.dataPagamento,
refer: p.periodoRef,
lancado: p.valorPrincipal ?? 0,
multa: p.valorMulta ?? 0,
juros: p.valorJuros ?? 0,
desconto: p.valorDesconto ?? 0,
total: p.valorTotal ?? 0,
})),
}))
} catch (e) {
mensagemErro.value = e?.data?.description ?? 'Erro ao consultar pagamentos.'
} finally {
isLoading.value = false
}
}
function limparFiltros() {
filtro.value = {
debitosInicio: null,
debitosFim: null,
pagInicio: null,
pagFim: null,
}
tributos.value = []
}
async function gerarPdf() {
if (!tributos.value.length) {
mensagemErro.value = 'Consulte os pagamentos antes de gerar o PDF.'
return
}
const listaPagamentos = tributos.value.map(t => ({
idTributo: t.idTributo,
identificador: t.identificador,
descricaoTributo: t.descricaoTributo,
siglaTributo: t.siglaTributo,
idContaTributo: t.idContaTributo,
descricaoContaTributo: t.descricaoContaTributo,
totalPagamentos: t.totalPagamentos,
pagamentos: (t.pagamentos || []).map(p => ({
numDoc: p.ndoc,
numGuia: p.nguia,
dataVencimento: p.vencimento,
dataPagamento: p.pagamento,
periodoRef: p.refer,
valorPrincipal: p.lancado,
valorJuros: p.juros,
valorMulta: p.multa,
valorDesconto: p.desconto,
valorTotal: p.total,
})),
}))
const dto = {
listaPagamentos,
...montarParametrosTributoConta(tributos.value),
periodoDebitosInicio: formatDateForDisplay(filtro.value.debitosInicio, true),
periodoDebitosFim: formatDateForDisplay(filtro.value.debitosFim, true),
periodoPagamentosInicio: formatDateForDisplay(filtro.value.pagInicio),
periodoPagamentosFim: formatDateForDisplay(filtro.value.pagFim),
}
isLoadingPdf.value = true
try {
const buf = await portalService.gerarExtratoPagamentosPdf(dto)
baixarPdf(buf, 'extrato-pagamentos.pdf')
} catch (e) {
mensagemErro.value = e?.data?.description ?? 'Erro ao gerar PDF.'
} finally {
isLoadingPdf.value = false
}
}
async function baixarComprovante(pag) {
if (!pag.idContaCorrente) return null
try {
return await portalService.getComprovante(pag.idContaCorrente)
} catch {
return null
}
}
return {
tributos,
isLoading,
isLoadingPdf,
mensagemErro,
filtro,
totais,
consultar,
limparFiltros,
gerarPdf,
baixarComprovante,
}
}

View File

@ -0,0 +1,106 @@
import { ref } from 'vue'
import { portalService } from '@/services/portalService'
import { formatDateISO, abrirPdf } from '@/utils/formatador'
export function useGuiasEmitidasPortal() {
const guias = ref([])
const isLoading = ref(false)
const mensagemErro = ref('')
const pagina = ref(0)
const totalPaginas = ref(0)
const totalElementos = ref(0)
const tamanhoPagina = ref(15)
const filtro = ref({
numeroGuia: '',
status: null,
valorMinimo: null,
valorMaximo: null,
dataEmissaoInicio: null,
dataEmissaoFim: null,
dataVencimentoInicio: null,
dataVencimentoFim: null,
})
const statusOptions = [
{ label: 'Não Processado', value: 0 },
{ label: 'Emitida/Ativa', value: 1 },
{ label: 'Paga', value: 2 },
{ label: 'Cancelada', value: 3 },
]
async function consultar() {
isLoading.value = true
mensagemErro.value = ''
try {
const params = {
page: pagina.value,
size: tamanhoPagina.value,
numeroGuia: filtro.value.numeroGuia || undefined,
status: filtro.value.status ?? undefined,
valorMinimo: filtro.value.valorMinimo ?? undefined,
valorMaximo: filtro.value.valorMaximo ?? undefined,
dataEmissaoInicio: formatDateISO(filtro.value.dataEmissaoInicio) || undefined,
dataEmissaoFim: formatDateISO(filtro.value.dataEmissaoFim) || undefined,
dataVencimentoInicio: formatDateISO(filtro.value.dataVencimentoInicio) || undefined,
dataVencimentoFim: formatDateISO(filtro.value.dataVencimentoFim) || undefined,
}
const res = await portalService.listarGuias(params)
const pageData = res.data ?? {}
guias.value = pageData.data ?? []
totalPaginas.value = pageData.paginasTotais ?? 0
totalElementos.value = pageData.elementosTotais ?? 0
} catch (e) {
mensagemErro.value = e?.data?.description ?? 'Erro ao consultar guias.'
guias.value = []
} finally {
isLoading.value = false
}
}
function limparFiltros() {
filtro.value = {
numeroGuia: '', status: null, valorMinimo: null, valorMaximo: null,
dataEmissaoInicio: null, dataEmissaoFim: null,
dataVencimentoInicio: null, dataVencimentoFim: null,
}
pagina.value = 0
consultar()
}
function mudarPagina(novaPagina) {
pagina.value = novaPagina
consultar()
}
async function visualizarPdf(id) {
const buf = await portalService.baixarGuiaEmitidaPdf(id)
abrirPdf(buf)
}
function formatarNumeroGuia(numero) {
return numero ? String(numero).padStart(10, '0') : '—'
}
function getSeverityStatus(codigo) {
return { 0: 'danger', 1: 'info', 2: 'success', 3: 'warn' }[codigo] ?? 'secondary'
}
return {
guias,
isLoading,
mensagemErro,
pagina,
totalPaginas,
totalElementos,
tamanhoPagina,
filtro,
statusOptions,
consultar,
limparFiltros,
mudarPagina,
visualizarPdf,
formatarNumeroGuia,
getSeverityStatus,
}
}

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/guias', label: 'Guias Emitidas', icon: 'pi-file' },
{ path: '/portal/taxas', label: 'Taxas', icon: 'pi-file-export' }, { 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/pagamentos', label: 'Pagamentos', icon: 'pi-credit-card' }, { path: '/portal/pagamentos', label: 'Pagamentos', icon: 'pi-credit-card' },

View File

@ -11,6 +11,7 @@ import bgTutoia from '@/assets/images/bg-tutoia.jpeg'
const { prefersReducedMotion } = useMotion() const { prefersReducedMotion } = useMotion()
const router = useRouter() const router = useRouter()
const route = useRoute()
const prefeitura = usePrefeituraStore() const prefeitura = usePrefeituraStore()
const { isAuthenticated, nomeUsuario, login } = useAuth() const { isAuthenticated, nomeUsuario, login } = useAuth()
const { requested: focusLoginRequested, consume: consumeFocusLogin } = useFocusLoginInput() const { requested: focusLoginRequested, consume: consumeFocusLogin } = useFocusLoginInput()
@ -19,6 +20,12 @@ const documento = ref('')
const erro = ref('') const erro = ref('')
const carregando = ref(false) const carregando = ref(false)
const AUTH_ERROR_MESSAGES = {
exchange_failed: 'Login cancelado: o servidor não conseguiu validar a sessão com o Keycloak. O client secret local provavelmente está incorreto — peça o valor atual ao admin.',
invalid_state: 'Sessão de login expirada. Tente entrar novamente.',
missing_params: 'Resposta inválida do login. Tente entrar novamente.',
}
// Ref ao DocumentoInput usado pelo botão "Entrar" do AppHeader pra focar o campo // Ref ao DocumentoInput usado pelo botão "Entrar" do AppHeader pra focar o campo
const documentoRef = ref(null) const documentoRef = ref(null)
@ -89,6 +96,14 @@ const AVISOS_FALLBACK = [
const avisos = ref(AVISOS_FALLBACK) const avisos = ref(AVISOS_FALLBACK)
onMounted(async () => { onMounted(async () => {
const authError = route.query.auth_error
if (typeof authError === 'string') {
erro.value = AUTH_ERROR_MESSAGES[authError] ?? `Erro ao autenticar (${authError}).`
const query = { ...route.query }
delete query.auth_error
router.replace({ query })
}
try { try {
const res = await avisoService.listar(prefeitura.dominio) const res = await avisoService.listar(prefeitura.dominio)
const lista = (res.data ?? []).map(a => ({ const lista = (res.data ?? []).map(a => ({
@ -119,7 +134,8 @@ const servicosPublicos = [
] ]
const servicosAutenticados = [ const servicosAutenticados = [
{ icon: 'pi-receipt', titulo: 'Débitos e Guias', descricao: 'Emita guias de pagamento de todos os seus tributos.', to: '/portal/debitos' }, { icon: 'pi-receipt', titulo: 'Extrato de Débitos', descricao: 'Consulte débitos e emita guias de pagamento.', to: '/portal/debitos' },
{ icon: 'pi-file', titulo: 'Guias Emitidas', descricao: 'Consulte e baixe guias de pagamento já emitidas.', to: '/portal/guias' },
{ icon: 'pi-file-edit', titulo: 'Certidões Ativas', descricao: 'Acesse e reemita certidões emitidas anteriormente.', to: '/portal/certidoes' }, { icon: 'pi-file-edit', titulo: 'Certidões Ativas', descricao: 'Acesse e reemita certidões emitidas anteriormente.', to: '/portal/certidoes' },
{ icon: 'pi-credit-card', titulo: 'Pagamentos', descricao: 'Histórico completo com comprovantes para download.', to: '/portal/pagamentos' }, { icon: 'pi-credit-card', titulo: 'Pagamentos', descricao: 'Histórico completo com comprovantes para download.', to: '/portal/pagamentos' },
{ icon: 'pi-user', titulo: 'Dados Cadastrais', descricao: 'Visualize e mantenha seus dados sempre atualizados.', to: '/portal/dados' }, { icon: 'pi-user', titulo: 'Dados Cadastrais', descricao: 'Visualize e mantenha seus dados sempre atualizados.', to: '/portal/dados' },

View File

@ -1,286 +1,224 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { portalService } from '@/services/portalService' import { useExtratoDebitosPortal } from '@/composables/useExtratoDebitosPortal'
import { formatCurrency, formatDate } from '@/utils/formatador'
import ModalTransacoesContaCorrente from '@/components/extrato/ModalTransacoesContaCorrente.vue'
definePageMeta({ definePageMeta({
layout: 'portal', layout: 'portal',
middleware: 'auth', middleware: 'auth',
}) })
const debitos = ref([]) const modalTransacoes = ref(null)
const carregando = ref(true)
const carregandoGuia = ref(null)
const filtroTipo = ref(null)
const filtroStatus = ref(null)
const mensagemErro = ref('')
const tiposDisponiveis = ['IPTU', 'ISS', 'TAXA', 'MULTA', 'DIVIDA_ATIVA'] const {
const statusDisponiveis = [ resultados,
{ value: 'VENCIDO', label: 'Vencido', classe: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400' }, isLoading,
{ value: 'A_VENCER', label: 'A vencer', classe: 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400' }, isLoadingGuia,
{ value: 'PARCELADO', label: 'Parcelado', classe: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400' }, isLoadingExtrato,
] mensagemErro,
dataVencimento,
erros,
filtro,
opcoesEstadoConta,
temSelecionado,
totalizadores,
consultar,
limparFiltros,
gerarGuia,
gerarExtratoPdf,
getEstadoSeverity,
} = useExtratoDebitosPortal()
const statusClasse = Object.fromEntries(statusDisponiveis.map(s => [s.value, s.classe])) onMounted(() => consultar())
const statusLabel = Object.fromEntries(statusDisponiveis.map(s => [s.value, s.label]))
onMounted(() => carregar()) function getSegundoNome(texto) {
const palavras = (texto || '').trim().split(/\s+/)
async function carregar() { return palavras.length > 1 ? palavras[1] : texto
carregando.value = true
mensagemErro.value = ''
try {
const params = {}
if (filtroTipo.value) params.tipo = filtroTipo.value
if (filtroStatus.value) params.status = filtroStatus.value
const res = await portalService.getDebitos(params)
debitos.value = res.data ?? []
} catch (e) {
mensagemErro.value = e?.data?.description ?? 'Não foi possível carregar os débitos.'
} finally {
carregando.value = false
}
}
async function emitirGuia(debito) {
carregandoGuia.value = debito.id
try {
const buf = await portalService.emitirGuia(debito.id)
const url = URL.createObjectURL(new Blob([buf], { type: 'application/pdf' }))
const a = document.createElement('a')
a.href = url
a.download = `guia-${debito.id}.pdf`
a.click()
URL.revokeObjectURL(url)
} catch (e) {
const status = e?.status ?? e?.response?.status
mensagemErro.value = status === 501
? 'Emissão de guia em configuração. Procure a prefeitura para geração manual.'
: 'Erro ao gerar a guia. Tente novamente.'
} finally {
carregandoGuia.value = null
}
}
const totalSelecionado = computed(() =>
debitos.value
.filter(d => d._selecionado)
.reduce((sum, d) => sum + (d.valorAtualizado ?? d.valor), 0)
)
const temSelecionados = computed(() => debitos.value.some(d => d._selecionado))
function toggleTodos(val) {
debitos.value.forEach(d => (d._selecionado = val))
}
function formatarMoeda(valor) {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(valor ?? 0)
}
function aplicarFiltro() {
carregar()
}
function limparFiltros() {
filtroTipo.value = null
filtroStatus.value = null
carregar()
}
function tituloDebito(d) {
if (d.tipo && d.referencia) return `${d.tipo}${d.referencia}`
if (d.tipo) return d.tipo
return d.descricao ?? 'Débito'
}
function formatarData(iso) {
if (!iso) return '—'
const partes = String(iso).split('-')
if (partes.length !== 3) return iso
return `${partes[2]}/${partes[1]}/${partes[0]}`
} }
</script> </script>
<template> <template>
<div class="space-y-6"> <div class="space-y-6">
<div class="flex items-center justify-between gap-4 flex-wrap">
<div> <div>
<h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100">Débitos e Guias</h1> <h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100">Extrato de Débitos</h1>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">Consulte seus débitos e emita guias de pagamento.</p> <p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
</div> Consulte seus débitos, selecione parcelas e emita guias de pagamento.
</p>
</div> </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="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4 space-y-4">
<div class="flex-1 min-w-[160px]"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1.5">Tipo</label> <div>
<Select <label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1.5">Período Início</label>
v-model="filtroTipo" <DatePicker v-model="filtro.periodoIni" view="month" date-format="mm/yy" show-icon class="w-full" size="small" />
:options="tiposDisponiveis"
placeholder="Todos"
show-clear
class="w-full"
size="small"
/>
</div> </div>
<div class="flex-1 min-w-[160px]"> <div>
<label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1.5">Status</label> <label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1.5">Período Fim</label>
<DatePicker v-model="filtro.periodoFim" view="month" date-format="mm/yy" show-icon class="w-full" size="small" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1.5">Estado da Conta</label>
<Select <Select
v-model="filtroStatus" v-model="filtro.estadoConta"
:options="statusDisponiveis" :options="opcoesEstadoConta"
option-label="label" option-label="label"
option-value="value" option-value="value"
placeholder="Todos" placeholder="Todos"
show-clear
class="w-full" class="w-full"
size="small" size="small"
/> />
</div> </div>
<div class="flex gap-2"> </div>
<Button label="Filtrar" icon="pi pi-filter" size="small" @click="aplicarFiltro" /> <div class="flex gap-2 flex-wrap">
<Button label="Limpar" severity="secondary" size="small" outlined @click="limparFiltros" /> <Button label="Consultar" icon="pi pi-search" size="small" :loading="isLoading" @click="consultar" />
<Button label="Limpar" icon="pi pi-eraser" size="small" severity="secondary" outlined @click="limparFiltros" />
</div> </div>
</div> </div>
<Transition <Message v-if="mensagemErro" severity="warn" :closable="false">{{ mensagemErro }}</Message>
enter-active-class="transition-all duration-200"
enter-from-class="opacity-0 -translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition-all duration-150"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-2"
>
<div
v-if="temSelecionados"
class="bg-primary/8 dark:bg-primary/15 border border-primary/20 rounded-xl p-4 flex items-center justify-between gap-4"
>
<p class="text-sm font-semibold text-primary">
Total selecionado: {{ formatarMoeda(totalSelecionado) }}
</p>
<Button label="Emitir guia unificada" icon="pi pi-download" size="small" />
</div>
</Transition>
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden"> <div v-if="isLoading" class="space-y-3">
<div v-for="i in 3" :key="i" class="h-24 bg-slate-200 dark:bg-slate-700 rounded-xl animate-pulse" />
<div v-if="carregando" class="divide-y divide-slate-100 dark:divide-slate-700">
<div v-for="i in 5" :key="i" class="p-5 flex items-center gap-4">
<div class="w-4 h-4 bg-slate-200 dark:bg-slate-700 rounded animate-pulse" />
<div class="flex-1 space-y-2">
<div class="h-3.5 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-1/3" />
<div class="h-3 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-1/4" />
</div>
<div class="h-3.5 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-20" />
<div class="h-6 bg-slate-200 dark:bg-slate-700 rounded-full animate-pulse w-16" />
<div class="h-8 bg-slate-200 dark:bg-slate-700 rounded-lg animate-pulse w-24" />
</div>
</div> </div>
<div v-else-if="debitos.length === 0 && !mensagemErro" class="p-12 text-center"> <div v-else-if="resultados.length === 0 && !mensagemErro" class="bg-white dark:bg-slate-800 rounded-xl border p-12 text-center">
<i class="pi pi-check-circle text-emerald-400 dark:text-emerald-500 text-4xl mb-3 block" aria-hidden="true" /> <i class="pi pi-check-circle text-emerald-400 text-4xl mb-3 block" />
<p class="font-semibold text-slate-700 dark:text-slate-200">Nenhum débito encontrado</p> <p class="font-semibold text-slate-700 dark:text-slate-200">Nenhum débito encontrado</p>
<p class="text-sm text-slate-400 dark:text-slate-500 mt-1">Sua situação fiscal está regularizada.</p>
</div> </div>
<div v-else-if="mensagemErro" class="p-8 text-center"> <template v-else-if="resultados.length">
<i class="pi pi-exclamation-triangle text-amber-400 text-3xl mb-3 block" aria-hidden="true" /> <Accordion :multiple="true" :active-index="[0]">
<p class="text-sm text-slate-600 dark:text-slate-300">{{ mensagemErro }}</p> <AccordionPanel v-for="(tributo, index) in resultados" :key="tributo.idContaTributo" :value="String(index)">
<Button label="Tentar novamente" severity="secondary" size="small" class="mt-4" @click="carregar" /> <AccordionHeader>
</div> <div class="flex items-center justify-between w-full gap-2 pr-2">
<span class="text-sm font-semibold truncate">
<div v-else> Conta {{ tributo.idContaTributo }} {{ tributo.descricaoContaTributo }} / {{ tributo.descricaoTributo }}
<!-- Cabeçalho apenas desktop --> </span>
<div class="hidden sm: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="text-sm shrink-0">
<Checkbox :binary="true" @change="toggleTodos($event.target.checked)" /> Total: <strong>{{ formatCurrency(tributo.totalPagamentos) }}</strong>
<span class="flex-1">Descrição</span>
<span class="w-28 text-right">Vencimento</span>
<span class="w-28 text-right">Valor</span>
<span class="w-20 text-center">Status</span>
<span class="w-28" />
</div>
<div class="divide-y divide-slate-100 dark:divide-slate-700">
<div
v-for="debito in debitos"
:key="debito.id"
class="hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors"
>
<!-- MOBILE -->
<div class="sm:hidden p-4 space-y-3">
<div class="flex items-start gap-3">
<Checkbox v-model="debito._selecionado" :binary="true" class="mt-0.5 shrink-0" />
<div class="flex-1 min-w-0">
<p class="text-sm font-bold text-slate-800 dark:text-slate-100 leading-snug">{{ tituloDebito(debito) }}</p>
<p class="text-xs text-slate-400 dark:text-slate-500 mt-0.5"> {{ debito.descricao }}</p>
</div>
<span
:class="['text-xs font-semibold px-2 py-1 rounded-full whitespace-nowrap shrink-0', statusClasse[debito.status] ?? 'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300']"
>
{{ statusLabel[debito.status] ?? debito.status }}
</span> </span>
</div> </div>
<div class="flex items-end justify-between pl-7"> </AccordionHeader>
<AccordionContent>
<DataTable
v-model:selection="tributo.selecionados"
:value="tributo.debitos"
data-key="id"
size="small"
show-gridlines
selection-mode="multiple"
scrollable
class="mt-2"
>
<Column selection-mode="multiple" header-style="width: 3rem" />
<Column field="numDoc" header="Nº Doc" style="min-width: 90px" />
<Column field="estadoConta" header="Estado" style="min-width: 90px">
<template #body="{ data }">
<Tag :value="getSegundoNome(data.estadoConta)" :severity="getEstadoSeverity(data.codigoEstadoConta)" />
</template>
</Column>
<Column field="periodoRef" header="Período" style="min-width: 80px" />
<Column field="dataVencimento" header="Vencimento" style="min-width: 95px">
<template #body="{ data }">{{ formatDate(data.dataVencimento) }}</template>
</Column>
<Column field="valorPrincipal" header="Principal" body-class="text-right" style="min-width: 100px">
<template #body="{ data }">{{ formatCurrency(data.valorPrincipal) }}</template>
</Column>
<Column field="valorMulta" header="Multa" body-class="text-right" style="min-width: 90px">
<template #body="{ data }">{{ formatCurrency(data.valorMulta) }}</template>
</Column>
<Column field="valorJuros" header="Juros" body-class="text-right" style="min-width: 90px">
<template #body="{ data }">{{ formatCurrency(data.valorJuros) }}</template>
</Column>
<Column field="valorTotal" header="Total" body-class="text-right" style="min-width: 100px">
<template #body="{ data }">
<strong>{{ formatCurrency(data.valorTotal) }}</strong>
</template>
</Column>
<Column header="" style="width: 3rem">
<template #body="{ data }">
<Button
icon="pi pi-search"
text
rounded
size="small"
@click="modalTransacoes?.abrir(data.idContaCorrente)"
/>
</template>
</Column>
</DataTable>
</AccordionContent>
</AccordionPanel>
</Accordion>
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4 space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div> <div>
<p class="text-xs text-slate-400 dark:text-slate-500">Vence em {{ formatarData(debito.vencimento) }}</p> <label class="block text-xs font-semibold text-slate-500 uppercase mb-1.5">Vencimento da Guia</label>
<p class="text-base font-bold text-slate-800 dark:text-slate-100 mt-0.5">{{ formatarMoeda(debito.valorAtualizado ?? debito.valor) }}</p> <DatePicker
<p v-if="debito.valorAtualizado > debito.valor" class="text-xs text-slate-400 line-through">{{ formatarMoeda(debito.valor) }}</p> v-model="dataVencimento"
show-icon
:min-date="new Date()"
date-format="dd/mm/yy"
class="w-full"
size="small"
/>
<p v-if="erros.dataVencimento" class="text-xs text-red-500 mt-1">{{ erros.dataVencimento }}</p>
</div>
<div class="sm:col-span-3 grid grid-cols-2 sm:grid-cols-4 gap-3 items-end">
<div>
<p class="text-xs text-slate-500">Principal</p>
<p class="font-bold">{{ formatCurrency(totalizadores.principal) }}</p>
</div>
<div>
<p class="text-xs text-slate-500">Multa</p>
<p class="font-bold">{{ formatCurrency(totalizadores.multa) }}</p>
</div>
<div>
<p class="text-xs text-slate-500">Juros</p>
<p class="font-bold">{{ formatCurrency(totalizadores.juros) }}</p>
</div>
<div>
<p class="text-xs text-slate-500">Total a Pagar</p>
<p class="font-bold text-primary">{{ formatCurrency(totalizadores.valorTotal) }}</p>
</div>
</div>
</div>
<div class="flex flex-wrap gap-2 justify-between">
<div class="flex flex-wrap gap-2">
<Button
label="Imprimir Extrato (todos)"
icon="pi pi-print"
size="small"
severity="secondary"
:loading="isLoadingExtrato"
@click="gerarExtratoPdf(false)"
/>
<Button
v-if="temSelecionado"
label="Imprimir Selecionados"
icon="pi pi-print"
size="small"
severity="secondary"
outlined
:loading="isLoadingExtrato"
@click="gerarExtratoPdf(true)"
/>
</div> </div>
<Button <Button
icon="pi pi-download" v-if="temSelecionado"
label="Emitir guia" label="Gerar Guia de Pagamento"
icon="pi pi-file-pdf"
size="small" size="small"
outlined :loading="isLoadingGuia"
:loading="carregandoGuia === debito.id" @click="gerarGuia"
:disabled="!!carregandoGuia"
@click="emitirGuia(debito)"
/> />
</div> </div>
</div> </div>
</template>
<!-- DESKTOP --> <ModalTransacoesContaCorrente ref="modalTransacoes" />
<div class="hidden sm:flex items-center gap-4 px-5 py-4">
<Checkbox v-model="debito._selecionado" :binary="true" />
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-slate-800 dark:text-slate-100 truncate">{{ tituloDebito(debito) }}</p>
<p class="text-xs text-slate-400 dark:text-slate-500 mt-0.5"> {{ debito.descricao }}</p>
</div>
<p class="text-sm text-slate-500 dark:text-slate-400 w-28 text-right whitespace-nowrap">
{{ formatarData(debito.vencimento) }}
</p>
<div class="w-28 text-right">
<p class="text-sm font-bold text-slate-800 dark:text-slate-100">{{ formatarMoeda(debito.valorAtualizado ?? debito.valor) }}</p>
<p v-if="debito.valorAtualizado > debito.valor" class="text-xs text-slate-400 line-through">{{ formatarMoeda(debito.valor) }}</p>
</div>
<div class="w-20 flex justify-center">
<span
:class="['text-xs font-semibold px-2 py-1 rounded-full whitespace-nowrap', statusClasse[debito.status] ?? 'bg-slate-100 dark:bg-slate-700 text-slate-600 dark:text-slate-300']"
>
{{ statusLabel[debito.status] ?? debito.status }}
</span>
</div>
<div class="w-28 flex justify-end">
<Button
label="Emitir guia"
icon="pi pi-download"
size="small"
outlined
class="whitespace-nowrap"
:loading="carregandoGuia === debito.id"
:disabled="!!carregandoGuia"
@click="emitirGuia(debito)"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</div> </div>
</template> </template>

170
src/pages/portal/guias.vue Normal file
View File

@ -0,0 +1,170 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useGuiasEmitidasPortal } from '@/composables/useGuiasEmitidasPortal'
import { formatCurrency, formatDate } from '@/utils/formatador'
import ModalDetalhesGuia from '@/components/extrato/ModalDetalhesGuia.vue'
definePageMeta({
layout: 'portal',
middleware: 'auth',
})
const modalVisible = ref(false)
const guiaSelecionadaId = ref(null)
const guiaSelecionadaStatus = ref(null)
const {
guias,
isLoading,
mensagemErro,
pagina,
totalPaginas,
totalElementos,
filtro,
statusOptions,
consultar,
limparFiltros,
mudarPagina,
visualizarPdf,
formatarNumeroGuia,
getSeverityStatus,
} = useGuiasEmitidasPortal()
onMounted(() => consultar())
function detalhar(guia) {
guiaSelecionadaId.value = guia.id
guiaSelecionadaStatus.value = guia.statusCodigo
modalVisible.value = true
}
function paginaAnterior() {
if (pagina.value > 0) mudarPagina(pagina.value - 1)
}
function proximaPagina() {
if (pagina.value < totalPaginas.value - 1) mudarPagina(pagina.value + 1)
}
function aplicarFiltros() {
pagina.value = 0
consultar()
}
</script>
<template>
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100">Guias Emitidas</h1>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">Consulte e baixe as guias de pagamento emitidas em seu nome.</p>
</div>
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4 space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<label class="block text-xs font-semibold text-slate-500 uppercase mb-1.5"> Guia</label>
<InputText v-model="filtro.numeroGuia" class="w-full" size="small" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-500 uppercase mb-1.5">Status</label>
<Select
v-model="filtro.status"
:options="statusOptions"
option-label="label"
option-value="value"
placeholder="Todos"
show-clear
class="w-full"
size="small"
/>
</div>
<div>
<label class="block text-xs font-semibold text-slate-500 uppercase mb-1.5">Emissão (início)</label>
<DatePicker v-model="filtro.dataEmissaoInicio" show-icon class="w-full" size="small" date-format="dd/mm/yy" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-500 uppercase mb-1.5">Emissão (fim)</label>
<DatePicker v-model="filtro.dataEmissaoFim" show-icon class="w-full" size="small" date-format="dd/mm/yy" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-500 uppercase mb-1.5">Vencimento (início)</label>
<DatePicker v-model="filtro.dataVencimentoInicio" show-icon class="w-full" size="small" date-format="dd/mm/yy" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-500 uppercase mb-1.5">Vencimento (fim)</label>
<DatePicker v-model="filtro.dataVencimentoFim" show-icon class="w-full" size="small" date-format="dd/mm/yy" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-500 uppercase mb-1.5">Valor mínimo</label>
<InputNumber v-model="filtro.valorMinimo" mode="currency" currency="BRL" locale="pt-BR" class="w-full" size="small" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-500 uppercase mb-1.5">Valor máximo</label>
<InputNumber v-model="filtro.valorMaximo" mode="currency" currency="BRL" locale="pt-BR" class="w-full" size="small" />
</div>
</div>
<div class="flex gap-2">
<Button label="Consultar" icon="pi pi-search" size="small" :loading="isLoading" @click="aplicarFiltros" />
<Button label="Limpar" icon="pi pi-eraser" size="small" severity="secondary" outlined @click="limparFiltros" />
</div>
</div>
<Message v-if="mensagemErro" severity="warn" :closable="false">{{ mensagemErro }}</Message>
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
<div v-if="isLoading" class="p-8 flex justify-center">
<ProgressSpinner />
</div>
<div v-else-if="guias.length === 0" class="p-12 text-center">
<p class="font-semibold text-slate-700 dark:text-slate-200">Nenhuma guia encontrada</p>
</div>
<div v-else>
<div class="hidden sm:grid grid-cols-[1fr_1fr_100px_100px_120px_140px] gap-3 px-5 py-3 bg-slate-50 dark:bg-slate-700/50 border-b text-xs font-semibold text-slate-500 uppercase">
<span> Guia</span>
<span>Contribuinte</span>
<span>Emissão</span>
<span>Vencimento</span>
<span class="text-right">Valor</span>
<span class="text-right">Ações</span>
</div>
<div class="divide-y divide-slate-100 dark:divide-slate-700">
<div
v-for="guia in guias"
:key="guia.id"
class="px-5 py-4 flex flex-col sm:grid sm:grid-cols-[1fr_1fr_100px_100px_120px_140px] sm:items-center gap-2 sm:gap-3"
>
<p class="font-semibold text-sm">{{ formatarNumeroGuia(guia.numeroGuia) }}</p>
<div class="min-w-0">
<p class="text-sm truncate">{{ guia.nomeContribuinte }}</p>
<Tag :value="guia.statusDescricao" :severity="getSeverityStatus(guia.statusCodigo)" class="mt-1" />
</div>
<p class="text-sm text-slate-500">{{ formatDate(guia.dataEmissao) }}</p>
<p class="text-sm text-slate-500">{{ formatDate(guia.dataVencimento) }}</p>
<p class="text-sm font-bold text-right">{{ formatCurrency(guia.valorTotal) }}</p>
<div class="flex gap-1 justify-end">
<Button icon="pi pi-info-circle" text rounded size="small" @click="detalhar(guia)" />
<Button icon="pi pi-file-pdf" text rounded size="small" @click="visualizarPdf(guia.id)" />
</div>
</div>
</div>
<div v-if="totalPaginas > 1" class="flex items-center justify-between px-5 py-3 border-t text-sm">
<span class="text-slate-500">{{ totalElementos }} guia(s)</span>
<div class="flex gap-2">
<Button icon="pi pi-chevron-left" size="small" text :disabled="pagina === 0" @click="paginaAnterior" />
<span class="px-2 py-1">{{ pagina + 1 }} / {{ totalPaginas }}</span>
<Button icon="pi pi-chevron-right" size="small" text :disabled="pagina >= totalPaginas - 1" @click="proximaPagina" />
</div>
</div>
</div>
</div>
<ModalDetalhesGuia
v-model:visible="modalVisible"
:guia-id="guiaSelecionadaId"
:status-codigo="guiaSelecionadaStatus"
/>
</div>
</template>

View File

@ -1,197 +1,151 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { portalService } from '@/services/portalService' import { useExtratoPagamentosPortal } from '@/composables/useExtratoPagamentosPortal'
import { formatCurrency, formatDate, abrirPdf } from '@/utils/formatador'
definePageMeta({ definePageMeta({
layout: 'portal', layout: 'portal',
middleware: 'auth', middleware: 'auth',
}) })
// MOCKS APRESENTAÇÃO remover antes do deploy
const MOCK_ATIVO = true
const PAGAMENTOS_MOCK = [
{
id: 101,
idContaCorrente: 201,
idTaxa: 11,
descricao: 'IPTU 2024 — Parcela 1/3',
referencia: '202401',
dataPagamento: '2024-02-05',
formaPagamento: 'GUIA',
valor: 382.50,
},
{
id: 102,
idContaCorrente: 202,
idTaxa: 12,
descricao: 'Taxa de Licença de Funcionamento',
referencia: '202403',
dataPagamento: '2024-03-20',
formaPagamento: 'GUIA',
valor: 215.00,
},
{
id: 103,
idContaCorrente: null,
idTaxa: null,
descricao: 'ISSQN 2023 — 4º Trimestre',
referencia: '202312',
dataPagamento: '2024-01-10',
formaPagamento: 'DIRETO',
valor: 540.00,
},
]
//
const pagamentos = ref([])
const carregando = ref(true)
const carregandoComprovante = ref(null) const carregandoComprovante = ref(null)
const mensagemErro = ref('')
const filtroAno = ref(new Date().getFullYear())
const anosDisponiveis = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i) const {
tributos,
isLoading,
isLoadingPdf,
mensagemErro,
filtro,
totais,
consultar,
limparFiltros,
gerarPdf,
baixarComprovante,
} = useExtratoPagamentosPortal()
onMounted(() => carregar()) onMounted(() => consultar())
async function carregar() { async function emitirComprovante(pag) {
carregando.value = true
mensagemErro.value = ''
try {
const res = await portalService.getPagamentos({ ano: filtroAno.value })
pagamentos.value = res.data ?? []
if (MOCK_ATIVO && pagamentos.value.length === 0) {
pagamentos.value = PAGAMENTOS_MOCK
}
} catch (e) {
mensagemErro.value = e?.data?.description ?? 'Não foi possível carregar os pagamentos.'
if (MOCK_ATIVO) {
pagamentos.value = PAGAMENTOS_MOCK
mensagemErro.value = ''
}
} finally {
carregando.value = false
}
}
async function baixarComprovante(pag) {
carregandoComprovante.value = pag.id carregandoComprovante.value = pag.id
try { try {
const buf = await portalService.getComprovanteByTaxa(pag.idTaxa) const buf = await baixarComprovante(pag)
const blob = new Blob([buf], { type: 'application/pdf' }) if (buf?.byteLength) {
const url = URL.createObjectURL(blob) abrirPdf(buf)
const janela = window.open(url, '_blank') } else {
if (!janela) { mensagemErro.value = 'Comprovante não disponível para este pagamento.'
const a = document.createElement('a')
a.href = url
a.download = `comprovante-${pag.idTaxa}.pdf`
a.click()
} }
setTimeout(() => URL.revokeObjectURL(url), 60000)
} catch {
mensagemErro.value = 'Erro ao gerar o comprovante.'
} finally { } finally {
carregandoComprovante.value = null carregandoComprovante.value = null
} }
} }
function formatarMoeda(valor) {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(valor ?? 0)
}
const formaPagMap = {
BOLETO: { label: 'Boleto', icone: 'pi-barcode' },
PIX: { label: 'Pix', icone: 'pi-qrcode' },
CARTAO: { label: 'Cartão', icone: 'pi-credit-card' },
TRANSFERENCIA: { label: 'Transferência', icone: 'pi-arrow-right-arrow-left' },
GUIA: { label: 'Guia', icone: 'pi-file' },
DIRETO: { label: 'Direto', icone: 'pi-check' },
}
</script> </script>
<template> <template>
<div class="space-y-6"> <div class="space-y-6">
<div> <div>
<h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100">Histórico de Pagamentos</h1> <h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100">Extrato de Pagamentos</h1>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">Todos os seus pagamentos com comprovantes para download.</p> <p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">Histórico de pagamentos com detalhamento por tributo.</p>
</div> </div>
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4 space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase mb-1.5">Período Débitos (início)</label>
<DatePicker v-model="filtro.debitosInicio" view="month" date-format="mm/yy" show-icon class="w-full" size="small" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase mb-1.5">Período Débitos (fim)</label>
<DatePicker v-model="filtro.debitosFim" view="month" date-format="mm/yy" show-icon class="w-full" size="small" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase mb-1.5">Pagamento (início)</label>
<DatePicker v-model="filtro.pagInicio" show-icon class="w-full" size="small" date-format="dd/mm/yy" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase mb-1.5">Pagamento (fim)</label>
<DatePicker v-model="filtro.pagFim" show-icon class="w-full" size="small" date-format="dd/mm/yy" />
</div>
</div>
<div class="flex gap-2 flex-wrap"> <div class="flex gap-2 flex-wrap">
<button <Button label="Consultar" icon="pi pi-search" size="small" :loading="isLoading" @click="consultar" />
v-for="ano in anosDisponiveis" <Button label="Limpar" icon="pi pi-eraser" size="small" severity="secondary" outlined @click="limparFiltros" />
:key="ano"
:class="['px-4 py-2 rounded-lg text-sm font-semibold border transition-colors', filtroAno === ano ? 'bg-primary text-white border-primary' : 'bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-700 hover:border-slate-300']"
@click="filtroAno = ano; carregar()"
>
{{ ano }}
</button>
</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 5" :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-4 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-20" />
<div class="h-8 bg-slate-200 dark:bg-slate-700 rounded-lg animate-pulse w-28" />
</div> </div>
</div> </div>
<div v-else-if="mensagemErro" class="p-8 text-center"> <Message v-if="mensagemErro" severity="warn" :closable="false">{{ mensagemErro }}</Message>
<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 v-if="isLoading" class="space-y-3">
<div v-for="i in 3" :key="i" class="h-20 bg-slate-200 dark:bg-slate-700 rounded-xl animate-pulse" />
</div> </div>
<div v-else-if="pagamentos.length === 0" class="p-12 text-center"> <div v-else-if="tributos.length === 0 && !mensagemErro" class="bg-white dark:bg-slate-800 rounded-xl border p-12 text-center">
<i class="pi pi-credit-card 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">Nenhum pagamento encontrado</p>
<p class="font-semibold text-slate-700 dark:text-slate-200">Nenhum pagamento em {{ filtroAno }}</p>
<p class="text-sm text-slate-400 dark:text-slate-500 mt-1">Pagamentos realizados aparecerão aqui.</p>
</div> </div>
<div v-else> <template v-else-if="tributos.length">
<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"> <Accordion :multiple="true" :active-index="[0]">
<span class="flex-1">Descrição</span> <AccordionPanel v-for="(t, index) in tributos" :key="t.idContaTributo ?? index" :value="String(index)">
<span class="hidden sm:block w-28 text-right">Data</span> <AccordionHeader>
<span class="hidden sm:block w-24 text-center">Forma</span> <span class="text-sm font-semibold truncate">
<span class="w-28 text-right">Valor</span> {{ t.descricaoTributo }} {{ t.descricaoContaTributo }}
<span class="w-28" /> ({{ t.pagamentos?.length ?? 0 }} pagamentos)
</div>
<div class="divide-y divide-slate-100 dark:divide-slate-700">
<div
v-for="pag in pagamentos"
:key="pag.id"
class="flex items-center gap-4 px-5 py-4 hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors"
>
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-slate-800 dark:text-slate-100 truncate">{{ pag.descricao }}</p>
<p class="text-xs text-slate-400 dark:text-slate-500 mt-0.5">Ref: {{ pag.referencia }}</p>
</div>
<p class="hidden sm:block text-sm text-slate-500 dark:text-slate-400 w-28 text-right whitespace-nowrap">{{ pag.dataPagamento }}</p>
<div class="hidden sm:flex w-24 justify-center">
<span class="inline-flex items-center gap-1.5 text-xs text-slate-500 dark:text-slate-400 bg-slate-100 dark:bg-slate-700 px-2 py-1 rounded-full">
<i :class="['pi', formaPagMap[pag.formaPagamento]?.icone ?? 'pi-circle', 'text-xs']" aria-hidden="true" />
{{ formaPagMap[pag.formaPagamento]?.label ?? pag.formaPagamento }}
</span> </span>
</div> </AccordionHeader>
<p class="text-sm font-bold text-emerald-600 dark:text-emerald-400 w-28 text-right whitespace-nowrap"> <AccordionContent>
{{ formatarMoeda(pag.valor) }} <DataTable :value="t.pagamentos" size="small" show-gridlines scrollable class="mt-2">
</p> <Column field="ndoc" header="Nº Doc" />
<div class="w-28 flex justify-end"> <Column field="refer" header="Período" />
<Column field="vencimento" header="Vencimento">
<template #body="{ data }">{{ formatDate(data.vencimento) }}</template>
</Column>
<Column field="pagamento" header="Pagamento">
<template #body="{ data }">{{ formatDate(data.pagamento) }}</template>
</Column>
<Column field="lancado" header="Principal" body-class="text-right">
<template #body="{ data }">{{ formatCurrency(data.lancado) }}</template>
</Column>
<Column field="multa" header="Multa" body-class="text-right">
<template #body="{ data }">{{ formatCurrency(data.multa) }}</template>
</Column>
<Column field="juros" header="Juros" body-class="text-right">
<template #body="{ data }">{{ formatCurrency(data.juros) }}</template>
</Column>
<Column field="desconto" header="Desconto" body-class="text-right">
<template #body="{ data }">{{ formatCurrency(data.desconto) }}</template>
</Column>
<Column field="total" header="Total" body-class="text-right">
<template #body="{ data }">
<strong class="text-emerald-600">{{ formatCurrency(data.total) }}</strong>
</template>
</Column>
<Column header="" style="width: 7rem">
<template #body="{ data }">
<Button <Button
icon="pi pi-print" icon="pi pi-print"
label="Comprovante" label="Comprovante"
size="small"
text text
:loading="carregandoComprovante === pag.id" size="small"
:disabled="!!carregandoComprovante || !pag.idTaxa" :loading="carregandoComprovante === data.id"
@click="baixarComprovante(pag)" :disabled="!data.idContaCorrente"
@click="emitirComprovante(data)"
/> />
</template>
</Column>
</DataTable>
</AccordionContent>
</AccordionPanel>
</Accordion>
<div class="bg-white dark:bg-slate-800 rounded-xl border p-4 flex flex-wrap items-end justify-between gap-4">
<div class="grid grid-cols-2 sm:grid-cols-5 gap-4 text-sm">
<div><p class="text-slate-500 text-xs">Principal</p><p class="font-bold">{{ formatCurrency(totais.principal) }}</p></div>
<div><p class="text-slate-500 text-xs">Multa</p><p class="font-bold">{{ formatCurrency(totais.multa) }}</p></div>
<div><p class="text-slate-500 text-xs">Juros</p><p class="font-bold">{{ formatCurrency(totais.juros) }}</p></div>
<div><p class="text-slate-500 text-xs">Desconto</p><p class="font-bold">{{ formatCurrency(totais.desconto) }}</p></div>
<div><p class="text-slate-500 text-xs">Total</p><p class="font-bold text-emerald-600">{{ formatCurrency(totais.total) }}</p></div>
</div> </div>
<Button label="Gerar PDF" icon="pi pi-file-pdf" size="small" :loading="isLoadingPdf" @click="gerarPdf" />
</div> </div>
</div> </template>
</div>
</div>
</div> </div>
</template> </template>

View File

@ -60,7 +60,8 @@ async function carregar() {
} }
const acesRapidos = [ const acesRapidos = [
{ icon: 'pi-receipt', label: 'Emitir Guia', to: '/portal/debitos', cor: 'text-primary' }, { icon: 'pi-receipt', label: 'Extrato Débitos', to: '/portal/debitos', cor: 'text-primary' },
{ icon: 'pi-file', label: 'Guias Emitidas', to: '/portal/guias', 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-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-credit-card', label: 'Pagamentos', to: '/portal/pagamentos', cor: 'text-amber-600 dark:text-amber-400' }, { icon: 'pi-credit-card', label: 'Pagamentos', to: '/portal/pagamentos', cor: 'text-amber-600 dark:text-amber-400' },

View File

@ -22,8 +22,8 @@ const filtroProtocolo = ref('')
const statusOpcoes = [ const statusOpcoes = [
{ value: 1, label: 'Emitida', classe: 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400' }, { 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: 2, label: 'Paga', classe: 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400' },
{ value: 3, label: 'Paga', classe: 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400' }, { value: 3, label: 'Cancelada', classe: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400' },
] ]
const statusMap = Object.fromEntries(statusOpcoes.map(s => [s.value, s])) const statusMap = Object.fromEntries(statusOpcoes.map(s => [s.value, s]))
@ -199,7 +199,7 @@ function formatarData(val) {
@click="baixar(taxa, 'guia')" @click="baixar(taxa, 'guia')"
/> />
<Button <Button
v-if="taxa.status === 3" v-if="taxa.status === 2"
icon="pi pi-check" icon="pi pi-check"
label="Compr." label="Compr."
size="small" size="small"
@ -209,7 +209,7 @@ function formatarData(val) {
@click="baixar(taxa, 'comprovante')" @click="baixar(taxa, 'comprovante')"
/> />
<Button <Button
v-if="taxa.status === 3 && taxa.possuiDocComprobatorio" v-if="taxa.status === 2 && taxa.possuiDocComprobatorio"
icon="pi pi-verified" icon="pi pi-verified"
label="Autor." label="Autor."
size="small" size="small"

View File

@ -1,5 +1,6 @@
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed, watch } from 'vue'
import { z } from 'zod'
import { useAuth } from '@/composables/useAuth' import { useAuth } from '@/composables/useAuth'
import { certidaoService } from '@/services/certidaoService' import { certidaoService } from '@/services/certidaoService'
@ -15,26 +16,109 @@ const labelVoltar = computed(() =>
) )
const documento = ref(isAuthenticated.value ? docUsuarioLogado.value : '') const documento = ref(isAuthenticated.value ? docUsuarioLogado.value : '')
const tipoCertidao = ref('negativa') const idModeloSelecionado = ref(null)
const finalidade = ref('')
const modelos = ref([])
const etapa = ref('formulario') const etapa = ref('formulario')
const carregandoModelos = ref(false)
const carregandoConsulta = ref(false) const carregandoConsulta = ref(false)
const carregandoEmissao = ref(false) const carregandoEmissao = ref(false)
const resultado = ref(null) const resultado = ref(null)
const mensagemErro = ref('') const mensagemErro = ref('')
const erros = ref({})
const tiposCertidao = [ const schemaEmissao = z.object({
{ value: 'negativa', label: 'Certidão Negativa', descricao: 'Confirma que não há débitos pendentes.' }, idModeloSelecionado: z
{ value: 'positiva_efeitos_negativa', label: 'Positiva com Efeitos de Negativa', descricao: 'Débitos com parcelamento em dia ou com exigibilidade suspensa.' }, .number({ required_error: 'Selecione o modelo de certidão.' })
{ value: 'positiva', label: 'Certidão Positiva', descricao: 'Confirma a existência de débitos.' }, .nullable()
] .refine(v => v !== null, 'Selecione o modelo de certidão.'),
finalidade: z.string().trim().min(1, 'Finalidade é obrigatória.'),
})
const docValido = computed(() => { const docValido = computed(() => {
const d = documento.value.replace(/\D/g, '') const d = documento.value.replace(/\D/g, '')
return d.length === 11 || d.length === 14 return d.length === 11 || d.length === 14
}) })
const modeloSelecionado = computed(() =>
modelos.value.find(m => m.id === idModeloSelecionado.value) ?? null
)
const validadeLabel = computed(() => {
const dias = modeloSelecionado.value?.validadeDias
if (!dias) return 'conforme modelo selecionado'
return dias === 1 ? '1 dia a partir da emissão' : `${dias} dias a partir da emissão`
})
let debounceTimer = null
function resetModelos() {
modelos.value = []
idModeloSelecionado.value = null
}
function extrairErro(e) {
return e?.data?.description ?? e?.data?.message ?? e?.statusMessage ?? null
}
async function carregarModelos() {
if (!docValido.value) {
resetModelos()
return
}
carregandoModelos.value = true
mensagemErro.value = ''
try {
const res = await certidaoService.listarModelos(documento.value, { size: 50 })
modelos.value = res.data?.data ?? []
if (modelos.value.length === 0) {
mensagemErro.value = 'Não há certidões disponíveis para este tipo de contribuinte.'
}
if (!modelos.value.some(m => m.id === idModeloSelecionado.value)) {
idModeloSelecionado.value = null
}
} catch (e) {
resetModelos()
mensagemErro.value = extrairErro(e) ?? 'Não foi possível carregar os modelos de certidão.'
} finally {
carregandoModelos.value = false
}
}
watch(documento, () => {
if (isAuthenticated.value) return
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
resetModelos()
if (docValido.value) carregarModelos()
}, 400)
})
if (docValido.value) {
carregarModelos()
}
function validarFormulario() {
const parsed = schemaEmissao.safeParse({
idModeloSelecionado: idModeloSelecionado.value,
finalidade: finalidade.value,
})
if (!parsed.success) {
const mapa = {}
for (const issue of parsed.error.issues) {
const campo = issue.path[0]
if (campo && !mapa[campo]) mapa[campo] = issue.message
}
erros.value = mapa
return false
}
erros.value = {}
return true
}
async function consultar() { async function consultar() {
if (!docValido.value) return if (!docValido.value) return
if (!validarFormulario()) return
carregandoConsulta.value = true carregandoConsulta.value = true
mensagemErro.value = '' mensagemErro.value = ''
try { try {
@ -42,24 +126,31 @@ async function consultar() {
resultado.value = res.data resultado.value = res.data
etapa.value = 'resultado' etapa.value = 'resultado'
} catch (e) { } catch (e) {
mensagemErro.value = e?.data?.description ?? 'Não foi possível consultar a situação fiscal. Tente novamente.' mensagemErro.value = extrairErro(e) ?? 'Não foi possível consultar a situação fiscal. Tente novamente.'
} finally { } finally {
carregandoConsulta.value = false carregandoConsulta.value = false
} }
} }
async function emitir() { async function emitir() {
if (!validarFormulario()) return
carregandoEmissao.value = true carregandoEmissao.value = true
mensagemErro.value = ''
try { try {
const buf = await certidaoService.emitir(documento.value, tipoCertidao.value) const buf = await certidaoService.emitir(
documento.value,
idModeloSelecionado.value,
finalidade.value.trim(),
)
const slug = (modeloSelecionado.value?.titulo ?? 'certidao').replace(/\s+/g, '-').toLowerCase()
const url = URL.createObjectURL(new Blob([buf], { type: 'application/pdf' })) const url = URL.createObjectURL(new Blob([buf], { type: 'application/pdf' }))
const a = document.createElement('a') const a = document.createElement('a')
a.href = url a.href = url
a.download = `certidao-${tipoCertidao.value}-${documento.value}.pdf` a.download = `certidao-${slug}-${documento.value.replace(/\D/g, '')}.pdf`
a.click() a.click()
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
} catch { } catch (e) {
mensagemErro.value = 'Erro ao gerar o PDF. Tente novamente.' mensagemErro.value = extrairErro(e) ?? 'Erro ao gerar o PDF. Tente novamente.'
} finally { } finally {
carregandoEmissao.value = false carregandoEmissao.value = false
} }
@ -67,9 +158,14 @@ async function emitir() {
function reiniciar() { function reiniciar() {
documento.value = isAuthenticated.value ? docUsuarioLogado.value : '' documento.value = isAuthenticated.value ? docUsuarioLogado.value : ''
idModeloSelecionado.value = null
finalidade.value = ''
resultado.value = null resultado.value = null
mensagemErro.value = '' mensagemErro.value = ''
erros.value = {}
etapa.value = 'formulario' etapa.value = 'formulario'
resetModelos()
if (docValido.value) carregarModelos()
} }
</script> </script>
@ -111,24 +207,42 @@ function reiniciar() {
</p> </p>
</div> </div>
<div> <div v-if="docValido">
<p class="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-3">Tipo de certidão</p> <label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">
<div class="space-y-2"> Modelo de certidão
<label
v-for="tipo in tiposCertidao"
:key="tipo.value"
class="flex items-start gap-3 p-4 rounded-xl border cursor-pointer transition-colors"
:class="tipoCertidao === tipo.value
? 'border-primary bg-primary/5 dark:bg-primary/10'
: 'border-slate-200 dark:border-slate-600 hover:border-slate-300 dark:hover:border-slate-500'"
>
<RadioButton v-model="tipoCertidao" :value="tipo.value" :input-id="tipo.value" class="mt-0.5 flex-shrink-0" />
<div>
<p class="text-sm font-semibold text-slate-800 dark:text-slate-100">{{ tipo.label }}</p>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{{ tipo.descricao }}</p>
</div>
</label> </label>
<Select
v-model="idModeloSelecionado"
:options="modelos"
option-label="titulo"
option-value="id"
placeholder="Selecione o modelo de certidão"
class="w-full"
:class="{ 'p-invalid': erros.idModeloSelecionado }"
:loading="carregandoModelos"
:disabled="carregandoModelos || modelos.length === 0"
/>
<p v-if="erros.idModeloSelecionado" class="mt-1.5 text-xs text-red-600 dark:text-red-400">
{{ erros.idModeloSelecionado }}
</p>
<p v-else-if="modeloSelecionado?.descricaoTipoCertidao" class="mt-1.5 text-xs text-slate-500 dark:text-slate-400">
{{ modeloSelecionado.descricaoTipoCertidao }}
</p>
</div> </div>
<div v-if="docValido">
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">
Finalidade da certidão
</label>
<InputText
v-model="finalidade"
class="w-full"
:class="{ 'p-invalid': erros.finalidade }"
placeholder="Informe a finalidade (ex.: licitação, financiamento)"
/>
<p v-if="erros.finalidade" class="mt-1.5 text-xs text-red-600 dark:text-red-400">
{{ erros.finalidade }}
</p>
</div> </div>
<p v-if="mensagemErro" role="alert" class="text-sm text-red-600 dark:text-red-400 flex items-center gap-1.5"> <p v-if="mensagemErro" role="alert" class="text-sm text-red-600 dark:text-red-400 flex items-center gap-1.5">
@ -142,7 +256,7 @@ function reiniciar() {
class="w-full" class="w-full"
size="large" size="large"
:loading="carregandoConsulta" :loading="carregandoConsulta"
:disabled="!docValido" :disabled="!docValido || carregandoModelos || modelos.length === 0"
@click="consultar" @click="consultar"
/> />
</div> </div>
@ -183,10 +297,13 @@ function reiniciar() {
<div class="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 p-6"> <div class="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 p-6">
<p class="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1">Certidão a emitir</p> <p class="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1">Certidão a emitir</p>
<p class="font-semibold text-slate-800 dark:text-slate-100"> <p class="font-semibold text-slate-800 dark:text-slate-100">
{{ tiposCertidao.find(t => t.value === tipoCertidao)?.label }} {{ modeloSelecionado?.titulo ?? '—' }}
</p>
<p v-if="finalidade" class="text-sm text-slate-600 dark:text-slate-300 mt-2">
Finalidade: {{ finalidade }}
</p> </p>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-4"> <p class="text-xs text-slate-500 dark:text-slate-400 mt-4">
Validade: <span class="font-medium">180 dias a partir da emissão</span> Validade: <span class="font-medium">{{ validadeLabel }}</span>
</p> </p>
</div> </div>

View File

@ -12,10 +12,17 @@ export const certidaoService = {
}) })
}, },
emitir(documento, tipoCertidao) { listarModelos(documento, params = {}) {
return $fetch(proxyUrl('/publico/certidao/modelos'), {
headers: FETCH_HEADERS,
query: { documento, ...params },
})
},
emitir(documento, idModelo, finalidade) {
return $fetch(proxyUrl('/publico/certidao/emitir'), { return $fetch(proxyUrl('/publico/certidao/emitir'), {
headers: FETCH_HEADERS, headers: FETCH_HEADERS,
query: { documento, tipoCertidao }, query: { documento, idModelo, finalidade },
responseType: 'arrayBuffer', responseType: 'arrayBuffer',
}) })
}, },

View File

@ -1,6 +1,4 @@
// Todas as chamadas vão via /api/proxy/** (BFF injeta Bearer + tenant headers). // Todas as chamadas vão via /api/proxy/** (BFF injeta Bearer + tenant headers).
// Cada método retorna o envelope cru do core-api: { data, message, statusCode, ... }
// — exceto rotas binárias (PDF), que retornam ArrayBuffer.
const FETCH_HEADERS = { 'X-Requested-With': 'fetch' } const FETCH_HEADERS = { 'X-Requested-With': 'fetch' }
@ -8,8 +6,15 @@ function proxyUrl(path) {
return `/api/proxy${path}` return `/api/proxy${path}`
} }
function limparParams(params) {
const out = {}
for (const [k, v] of Object.entries(params)) {
if (v !== null && v !== undefined && v !== '') out[k] = v
}
return out
}
export const portalService = { export const portalService = {
// ─── Painel ──────────────────────────────────────────────────────────────
getPainelResumo() { getPainelResumo() {
return $fetch(proxyUrl('/contribuinte/painel/resumo'), { headers: FETCH_HEADERS }) return $fetch(proxyUrl('/contribuinte/painel/resumo'), { headers: FETCH_HEADERS })
}, },
@ -21,17 +26,45 @@ export const portalService = {
}) })
}, },
// ─── Débitos ───────────────────────────────────────────────────────────── // ─── Débitos / Extrato ───────────────────────────────────────────────────
getDebitos(params = {}) { getDebitosExtrato(params = {}) {
return $fetch(proxyUrl('/contribuinte/debitos'), { return $fetch(proxyUrl('/contribuinte/debitos'), {
headers: FETCH_HEADERS, headers: FETCH_HEADERS,
query: params, query: limparParams(params),
}) })
}, },
emitirGuia(idDebito) { getTransacoes(idContaCorrente) {
return $fetch(proxyUrl(`/contribuinte/debitos/${idDebito}/guia`), { return $fetch(proxyUrl(`/contribuinte/debitos/${idContaCorrente}/transacoes`), { headers: FETCH_HEADERS })
},
gerarGuiaDebitos(dto) {
return $fetch(proxyUrl('/contribuinte/debitos/gerar-guia'), {
method: 'POST',
headers: FETCH_HEADERS, headers: FETCH_HEADERS,
body: dto,
})
},
baixarGuiaPdf(idDoctoArr) {
return $fetch(proxyUrl(`/contribuinte/debitos/guia/${idDoctoArr}`), {
headers: FETCH_HEADERS,
responseType: 'arrayBuffer',
})
},
emitirGuia(idContaCorrente) {
return $fetch(proxyUrl(`/contribuinte/debitos/${idContaCorrente}/guia`), {
headers: FETCH_HEADERS,
responseType: 'arrayBuffer',
})
},
gerarExtratoDebitosPdf(dto) {
return $fetch(proxyUrl('/contribuinte/debitos/extrato-pdf'), {
method: 'POST',
headers: FETCH_HEADERS,
body: dto,
responseType: 'arrayBuffer', responseType: 'arrayBuffer',
}) })
}, },
@ -48,23 +81,44 @@ export const portalService = {
}) })
}, },
// ─── Pagamentos ────────────────────────────────────────────────────────── // ─── Pagamentos / Extrato ─────────────────────────────────────────────────
getPagamentos(params = {}) { getPagamentosExtrato(params = {}) {
return $fetch(proxyUrl('/contribuinte/pagamentos'), { return $fetch(proxyUrl('/contribuinte/pagamentos'), {
headers: FETCH_HEADERS, headers: FETCH_HEADERS,
query: params, query: limparParams(params),
}) })
}, },
getComprovante(idPagamento) { gerarExtratoPagamentosPdf(dto) {
return $fetch(proxyUrl(`/contribuinte/pagamentos/${idPagamento}/comprovante`), { return $fetch(proxyUrl('/contribuinte/pagamentos/extrato-pdf'), {
method: 'POST',
headers: FETCH_HEADERS,
body: dto,
responseType: 'arrayBuffer',
})
},
getComprovante(idContaCorrente) {
return $fetch(proxyUrl(`/contribuinte/pagamentos/${idContaCorrente}/comprovante`), {
headers: FETCH_HEADERS, headers: FETCH_HEADERS,
responseType: 'arrayBuffer', responseType: 'arrayBuffer',
}) })
}, },
getComprovanteByTaxa(idTaxa) { // ─── Guias emitidas ──────────────────────────────────────────────────────
return $fetch(proxyUrl(`/arrecadacao/taxas/${idTaxa}/autorizacao`), { listarGuias(params = {}) {
return $fetch(proxyUrl('/contribuinte/guias'), {
headers: FETCH_HEADERS,
query: limparParams(params),
})
},
buscarGuia(id) {
return $fetch(proxyUrl(`/contribuinte/guias/${id}`), { headers: FETCH_HEADERS })
},
baixarGuiaEmitidaPdf(id) {
return $fetch(proxyUrl(`/contribuinte/guias/${id}/pdf`), {
headers: FETCH_HEADERS, headers: FETCH_HEADERS,
responseType: 'arrayBuffer', responseType: 'arrayBuffer',
}) })

49
src/utils/formatador.js Normal file
View File

@ -0,0 +1,49 @@
import { formatarMoeda, formatarDataParaAPI, baixarPdf } from '@/utils/formatacao'
export function formatDate(value) {
if (!value) return '—'
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
const [y, m, d] = value.split('-')
return `${d}/${m}/${y}`
}
try {
const date = new Date(value)
if (isNaN(date.getTime())) return '—'
return `${String(date.getDate()).padStart(2, '0')}/${String(date.getMonth() + 1).padStart(2, '0')}/${date.getFullYear()}`
} catch {
return '—'
}
}
export function formatCurrency(value) {
if (value == null) return 'R$ 0,00'
return Number(value).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
}
export function formatDateISO(value) {
return formatarDataParaAPI(value)
}
export function dateToYYYYMM(date) {
if (!date) return null
const d = new Date(date)
return `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}`
}
export function parseYYYYMM(str) {
if (!str) return null
const s = String(str)
if (s.length !== 6 || !/^\d{6}$/.test(s)) return null
return new Date(parseInt(s.substring(0, 4)), parseInt(s.substring(4, 6)) - 1, 1)
}
export function abrirPdf(buf) {
const url = URL.createObjectURL(new Blob([buf], { type: 'application/pdf' }))
const janela = window.open(url, '_blank')
if (!janela) {
baixarPdf(buf, 'documento.pdf')
}
setTimeout(() => URL.revokeObjectURL(url), 60000)
}
export { formatarMoeda, baixarPdf }