Compare commits

..

1 Commits

Author SHA1 Message Date
4360a2d998 Merge pull request 'developer' (#3) from developer into main
All checks were successful
Prod Build & Deploy Portal / build (push) Successful in 2m21s
Prod Build & Deploy Portal / deploy (push) Successful in 32s
Reviewed-on: #3
2026-05-19 13:42:37 +00:00
45 changed files with 868 additions and 3690 deletions

View File

@ -86,39 +86,6 @@ 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.
@ -127,8 +94,7 @@ 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 |
| `idModelo` | long | sim | ID do modelo selecionado em `/modelos` | | `tipoCertidao` | string | sim | `NEGATIVA` · `POSITIVA` · `POSITIVA_EFEITOS_NEGATIVA` |
| `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)
@ -430,59 +396,45 @@ Lista das últimas atividades do contribuinte.
--- ---
### Débitos (Extrato) ### Débitos
#### `GET /contribuinte/debitos` #### `GET /contribuinte/debitos`
Lista débitos agrupados por conta/tributo (`ContaCorrenteTributoDTO`), com os mesmos filtros do extrato interno. Lista os débitos do contribuinte com filtros opcionais.
**Query params:** `idTaxa`, `idContaTributo`, `periodoIni` (YYYYMM), `periodoFim` (YYYYMM), `inscMunicipal`, `idEstadoConta` (1=débito, 2=zero, 3=crédito) **Query params:**
| Param | Tipo | Obrigatório | Descrição |
|---|---|---|---|
| `tipo` | string | não | `IPTU` · `ISS` · `TAXA` · `MULTA` |
| `status` | string | não | `VENCIDO` · `A_VENCER` · `PARCELADO` |
**Response `data`:** array de `ContaCorrenteTributoDTO` com lista `debitos` aninhada. **Response `data`:**
```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/tributos/{codigo}` #### `GET /contribuinte/debitos/{id}/guia`
Busca tributo por código para filtros. Emite a guia de pagamento (boleto/DAM) de um débito específico em PDF.
--- **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`
@ -562,58 +514,47 @@ Lista os processos de alvará do contribuinte.
--- ---
### Pagamentos (Extrato) ### Pagamentos
#### `GET /contribuinte/pagamentos` #### `GET /contribuinte/pagamentos`
Lista pagamentos agrupados por tributo (`ExtratoPagamentoTributoDTO`). Histórico de pagamentos do contribuinte, filtrável por ano.
**Query params:** `idTaxa`, `idContaTributo`, `pagInicio`, `pagFim`, `periodoIni`, `periodoFim`, `ano` (atalho para intervalo anual) **Query params:**
| Param | Tipo | Obrigatório | Descrição |
|---|---|---|---|
| `ano` | integer | não | Ano de referência (padrão: ano atual) |
**Response `data`:** array com `pagamentos` aninhados (principal, multa, juros, desconto, total). **Response `data`:**
```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`
--- ---
#### `POST /contribuinte/pagamentos/extrato-pdf` #### `GET /contribuinte/pagamentos/{id}/comprovante`
Gera PDF do extrato de pagamentos. Body: `GerarExtratoPagamentosRequestDTO`. Baixa o comprovante de um pagamento em PDF.
**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`
@ -732,9 +673,8 @@ 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` | ✓ | ✓ | — | | `GET /publico/certidao/consultar` | ✓ | pendente | ✓ |
| `GET /publico/certidao/modelos` | ✓ | ✓ | — | | `GET /publico/certidao/emitir` | ✓ | pendente | ✓ |
| `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,6 +1686,24 @@
"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",
@ -5569,6 +5587,17 @@
"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",
@ -7911,6 +7940,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -7931,6 +7961,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -7951,6 +7982,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -7971,6 +8003,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -7991,6 +8024,7 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -8011,6 +8045,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -8031,6 +8066,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -8051,6 +8087,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -8071,6 +8108,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -8091,6 +8129,7 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [
@ -8111,6 +8150,7 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MPL-2.0", "license": "MPL-2.0",
"optional": true, "optional": true,
"os": [ "os": [

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

View File

@ -3,8 +3,6 @@ import { z } from 'zod'
const bodySchema = z.object({ const bodySchema = z.object({
documento: z.string().trim().min(11).max(20).optional(), documento: z.string().trim().min(11).max(20).optional(),
returnTo: z.string().startsWith('/').max(200).optional(), returnTo: z.string().startsWith('/').max(200).optional(),
primary: z.string().regex(/^[0-9a-fA-F]{6}$/).optional(),
dark: z.boolean().optional(),
}) })
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
@ -16,16 +14,11 @@ export default defineEventHandler(async (event) => {
const { codeVerifier, codeChallenge, state } = await generatePkce() const { codeVerifier, codeChallenge, state } = await generatePkce()
const returnTo = body.data.returnTo ?? '/portal/painel' const returnTo = body.data.returnTo ?? '/portal/painel'
try {
await savePkceState(state, { await savePkceState(state, {
codeVerifier, codeVerifier,
returnTo, returnTo,
createdAt: Date.now(), createdAt: Date.now(),
}) })
} catch (err) {
console.error('[auth/login] falha ao salvar estado PKCE (Redis indisponível?):', (err as Error).message)
throw createError({ statusCode: 503, statusMessage: 'Serviço temporariamente indisponível. Tente novamente em instantes.' })
}
const redirectUri = callbackUrlFromEvent(event) const redirectUri = callbackUrlFromEvent(event)
const authUrl = buildAuthUrl({ const authUrl = buildAuthUrl({
@ -33,8 +26,6 @@ export default defineEventHandler(async (event) => {
state, state,
redirectUri, redirectUri,
loginHint: body.data.documento?.replace(/\D/g, ''), loginHint: body.data.documento?.replace(/\D/g, ''),
primary: body.data.primary,
dark: body.data.dark,
}) })
return { authUrl } return { authUrl }

View File

@ -46,8 +46,6 @@ export default defineEventHandler(async (event) => {
const contentType = getHeader(event, 'content-type') const contentType = getHeader(event, 'content-type')
if (contentType) headers['Content-Type'] = contentType if (contentType) headers['Content-Type'] = contentType
console.log(`[proxy] ${method} ${url} | X-Municipio: ${headers['X-Municipio']} | X-Dominio: ${headers['X-Dominio']} | auth: ${!!accessToken}`)
try { try {
const res = await $fetch.raw(url, { const res = await $fetch.raw(url, {
method: method as 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE', method: method as 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
@ -58,48 +56,17 @@ 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 (skipResponseHeaders.has(name.toLowerCase())) continue if (name === 'transfer-encoding') continue
setResponseHeader(event, name, value) setResponseHeader(event, name, value)
} }
return res._data return res._data
} catch (err: unknown) { } catch (err: unknown) {
const fetchErr = err as { response?: { status?: number; _data?: unknown } } const fetchErr = err as { response?: { status?: number; _data?: unknown } }
if (fetchErr.response) { if (fetchErr.response) {
const status = fetchErr.response.status ?? 500 setResponseStatus(event, fetchErr.response.status ?? 500)
const raw = fetchErr.response._data return fetchErr.response._data
// responseType: 'stream' faz _data ser ReadableStream mesmo em erros —
// lemos o stream e parseamos como JSON para que o cliente veja o envelope de erro
let body: unknown = raw
if (raw && typeof (raw as ReadableStream).getReader === 'function') {
const reader = (raw as ReadableStream<Uint8Array>).getReader()
const chunks: Uint8Array[] = []
for (;;) {
const { value, done } = await reader.read()
if (done) break
if (value) chunks.push(value)
} }
const totalLen = chunks.reduce((n, c) => n + c.length, 0) throw err
const merged = new Uint8Array(totalLen)
let offset = 0
for (const c of chunks) { merged.set(c, offset); offset += c.length }
const text = new TextDecoder().decode(merged)
try { body = JSON.parse(text) } catch { body = text }
}
if (import.meta.dev) {
console.error(`[proxy] ERRO ${status}${url}`, body)
}
setResponseStatus(event, status)
setResponseHeader(event, 'content-type', 'application/json; charset=utf-8')
return body
}
// Erro de rede (backend inacessível — ECONNREFUSED, timeout, etc.)
console.error(`[proxy] backend inacessível: ${url}`, (err as Error).message)
throw createError({ statusCode: 503, statusMessage: 'Sistema temporariamente indisponível.' })
} }
}) })

View File

@ -18,8 +18,6 @@ export function buildAuthUrl(opts: {
state: string state: string
redirectUri: string redirectUri: string
loginHint?: string loginHint?: string
primary?: string
dark?: boolean
}): string { }): string {
const cfg = useRuntimeConfig() const cfg = useRuntimeConfig()
const params = new URLSearchParams({ const params = new URLSearchParams({
@ -32,8 +30,6 @@ export function buildAuthUrl(opts: {
state: opts.state, state: opts.state,
}) })
if (opts.loginHint) params.set('login_hint', opts.loginHint) if (opts.loginHint) params.set('login_hint', opts.loginHint)
if (opts.primary) params.set('primary', opts.primary)
if (opts.dark !== undefined) params.set('dark', String(opts.dark))
return `${realmBase()}/protocol/openid-connect/auth?${params.toString()}` return `${realmBase()}/protocol/openid-connect/auth?${params.toString()}`
} }
@ -51,7 +47,6 @@ 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`,
{ {
@ -60,13 +55,6 @@ 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

@ -17,8 +17,6 @@ export async function fetchPrefeituraInfo(dominio: string): Promise<PrefeituraIn
if (!dominio) return null if (!dominio) return null
const cacheKey = `prefeitura:${dominio}` const cacheKey = `prefeitura:${dominio}`
let redisAvailable = true
try {
const cached = await useRedis().get(cacheKey) const cached = await useRedis().get(cacheKey)
if (cached) { if (cached) {
try { try {
@ -27,9 +25,6 @@ export async function fetchPrefeituraInfo(dominio: string): Promise<PrefeituraIn
// cache corrompido — segue para refetch // cache corrompido — segue para refetch
} }
} }
} catch {
redisAvailable = false
}
const cfg = useRuntimeConfig() const cfg = useRuntimeConfig()
try { try {
@ -40,9 +35,7 @@ export async function fetchPrefeituraInfo(dominio: string): Promise<PrefeituraIn
const info = res?.data const info = res?.data
if (!info?.codigoMunicipio) return null if (!info?.codigoMunicipio) return null
if (redisAvailable) { await useRedis().set(cacheKey, JSON.stringify(info), 'EX', CACHE_TTL_SECONDS)
await useRedis().set(cacheKey, JSON.stringify(info), 'EX', CACHE_TTL_SECONDS).catch(() => {})
}
return info return info
} catch (err) { } catch (err) {
console.error(`[prefeitura] lookup falhou para '${dominio}':`, (err as Error).message) console.error(`[prefeitura] lookup falhou para '${dominio}':`, (err as Error).message)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

View File

@ -86,7 +86,6 @@ function onHide() {
class="w-full" class="w-full"
size="large" size="large"
:loading="carregando" :loading="carregando"
:disabled="carregando"
@click="entrar" @click="entrar"
/> />

View File

@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, watch, onMounted, onUnmounted } from 'vue' import { ref, watch, onMounted } from 'vue'
const aberto = ref(false) const aberto = ref(false)
const nivelFonte = ref(0) const nivelFonte = ref(0)
@ -18,38 +18,16 @@ function applyFonte(nivel) {
if (nivel === 2) document.documentElement.classList.add('a11y-font-xl') if (nivel === 2) document.documentElement.classList.add('a11y-font-xl')
} }
let darkMediaQuery = null
let systemThemeChanging = false
function onSystemThemeChange(e) {
if (localStorage.getItem('a11y-escuro') === null) {
systemThemeChanging = true
modoEscuro.value = e.matches
systemThemeChanging = false
}
}
// Toda leitura de localStorage e DOM precisa estar dentro de onMounted // Toda leitura de localStorage e DOM precisa estar dentro de onMounted
// caso contrário o componente quebra no SSR. // caso contrário o componente quebra no SSR.
onMounted(() => { onMounted(() => {
nivelFonte.value = Number(localStorage.getItem('a11y-fonte') || 0) nivelFonte.value = Number(localStorage.getItem('a11y-fonte') || 0)
altoContraste.value = localStorage.getItem('a11y-contraste') === '1' altoContraste.value = localStorage.getItem('a11y-contraste') === '1'
modoEscuro.value = localStorage.getItem('a11y-escuro') === '1'
const storedDark = localStorage.getItem('a11y-escuro')
modoEscuro.value = storedDark !== null
? storedDark === '1'
: window.matchMedia('(prefers-color-scheme: dark)').matches
applyFonte(nivelFonte.value) applyFonte(nivelFonte.value)
document.documentElement.classList.toggle('a11y-contrast', altoContraste.value) document.documentElement.classList.toggle('a11y-contrast', altoContraste.value)
document.documentElement.classList.toggle('app-dark', modoEscuro.value) document.documentElement.classList.toggle('app-dark', modoEscuro.value)
darkMediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
darkMediaQuery.addEventListener('change', onSystemThemeChange)
})
onUnmounted(() => {
darkMediaQuery?.removeEventListener('change', onSystemThemeChange)
}) })
watch(nivelFonte, (val) => { watch(nivelFonte, (val) => {
@ -67,9 +45,7 @@ watch(altoContraste, (val) => {
watch(modoEscuro, (val) => { watch(modoEscuro, (val) => {
if (!import.meta.client) return if (!import.meta.client) return
document.documentElement.classList.toggle('app-dark', val) document.documentElement.classList.toggle('app-dark', val)
if (!systemThemeChanging) {
localStorage.setItem('a11y-escuro', val ? '1' : '0') localStorage.setItem('a11y-escuro', val ? '1' : '0')
}
}) })
</script> </script>

View File

@ -1,124 +0,0 @@
<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

@ -1,71 +0,0 @@
<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

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

View File

@ -18,7 +18,6 @@ export function useApi() {
} }
async function request<T>(path: string, options: FetchOptions = {}): Promise<T> { async function request<T>(path: string, options: FetchOptions = {}): Promise<T> {
try {
return await $fetch<T>(buildUrl(path), { return await $fetch<T>(buildUrl(path), {
...options, ...options,
headers: { headers: {
@ -26,13 +25,6 @@ export function useApi() {
...(options.headers ?? {}), ...(options.headers ?? {}),
}, },
}) })
} catch (err: unknown) {
if (import.meta.dev) {
const e = err as { status?: number; data?: unknown }
console.error(`[api] ${(options.method ?? 'GET').toUpperCase()} ${path}${e.status}`, e.data)
}
throw err
}
} }
return { return {

View File

@ -1,6 +1,5 @@
import { computed } from 'vue' import { computed } from 'vue'
import { useAuthStore } from '@/stores/authStore' import { useAuthStore } from '@/stores/authStore'
import { usePrefeituraStore } from '@/stores/prefeituraStore'
interface MeResponse { interface MeResponse {
name: string name: string
@ -11,33 +10,15 @@ interface MeResponse {
const FETCH_HEADERS = { 'X-Requested-With': 'fetch' } const FETCH_HEADERS = { 'X-Requested-With': 'fetch' }
const TEMPLATE_COLORS: Record<string, string> = {
tutoia: 'f97316',
amber: 'f59e0b',
blue: '3b82f6',
indigo: '6366f1',
violet: '8b5cf6',
emerald: '10b981',
teal: '14b8a6',
rose: 'f43f5e',
zinc: '71717a',
}
export function useAuth() { export function useAuth() {
const store = useAuthStore() const store = useAuthStore()
const router = useRouter() const router = useRouter()
async function login(documento?: string, returnTo?: string) { async function login(documento?: string, returnTo?: string) {
const template = usePrefeituraStore().template as string | null
const primary = template ? (TEMPLATE_COLORS[template] ?? '') : ''
const dark = import.meta.client
? (window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false)
: false
const res = await $fetch<{ authUrl: string }>('/api/auth/login', { const res = await $fetch<{ authUrl: string }>('/api/auth/login', {
method: 'POST', method: 'POST',
headers: FETCH_HEADERS, headers: FETCH_HEADERS,
body: { documento, returnTo, primary: primary || undefined, dark }, body: { documento, returnTo },
}) })
if (import.meta.client) { if (import.meta.client) {
window.location.href = res.authUrl window.location.href = res.authUrl

View File

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

View File

@ -1,227 +0,0 @@
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 ESTADO_DEBITO = 1
const temSelecionado = computed(() =>
resultados.value.some(t => t.selecionados?.length > 0)
)
const temDebitoSelecionado = computed(() =>
resultados.value.some(t =>
t.selecionados?.some(d => d.codigoEstadoConta === ESTADO_DEBITO)
)
)
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
.map(t => ({
...t,
selecionados: (t.selecionados || []).filter(
d => d.codigoEstadoConta === ESTADO_DEBITO
),
}))
.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 (!temDebitoSelecionado.value) {
mensagemErro.value = 'Selecione pelo menos um débito em aberto. Parcelas quitadas ou com saldo zerado não geram guia.'
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,
temDebitoSelecionado,
totalizadores,
consultar,
limparFiltros,
gerarGuia,
gerarExtratoPdf,
getEstadoSeverity,
}
}

View File

@ -1,197 +0,0 @@
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

@ -1,106 +0,0 @@
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

@ -1,58 +0,0 @@
<script setup>
const props = defineProps({
error: {
type: Object,
default: () => ({}),
},
})
const is404 = computed(() => props.error?.statusCode === 404)
function voltar() {
clearError({ redirect: '/' })
}
</script>
<template>
<div class="min-h-screen bg-slate-50 dark:bg-slate-950 flex flex-col items-center justify-center px-4">
<div class="w-full max-w-md text-center">
<div class="w-20 h-20 mx-auto mb-6 rounded-2xl flex items-center justify-center"
:class="is404 ? 'bg-slate-100 dark:bg-slate-800' : 'bg-amber-50 dark:bg-amber-900/20'">
<svg v-if="is404" xmlns="http://www.w3.org/2000/svg" class="w-10 h-10 text-slate-400 dark:text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="w-10 h-10 text-amber-500 dark:text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17 17.25 21A2.652 2.652 0 0 0 21 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 1 1-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 0 0 4.486-6.336l-3.276 3.277a3.004 3.004 0 0 1-2.25-2.25l3.276-3.276a4.5 4.5 0 0 0-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437 1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008Z" />
</svg>
</div>
<h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100 mb-3">
{{ is404 ? 'Página não encontrada' : 'Portal temporariamente indisponível' }}
</h1>
<p class="text-slate-500 dark:text-slate-400 text-sm leading-relaxed mb-8">
<template v-if="is404">
O endereço que você acessou não existe ou foi movido.
Verifique o link e tente novamente.
</template>
<template v-else>
Estamos realizando manutenção ou enfrentando uma instabilidade momentânea.
Aguarde alguns minutos e tente novamente. Se o problema persistir,
entre em contato com a prefeitura.
</template>
</p>
<button
class="inline-flex items-center gap-2 px-6 py-3 rounded-xl bg-primary text-white font-semibold text-sm hover:bg-primary/90 transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
@click="voltar"
>
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
</svg>
{{ is404 ? 'Ir para a página inicial' : 'Tentar novamente' }}
</button>
</div>
</div>
</template>

View File

@ -1,83 +1,44 @@
<script setup> <script setup>
import { ref } from 'vue'
import { useAuth } from '@/composables/useAuth' import { useAuth } from '@/composables/useAuth'
import { usePrefeituraStore } from '@/stores/prefeituraStore'
const { nomeUsuario, logout } = useAuth() const { nomeUsuario, logout } = useAuth()
const route = useRoute() const route = useRoute()
const router = useRouter()
const prefeitura = usePrefeituraStore()
const menuAberto = ref(false)
const navItems = [ const navItems = [
{ path: '/portal/painel', label: 'Painel', icon: 'pi-home' }, { path: '/portal/painel', label: 'Painel' },
{ path: '/portal/debitos', label: 'Débitos', icon: 'pi-receipt' }, { path: '/portal/debitos', label: 'Débitos' },
{ path: '/portal/guias', label: 'Guias Emitidas', icon: 'pi-file' }, { path: '/portal/certidoes', label: 'Certidões' },
{ path: '/portal/taxas', label: 'Taxas', icon: 'pi-file-export' }, { path: '/portal/alvaras', label: 'Alvarás' },
{ path: '/portal/certidoes', label: 'Certidões', icon: 'pi-file-check' }, { path: '/portal/pagamentos', label: 'Pagamentos' },
{ path: '/portal/pagamentos', label: 'Pagamentos', icon: 'pi-credit-card' }, { path: '/portal/dados', label: 'Dados Cadastrais' },
{ path: '/portal/dados', label: 'Dados Cadastrais', icon: 'pi-user' },
] ]
function navegar(path) {
menuAberto.value = false
router.push(path)
}
function sair() { function sair() {
menuAberto.value = false
logout() logout()
} }
</script> </script>
<template> <template>
<div class="min-h-screen flex flex-col bg-slate-50 dark:bg-slate-950"> <div class="min-h-screen flex flex-col bg-slate-50 dark:bg-slate-950">
<header class="bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700 sticky top-0 z-40" role="banner"> <header class="bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700 sticky top-0 z-40" role="banner">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center gap-3"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
<!-- Hambúrguer mobile only -->
<button
class="md:hidden flex items-center justify-center w-9 h-9 rounded-lg text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors flex-shrink-0"
aria-label="Abrir menu de navegação"
aria-expanded="menuAberto"
aria-controls="mobile-drawer"
@click="menuAberto = true"
>
<i class="pi pi-bars text-base" aria-hidden="true" />
</button>
<!-- Logo + título -->
<NuxtLink <NuxtLink
to="/portal/painel" to="/portal/painel"
class="flex items-center gap-2.5 min-w-0 flex-shrink-0" class="flex items-center gap-3"
aria-label="Ir para o painel principal" aria-label="Ir para o painel principal"
> >
<img <div class="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
v-if="prefeitura.pathLogo"
:src="prefeitura.pathLogo"
:alt="prefeitura.nomePrefeitura ?? 'Logo do município'"
class="h-8 w-auto object-contain flex-shrink-0"
/>
<div v-else class="w-8 h-8 bg-primary rounded-lg flex items-center justify-center flex-shrink-0">
<i class="pi pi-building text-white text-sm" aria-hidden="true" /> <i class="pi pi-building text-white text-sm" aria-hidden="true" />
</div> </div>
<div class="flex flex-col leading-tight min-w-0"> <span class="font-semibold text-slate-800 dark:text-slate-100">Portal do Contribuinte</span>
<span class="font-semibold text-slate-800 dark:text-slate-100 text-sm truncate">Portal do Contribuinte</span>
<span v-if="prefeitura.nomePrefeitura" class="text-xs text-slate-400 dark:text-slate-500 truncate hidden sm:block">
{{ prefeitura.nomePrefeitura }}
</span>
</div>
</NuxtLink> </NuxtLink>
<!-- Nav desktop --> <nav aria-label="Menu do contribuinte" class="hidden md:flex items-center gap-1">
<nav aria-label="Menu do contribuinte" class="hidden md:flex items-center gap-1 flex-1 justify-center">
<NuxtLink <NuxtLink
v-for="item in navItems" v-for="item in navItems"
:key="item.path" :key="item.path"
:to="item.path" :to="item.path"
class="px-3 py-2 rounded-lg text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 hover:text-slate-900 dark:hover:text-slate-100 transition-colors whitespace-nowrap" class="px-4 py-2 rounded-lg text-sm text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 hover:text-slate-900 dark:hover:text-slate-100 transition-colors"
active-class="bg-primary/10 text-primary font-semibold" active-class="bg-primary/10 text-primary font-semibold"
:aria-current="route.path === item.path ? 'page' : undefined" :aria-current="route.path === item.path ? 'page' : undefined"
> >
@ -85,9 +46,8 @@ function sair() {
</NuxtLink> </NuxtLink>
</nav> </nav>
<!-- Ações desktop --> <div class="flex items-center gap-3">
<div class="hidden md:flex items-center gap-3 flex-shrink-0 ml-auto"> <span class="hidden sm:block text-sm text-slate-600 dark:text-slate-300" aria-live="polite">
<span class="text-sm text-slate-600 dark:text-slate-300 truncate max-w-40" aria-live="polite">
{{ nomeUsuario }} {{ nomeUsuario }}
</span> </span>
<Button <Button
@ -100,90 +60,10 @@ function sair() {
@click="sair" @click="sair"
/> />
</div> </div>
<!-- Ícone de usuário mobile only, fica à direita -->
<button
class="md:hidden ml-auto flex items-center justify-center w-9 h-9 rounded-full bg-primary/10 dark:bg-primary/20 text-primary flex-shrink-0"
aria-label="Opções do usuário"
@click="menuAberto = true"
>
<i class="pi pi-user text-sm" aria-hidden="true" />
</button>
</div> </div>
</header> </header>
<!-- Drawer mobile --> <main id="main-content" tabindex="-1" class="flex-1 max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 py-8 outline-none">
<Drawer
id="mobile-drawer"
v-model:visible="menuAberto"
position="left"
:style="{ width: '17rem' }"
>
<template #header>
<div class="flex items-center gap-2.5">
<img
v-if="prefeitura.pathLogo"
:src="prefeitura.pathLogo"
:alt="prefeitura.nomePrefeitura ?? 'Logo do município'"
class="h-7 w-auto object-contain"
/>
<div v-else class="w-7 h-7 bg-primary rounded-lg flex items-center justify-center">
<i class="pi pi-building text-white text-xs" aria-hidden="true" />
</div>
<div class="flex flex-col leading-tight min-w-0">
<span class="font-semibold text-slate-800 dark:text-slate-100 text-sm">Portal</span>
<span v-if="prefeitura.nomePrefeitura" class="text-xs text-slate-400 dark:text-slate-500 truncate">
{{ prefeitura.nomePrefeitura }}
</span>
</div>
</div>
</template>
<div class="flex flex-col h-full -mx-4 px-2">
<!-- Info do usuário -->
<div class="px-2 pb-4 mb-3 border-b border-slate-200 dark:border-slate-700">
<p class="text-xs text-slate-400 dark:text-slate-500 uppercase tracking-wide mb-0.5">Logado como</p>
<p class="text-sm font-semibold text-slate-800 dark:text-slate-100 truncate">{{ nomeUsuario }}</p>
</div>
<!-- Links de navegação -->
<nav class="flex flex-col gap-0.5 flex-1" aria-label="Menu de navegação mobile">
<button
v-for="item in navItems"
:key="item.path"
class="flex items-center gap-3 w-full px-3 py-3 rounded-xl text-sm font-medium transition-colors text-left"
:class="route.path === item.path
? 'bg-primary/10 dark:bg-primary/15 text-primary'
: 'text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800'"
:aria-current="route.path === item.path ? 'page' : undefined"
@click="navegar(item.path)"
>
<i :class="['pi', item.icon, 'text-base w-5 text-center flex-shrink-0']" aria-hidden="true" />
{{ item.label }}
</button>
</nav>
<!-- Sair -->
<div class="pt-3 mt-3 border-t border-slate-200 dark:border-slate-700">
<button
class="flex items-center gap-3 w-full px-3 py-3 rounded-xl text-sm font-medium text-slate-600 dark:text-slate-300 hover:bg-red-50 dark:hover:bg-red-900/20 hover:text-red-600 dark:hover:text-red-400 transition-colors"
@click="sair"
>
<i class="pi pi-sign-out text-base w-5 text-center flex-shrink-0" aria-hidden="true" />
Sair do portal
</button>
</div>
</div>
</Drawer>
<main
id="main-content"
tabindex="-1"
class="flex-1 max-w-7xl mx-auto w-full px-4 sm:px-6 lg:px-8 py-6 sm:py-8 outline-none"
>
<slot /> <slot />
</main> </main>

View File

@ -371,7 +371,7 @@ const estados = ['AC','AL','AP','AM','BA','CE','DF','ES','GO','MA','MT','MS','MG
</p> </p>
<div class="flex gap-3 pt-2"> <div class="flex gap-3 pt-2">
<Button label="Voltar" severity="secondary" outlined class="flex-1" @click="voltar" /> <Button label="Voltar" severity="secondary" outlined class="flex-1" @click="voltar" />
<Button label="Enviar solicitação" icon="pi pi-send" class="flex-1" :loading="carregando" :disabled="carregando" @click="solicitar" /> <Button label="Enviar solicitação" icon="pi pi-send" class="flex-1" :loading="carregando" @click="solicitar" />
</div> </div>
</template> </template>

View File

@ -1,5 +0,0 @@
<script setup>
const { isAuthenticated } = useAuth()
await navigateTo(isAuthenticated.value ? '/portal/painel' : '/', { replace: true })
</script>

View File

@ -1,17 +1,15 @@
<script setup> <script setup>
import { ref, computed, watch, nextTick, onMounted } from 'vue' import { ref, computed, watch, nextTick } from 'vue'
import { usePrefeituraStore } from '@/stores/prefeituraStore' import { usePrefeituraStore } from '@/stores/prefeituraStore'
import { useAuth } from '@/composables/useAuth' import { useAuth } from '@/composables/useAuth'
import { useFocusLoginInput } from '@/composables/useFocusLoginInput' import { useFocusLoginInput } from '@/composables/useFocusLoginInput'
import { useMotion } from '@/composables/useMotion' import { useMotion } from '@/composables/useMotion'
import { avisoService } from '@/services/avisoService'
import bgTutoia from '@/assets/images/bg-tutoia.jpeg' 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()
@ -20,12 +18,6 @@ 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)
@ -61,8 +53,8 @@ const heroBgStyle = computed(() => {
const heroHasPhoto = computed(() => !!heroBgUrl.value) const heroHasPhoto = computed(() => !!heroBgUrl.value)
// FALLBACK exibido quando nenhum aviso está cadastrado no banco // Dados mockados conectar ao endpoint /publico/avisos/{dominio} futuramente
const AVISOS_FALLBACK = [ const avisos = ref([
{ {
id: 1, id: 1,
tipo: 'prazo', tipo: 'prazo',
@ -90,36 +82,7 @@ const AVISOS_FALLBACK = [
cor: 'blue', cor: 'blue',
acao: null, acao: null,
}, },
] ])
//
const avisos = ref(AVISOS_FALLBACK)
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 {
const res = await avisoService.listar(prefeitura.dominio)
const lista = (res.data ?? []).map(a => ({
id: a.id,
tipo: a.tipo ?? 'info',
icone: a.icone ?? 'pi-info-circle',
titulo: a.titulo,
descricao: a.descricao,
cor: a.cor ?? 'blue',
acao: a.acaoLabel ? { label: a.acaoLabel, to: a.acaoLink } : null,
}))
if (lista.length > 0) avisos.value = lista
} catch {
// mantém fallback
}
})
const corAviso = { const corAviso = {
amber: { bg: 'bg-amber-50 dark:bg-amber-900/20', borda: 'border-amber-200 dark:border-amber-700/40', icone: 'text-amber-600 dark:text-amber-400', tag: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300' }, amber: { bg: 'bg-amber-50 dark:bg-amber-900/20', borda: 'border-amber-200 dark:border-amber-700/40', icone: 'text-amber-600 dark:text-amber-400', tag: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300' },
@ -134,9 +97,9 @@ const servicosPublicos = [
] ]
const servicosAutenticados = [ const servicosAutenticados = [
{ icon: 'pi-receipt', titulo: 'Extrato de Débitos', descricao: 'Consulte débitos e emita guias de pagamento.', to: '/portal/debitos' }, { icon: 'pi-receipt', titulo: 'Débitos e Guias', descricao: 'Emita guias de pagamento de todos os seus tributos.', 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-briefcase', titulo: 'Alvarás', descricao: 'Acompanhe processos e solicite novos alvarás.', to: '/portal/alvaras' },
{ 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' },
] ]
@ -304,7 +267,6 @@ async function continuar() {
class="w-full" class="w-full"
size="large" size="large"
:loading="carregando" :loading="carregando"
:disabled="carregando"
@click="continuar" @click="continuar"
/> />
</div> </div>
@ -352,12 +314,12 @@ async function continuar() {
<div <div
:class="[ :class="[
'mx-2 rounded-xl border p-4 flex items-start gap-4', 'mx-2 rounded-xl border p-4 flex items-start gap-4',
(corAviso[aviso.cor] ?? corAviso.blue).bg, corAviso[aviso.cor].bg,
(corAviso[aviso.cor] ?? corAviso.blue).borda, corAviso[aviso.cor].borda,
]" ]"
> >
<div class="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 bg-white shadow-sm"> <div class="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 bg-white shadow-sm">
<i :class="['pi', aviso.icone, 'text-lg', (corAviso[aviso.cor] ?? corAviso.blue).icone]" /> <i :class="['pi', aviso.icone, 'text-lg', corAviso[aviso.cor].icone]" />
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="font-semibold text-slate-800 dark:text-slate-100 text-sm">{{ aviso.titulo }}</p> <p class="font-semibold text-slate-800 dark:text-slate-100 text-sm">{{ aviso.titulo }}</p>

View File

@ -0,0 +1,146 @@
<script setup>
import { ref, onMounted } from 'vue'
import { portalService } from '@/services/portalService'
definePageMeta({
layout: 'portal',
middleware: 'auth',
})
const alvaras = ref([])
const carregando = ref(true)
const mensagemErro = ref('')
const filtroStatus = ref(null)
const statusOptions = [
{ value: 'EM_ANALISE', label: 'Em análise' },
{ value: 'AGUARDANDO_DOCUMENTOS', label: 'Aguardando documentos' },
{ value: 'DEFERIDO', label: 'Deferido' },
{ value: 'INDEFERIDO', label: 'Indeferido' },
{ value: 'CANCELADO', label: 'Cancelado' },
]
const statusMap = {
EM_ANALISE: { label: 'Em análise', classe: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400', icone: 'pi-clock' },
AGUARDANDO_DOCUMENTOS: { label: 'Aguard. docs', classe: 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400', icone: 'pi-file-edit' },
DEFERIDO: { label: 'Deferido', classe: 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400', icone: 'pi-check-circle' },
INDEFERIDO: { label: 'Indeferido', classe: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400', icone: 'pi-times-circle' },
CANCELADO: { label: 'Cancelado', classe: 'bg-slate-100 dark:bg-slate-700 text-slate-500 dark:text-slate-400', icone: 'pi-ban' },
}
onMounted(() => carregar())
async function carregar() {
carregando.value = true
mensagemErro.value = ''
try {
const params = filtroStatus.value ? { status: filtroStatus.value } : {}
const res = await portalService.getAlvaras(params)
alvaras.value = res.data?.content ?? []
} catch (e) {
mensagemErro.value = e?.data?.description ?? 'Não foi possível carregar os alvarás.'
} finally {
carregando.value = false
}
}
</script>
<template>
<div class="space-y-6">
<div>
<h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100">Alvarás</h1>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">Acompanhe o andamento dos seus processos de alvará.</p>
</div>
<div class="flex gap-3 flex-wrap">
<button
:class="['px-4 py-2 rounded-lg text-sm font-semibold border transition-colors', filtroStatus === null ? '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="filtroStatus = null; carregar()"
>
Todos
</button>
<button
v-for="opt in statusOptions"
:key="opt.value"
:class="['px-4 py-2 rounded-lg text-sm font-semibold border transition-colors', filtroStatus === opt.value ? '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="filtroStatus = opt.value; carregar()"
>
{{ opt.label }}
</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 3" :key="i" class="p-5 space-y-3">
<div class="flex items-center gap-3">
<div class="h-4 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-1/3" />
<div class="h-6 bg-slate-200 dark:bg-slate-700 rounded-full animate-pulse w-24 ml-auto" />
</div>
<div class="h-3 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-2/3" />
<div class="h-2.5 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-1/4" />
</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="alvaras.length === 0" class="p-12 text-center">
<i class="pi pi-briefcase 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 alvará encontrado</p>
<p class="text-sm text-slate-400 dark:text-slate-500 mt-1">Solicitações de alvará aparecem aqui após o protocolo.</p>
</div>
<div v-else class="divide-y divide-slate-100 dark:divide-slate-700">
<div
v-for="alv in alvaras"
:key="alv.id"
class="p-5 hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors"
>
<div class="flex items-start justify-between gap-4 mb-2">
<div>
<p class="font-semibold text-slate-800 dark:text-slate-100 text-sm">{{ alv.tipo }}</p>
<p class="text-xs text-slate-400 dark:text-slate-500 mt-0.5">Processo {{ alv.numeroProcesso }}</p>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<i :class="['pi', statusMap[alv.status]?.icone ?? 'pi-circle', 'text-xs', statusMap[alv.status]?.classe?.split(' ').find(c => c.startsWith('text'))]" aria-hidden="true" />
<span :class="['text-xs font-semibold px-2 py-1 rounded-full whitespace-nowrap', statusMap[alv.status]?.classe ?? 'bg-slate-100 text-slate-500']">
{{ statusMap[alv.status]?.label ?? alv.status }}
</span>
</div>
</div>
<div v-if="alv.etapas?.length" class="mt-4 flex items-center gap-1 overflow-x-auto pb-1">
<template v-for="(etapa, idx) in alv.etapas" :key="idx">
<div class="flex flex-col items-center gap-1 min-w-[72px]">
<div
class="w-6 h-6 rounded-full flex items-center justify-center text-xs"
:class="etapa.concluida
? 'bg-primary text-white'
: etapa.atual
? 'bg-primary/20 dark:bg-primary/30 text-primary border-2 border-primary'
: 'bg-slate-100 dark:bg-slate-700 text-slate-400'"
>
<i v-if="etapa.concluida" class="pi pi-check text-xs" aria-hidden="true" />
<span v-else class="text-xs font-bold">{{ idx + 1 }}</span>
</div>
<span class="text-xs text-slate-500 dark:text-slate-400 text-center leading-tight w-16">{{ etapa.nome }}</span>
</div>
<div
v-if="idx < alv.etapas.length - 1"
class="flex-1 h-px min-w-[12px]"
:class="alv.etapas[idx + 1].concluida || alv.etapas[idx + 1].atual ? 'bg-primary/40' : 'bg-slate-200 dark:bg-slate-700'"
/>
</template>
</div>
<p v-if="alv.ultimaAtualizacao" class="text-xs text-slate-400 dark:text-slate-500 mt-3">
Última atualização: {{ alv.ultimaAtualizacao }}
</p>
</div>
</div>
</div>
</div>
</template>

View File

@ -1,35 +1,12 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { portalService } from '@/services/portalService' import { portalService } from '@/services/portalService'
import { formatDate } 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 CERTIDOES_MOCK = [
{
id: 1,
tipo: 'Certidão Negativa de Débitos',
numero: '2024/0042',
dataEmissao: '2024-03-10',
dataValidade: '2025-03-10',
status: 'ATIVA',
},
{
id: 2,
tipo: 'Certidão de Regularidade Fiscal',
numero: '2023/0187',
dataEmissao: '2023-06-15',
dataValidade: '2024-06-15',
status: 'VENCIDA',
},
]
//
const router = useRouter() const router = useRouter()
const certidoes = ref([]) const certidoes = ref([])
const carregando = ref(true) const carregando = ref(true)
@ -43,16 +20,9 @@ async function carregar() {
mensagemErro.value = '' mensagemErro.value = ''
try { try {
const res = await portalService.getCertidoes() const res = await portalService.getCertidoes()
certidoes.value = res.data ?? [] certidoes.value = res.data?.content ?? []
if (MOCK_ATIVO && certidoes.value.length === 0) {
certidoes.value = CERTIDOES_MOCK
}
} catch (e) { } catch (e) {
mensagemErro.value = e?.data?.description ?? 'Não foi possível carregar as certidões.' mensagemErro.value = e?.data?.description ?? 'Não foi possível carregar as certidões.'
if (MOCK_ATIVO) {
certidoes.value = CERTIDOES_MOCK
mensagemErro.value = ''
}
} finally { } finally {
carregando.value = false carregando.value = false
} }
@ -89,7 +59,7 @@ const statusMap = {
<h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100">Certidões</h1> <h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100">Certidões</h1>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">Suas certidões emitidas e disponíveis para reemissão.</p> <p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">Suas certidões emitidas e disponíveis para reemissão.</p>
</div> </div>
<Button label="Nova certidão" icon="pi pi-plus" size="small" @click="router.push('/servicos/certidao?from=portal')" /> <Button label="Nova certidão" icon="pi pi-plus" size="small" @click="router.push('/servicos/certidao')" />
</div> </div>
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden"> <div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
@ -114,7 +84,7 @@ const statusMap = {
<i class="pi pi-file text-slate-300 dark:text-slate-600 text-4xl mb-3 block" aria-hidden="true" /> <i class="pi pi-file 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 certidão emitida</p> <p class="font-semibold text-slate-700 dark:text-slate-200">Nenhuma certidão emitida</p>
<p class="text-sm text-slate-400 dark:text-slate-500 mt-1 mb-4">Emita sua primeira certidão pelo portal público.</p> <p class="text-sm text-slate-400 dark:text-slate-500 mt-1 mb-4">Emita sua primeira certidão pelo portal público.</p>
<Button label="Emitir certidão" size="small" @click="router.push('/servicos/certidao?from=portal')" /> <Button label="Emitir certidão" size="small" @click="router.push('/servicos/certidao')" />
</div> </div>
<div v-else> <div v-else>
@ -135,8 +105,8 @@ const statusMap = {
<p class="text-sm font-semibold text-slate-800 dark:text-slate-100 truncate">{{ cert.tipo }}</p> <p class="text-sm font-semibold text-slate-800 dark:text-slate-100 truncate">{{ cert.tipo }}</p>
<p class="text-xs text-slate-400 dark:text-slate-500 mt-0.5"> {{ cert.numero }}</p> <p class="text-xs text-slate-400 dark:text-slate-500 mt-0.5"> {{ cert.numero }}</p>
</div> </div>
<p class="hidden sm:block text-sm text-slate-500 dark:text-slate-400 w-28 text-right">{{ formatDate(cert.dataEmissao) }}</p> <p class="hidden sm:block text-sm text-slate-500 dark:text-slate-400 w-28 text-right">{{ cert.dataEmissao }}</p>
<p class="hidden sm:block text-sm text-slate-500 dark:text-slate-400 w-28 text-right">{{ formatDate(cert.dataValidade) }}</p> <p class="hidden sm:block text-sm text-slate-500 dark:text-slate-400 w-28 text-right">{{ cert.dataValidade }}</p>
<div class="w-20 flex justify-center"> <div class="w-20 flex justify-center">
<span :class="['text-xs font-semibold px-2 py-1 rounded-full', statusMap[cert.status]?.classe ?? 'bg-slate-100 text-slate-500']"> <span :class="['text-xs font-semibold px-2 py-1 rounded-full', statusMap[cert.status]?.classe ?? 'bg-slate-100 text-slate-500']">
{{ statusMap[cert.status]?.label ?? cert.status }} {{ statusMap[cert.status]?.label ?? cert.status }}
@ -149,7 +119,7 @@ const statusMap = {
size="small" size="small"
outlined outlined
:loading="carregandoPdf === cert.id" :loading="carregandoPdf === cert.id"
:disabled="cert.status === 'CANCELADA' || !!carregandoPdf" :disabled="cert.status === 'CANCELADA'"
@click="reemitir(cert)" @click="reemitir(cert)"
/> />
</div> </div>

View File

@ -30,16 +30,6 @@ onMounted(async () => {
} }
}) })
function formatarDocumento(doc, tipo) {
if (!doc) return '—'
const d = doc.replace(/\D/g, '')
if (tipo === 'JURIDICA' && d.length === 14)
return d.replace(/(\d{2})(\d{3})(\d{3})(\d{4})(\d{2})/, '$1.$2.$3/$4-$5')
if (d.length === 11)
return d.replace(/(\d{3})(\d{3})(\d{3})(\d{2})/, '$1.$2.$3-$4')
return doc
}
async function salvarContato() { async function salvarContato() {
salvando.value = true salvando.value = true
mensagemErro.value = '' mensagemErro.value = ''
@ -103,7 +93,7 @@ function formatarTelefone(e) {
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<p class="text-xs text-slate-400 dark:text-slate-500">{{ dados.tipoPessoa === 'JURIDICA' ? 'CNPJ' : 'CPF' }}</p> <p class="text-xs text-slate-400 dark:text-slate-500">{{ dados.tipoPessoa === 'JURIDICA' ? 'CNPJ' : 'CPF' }}</p>
<p class="text-sm font-semibold text-slate-800 dark:text-slate-100 mt-0.5 font-mono">{{ formatarDocumento(dados.documento, dados.tipoPessoa) }}</p> <p class="text-sm font-semibold text-slate-800 dark:text-slate-100 mt-0.5 font-mono">{{ dados.documento }}</p>
</div> </div>
<div> <div>
<p class="text-xs text-slate-400 dark:text-slate-500">{{ dados.tipoPessoa === 'JURIDICA' ? 'Razão Social' : 'Nome completo' }}</p> <p class="text-xs text-slate-400 dark:text-slate-500">{{ dados.tipoPessoa === 'JURIDICA' ? 'Razão Social' : 'Nome completo' }}</p>
@ -123,13 +113,13 @@ function formatarTelefone(e) {
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6"> <div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-6">
<p class="text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-4">Endereço</p> <p class="text-xs font-bold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-4">Endereço</p>
<p class="text-sm text-slate-800 dark:text-slate-100"> <p class="text-sm text-slate-800 dark:text-slate-100">
{{ dados.logradouro }}, {{ dados.numero }} {{ dados.endereco?.logradouro }}, {{ dados.endereco?.numero }}
<template v-if="dados.complemento"> {{ dados.complemento }}</template> <template v-if="dados.endereco?.complemento"> {{ dados.endereco.complemento }}</template>
</p> </p>
<p class="text-sm text-slate-600 dark:text-slate-300 mt-0.5"> <p class="text-sm text-slate-600 dark:text-slate-300 mt-0.5">
{{ dados.bairro }}<template v-if="dados.cidade"> {{ dados.cidade }}</template>/{{ dados.uf }} {{ dados.endereco?.bairro }} {{ dados.endereco?.cidade }}/{{ dados.endereco?.uf }}
</p> </p>
<p class="text-xs text-slate-400 dark:text-slate-500 mt-1">CEP: {{ dados.cep }}</p> <p class="text-xs text-slate-400 dark:text-slate-500 mt-1">CEP: {{ dados.endereco?.cep }}</p>
<p class="text-xs text-slate-400 dark:text-slate-500 mt-3"> <p class="text-xs text-slate-400 dark:text-slate-500 mt-3">
Para alterar o endereço, compareça ao setor de atendimento da Prefeitura. Para alterar o endereço, compareça ao setor de atendimento da Prefeitura.
</p> </p>
@ -182,7 +172,7 @@ function formatarTelefone(e) {
</p> </p>
<div class="flex gap-3"> <div class="flex gap-3">
<Button label="Cancelar" severity="secondary" outlined class="flex-1" @click="cancelarEdicao" /> <Button label="Cancelar" severity="secondary" outlined class="flex-1" @click="cancelarEdicao" />
<Button label="Salvar" icon="pi pi-check" class="flex-1" :loading="salvando" :disabled="salvando" @click="salvarContato" /> <Button label="Salvar" icon="pi pi-check" class="flex-1" :loading="salvando" @click="salvarContato" />
</div> </div>
</div> </div>

View File

@ -1,226 +1,233 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useExtratoDebitosPortal } from '@/composables/useExtratoDebitosPortal' import { portalService } from '@/services/portalService'
import { formatCurrency, formatDate } from '@/utils/formatador'
import ModalTransacoesContaCorrente from '@/components/extrato/ModalTransacoesContaCorrente.vue'
definePageMeta({ definePageMeta({
layout: 'portal', layout: 'portal',
middleware: 'auth', middleware: 'auth',
}) })
const modalTransacoes = ref(null) const debitos = ref([])
const carregando = ref(true)
const carregandoGuia = ref(null)
const filtroTipo = ref(null)
const filtroStatus = ref(null)
const mensagemErro = ref('')
const { const tiposDisponiveis = ['IPTU', 'ISS', 'TAXA', 'MULTA', 'DIVIDA_ATIVA']
resultados, const statusDisponiveis = [
isLoading, { value: 'VENCIDO', label: 'Vencido', classe: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400' },
isLoadingGuia, { value: 'A_VENCER', label: 'A vencer', classe: 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400' },
isLoadingExtrato, { value: 'PARCELADO', label: 'Parcelado', classe: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400' },
mensagemErro, ]
dataVencimento,
erros,
filtro,
opcoesEstadoConta,
temSelecionado,
temDebitoSelecionado,
totalizadores,
consultar,
limparFiltros,
gerarGuia,
gerarExtratoPdf,
getEstadoSeverity,
} = useExtratoDebitosPortal()
onMounted(() => consultar()) const statusClasse = Object.fromEntries(statusDisponiveis.map(s => [s.value, s.classe]))
const statusLabel = Object.fromEntries(statusDisponiveis.map(s => [s.value, s.label]))
function getSegundoNome(texto) { onMounted(() => carregar())
const palavras = (texto || '').trim().split(/\s+/)
return palavras.length > 1 ? palavras[1] : texto async function carregar() {
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?.content ?? []
} 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 {
mensagemErro.value = '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()
} }
</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">Extrato de Débitos</h1> <h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100">Débitos e Guias</h1>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5"> <p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">Consulte seus débitos e emita guias de pagamento.</p>
Consulte seus débitos, selecione parcelas e emita guias de pagamento. </div>
</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="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="grid grid-cols-1 sm:grid-cols-2 gap-4"> <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">Tipo</label>
<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>
<DatePicker v-model="filtro.periodoIni" 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">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="filtro.estadoConta" v-model="filtroTipo"
:options="opcoesEstadoConta" :options="tiposDisponiveis"
placeholder="Todos"
show-clear
class="w-full"
size="small"
/>
</div>
<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="statusDisponiveis"
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> <div class="flex gap-2">
<div class="flex gap-2 flex-wrap"> <Button label="Filtrar" icon="pi pi-filter" size="small" @click="aplicarFiltro" />
<Button label="Consultar" icon="pi pi-search" size="small" :loading="isLoading" @click="consultar" /> <Button label="Limpar" severity="secondary" size="small" outlined @click="limparFiltros" />
<Button label="Limpar" icon="pi pi-eraser" size="small" severity="secondary" outlined @click="limparFiltros" />
</div> </div>
</div> </div>
<Message v-if="mensagemErro" severity="warn" :closable="false">{{ mensagemErro }}</Message> <Transition
enter-active-class="transition-all duration-200"
<div v-if="isLoading" class="space-y-3"> enter-from-class="opacity-0 -translate-y-2"
<div v-for="i in 3" :key="i" class="h-24 bg-slate-200 dark:bg-slate-700 rounded-xl animate-pulse" /> enter-to-class="opacity-100 translate-y-0"
</div> leave-active-class="transition-all duration-150"
leave-from-class="opacity-100 translate-y-0"
<div v-else-if="resultados.length === 0 && !mensagemErro" class="bg-white dark:bg-slate-800 rounded-xl border p-12 text-center"> leave-to-class="opacity-0 -translate-y-2"
<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>
</div>
<template v-else-if="resultados.length">
<Accordion :multiple="true" :active-index="[0]">
<AccordionPanel v-for="(tributo, index) in resultados" :key="tributo.idContaTributo" :value="String(index)">
<AccordionHeader>
<div class="flex items-center justify-between w-full gap-2 pr-2">
<span class="text-sm font-semibold truncate">
Conta {{ tributo.idContaTributo }} {{ tributo.descricaoContaTributo }} / {{ tributo.descricaoTributo }}
</span>
<span class="text-sm shrink-0">
Total: <strong>{{ formatCurrency(tributo.totalPagamentos) }}</strong>
</span>
</div>
</AccordionHeader>
<AccordionContent>
<DataTable
v-model:selection="tributo.selecionados"
:value="tributo.debitos"
data-key="id"
size="small"
show-gridlines
selection-mode="multiple"
:is-row-selectable="({ data }) => data.codigoEstadoConta === 1"
scrollable
class="mt-2"
> >
<Column selection-mode="multiple" header-style="width: 3rem" /> <div
<Column field="numDoc" header="Nº Doc" style="min-width: 90px" /> v-if="temSelecionados"
<Column field="estadoConta" header="Estado" style="min-width: 90px"> class="bg-primary/8 dark:bg-primary/15 border border-primary/20 rounded-xl p-4 flex items-center justify-between gap-4"
<template #body="{ data }"> >
<Tag :value="getSegundoNome(data.estadoConta)" :severity="getEstadoSeverity(data.codigoEstadoConta)" /> <p class="text-sm font-semibold text-primary">
</template> Total selecionado: {{ formatarMoeda(totalSelecionado) }}
</Column> </p>
<Column field="periodoRef" header="Período" style="min-width: 80px" /> <Button label="Emitir guia unificada" icon="pi pi-download" size="small" />
<Column field="dataVencimento" header="Vencimento" style="min-width: 95px"> </div>
<template #body="{ data }">{{ formatDate(data.dataVencimento) }}</template> </Transition>
</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="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div> <div v-if="carregando" class="divide-y divide-slate-100 dark:divide-slate-700">
<label class="block text-xs font-semibold text-slate-500 uppercase mb-1.5">Vencimento da Guia</label> <div v-for="i in 5" :key="i" class="p-5 flex items-center gap-4">
<DatePicker <div class="w-4 h-4 bg-slate-200 dark:bg-slate-700 rounded animate-pulse" />
v-model="dataVencimento" <div class="flex-1 space-y-2">
show-icon <div class="h-3.5 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-1/3" />
:min-date="new Date()" <div class="h-3 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-1/4" />
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 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>
<div class="flex flex-wrap gap-2 justify-between"> <div v-else-if="debitos.length === 0 && !mensagemErro" class="p-12 text-center">
<div class="flex flex-wrap gap-2"> <i class="pi pi-check-circle text-emerald-400 dark:text-emerald-500 text-4xl mb-3 block" aria-hidden="true" />
<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 v-else-if="mensagemErro" class="p-8 text-center">
<i class="pi pi-exclamation-triangle text-amber-400 text-3xl mb-3 block" aria-hidden="true" />
<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>
<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">
<Checkbox :binary="true" @change="toggleTodos($event.target.checked)" />
<span class="flex-1">Descrição</span>
<span class="hidden sm:block 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="flex items-center gap-4 px-5 py-4 hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors"
>
<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">{{ debito.descricao }}</p>
<p class="text-xs text-slate-400 dark:text-slate-500 mt-0.5">{{ debito.tipo }} · Ref: {{ debito.referencia }}</p>
</div>
<p class="hidden sm:block text-sm text-slate-500 dark:text-slate-400 w-28 text-right whitespace-nowrap">
{{ 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 <Button
label="Imprimir Extrato (todos)" label="Emitir guia"
icon="pi pi-print" icon="pi pi-download"
size="small" 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 outlined
:loading="isLoadingExtrato" class="whitespace-nowrap"
@click="gerarExtratoPdf(true)" :loading="carregandoGuia === debito.id"
/> @click="emitirGuia(debito)"
</div>
<Button
v-if="temDebitoSelecionado"
label="Gerar Guia de Pagamento"
icon="pi pi-file-pdf"
size="small"
:loading="isLoadingGuia"
@click="gerarGuia"
/> />
</div> </div>
</div> </div>
</template> </div>
</div>
</div>
<ModalTransacoesContaCorrente ref="modalTransacoes" />
</div> </div>
</template> </template>

View File

@ -1,170 +0,0 @@
<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-4 gap-4">
<div class="lg:col-span-2">
<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 class="lg:col-span-2">
<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 class="lg:col-span-2">
<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 class="lg:col-span-2">
<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,151 +1,147 @@
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useExtratoPagamentosPortal } from '@/composables/useExtratoPagamentosPortal' import { portalService } from '@/services/portalService'
import { formatCurrency, formatDate, abrirPdf } from '@/utils/formatador'
definePageMeta({ definePageMeta({
layout: 'portal', layout: 'portal',
middleware: 'auth', middleware: 'auth',
}) })
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 { const anosDisponiveis = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i)
tributos,
isLoading,
isLoadingPdf,
mensagemErro,
filtro,
totais,
consultar,
limparFiltros,
gerarPdf,
baixarComprovante,
} = useExtratoPagamentosPortal()
onMounted(() => consultar()) onMounted(() => carregar())
async function emitirComprovante(pag) { async function carregar() {
carregando.value = true
mensagemErro.value = ''
try {
const res = await portalService.getPagamentos({ ano: filtroAno.value })
pagamentos.value = res.data?.content ?? []
} catch (e) {
mensagemErro.value = e?.data?.description ?? 'Não foi possível carregar os pagamentos.'
} finally {
carregando.value = false
}
}
async function baixarComprovante(pag) {
carregandoComprovante.value = pag.id carregandoComprovante.value = pag.id
try { try {
const buf = await baixarComprovante(pag) const buf = await portalService.getComprovante(pag.id)
if (buf?.byteLength) { const url = URL.createObjectURL(new Blob([buf], { type: 'application/pdf' }))
abrirPdf(buf) const a = document.createElement('a')
} else { a.href = url
mensagemErro.value = 'Comprovante não disponível para este pagamento.' a.download = `comprovante-${pag.id}.pdf`
} a.click()
URL.revokeObjectURL(url)
} catch {
mensagemErro.value = 'Erro ao baixar 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' },
}
</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">Extrato de Pagamentos</h1> <h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100">Histórico de Pagamentos</h1>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">Histórico de pagamentos com detalhamento por tributo.</p> <p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">Todos os seus pagamentos com comprovantes para download.</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 label="Consultar" icon="pi pi-search" size="small" :loading="isLoading" @click="consultar" /> <button
<Button label="Limpar" icon="pi pi-eraser" size="small" severity="secondary" outlined @click="limparFiltros" /> v-for="ano in anosDisponiveis"
: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>
<Message v-if="mensagemErro" severity="warn" :closable="false">{{ mensagemErro }}</Message> <div v-else-if="mensagemErro" class="p-8 text-center">
<p class="text-sm text-slate-600 dark:text-slate-300">{{ mensagemErro }}</p>
<div v-if="isLoading" class="space-y-3"> <Button label="Tentar novamente" severity="secondary" size="small" class="mt-4" @click="carregar" />
<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="tributos.length === 0 && !mensagemErro" class="bg-white dark:bg-slate-800 rounded-xl border p-12 text-center"> <div v-else-if="pagamentos.length === 0" class="p-12 text-center">
<p class="font-semibold text-slate-700 dark:text-slate-200">Nenhum pagamento encontrado</p> <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 em {{ filtroAno }}</p>
<p class="text-sm text-slate-400 dark:text-slate-500 mt-1">Pagamentos realizados aparecerão aqui.</p>
</div> </div>
<template v-else-if="tributos.length"> <div v-else>
<Accordion :multiple="true" :active-index="[0]"> <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">
<AccordionPanel v-for="(t, index) in tributos" :key="t.idContaTributo ?? index" :value="String(index)"> <span class="flex-1">Descrição</span>
<AccordionHeader> <span class="hidden sm:block w-28 text-right">Data</span>
<span class="text-sm font-semibold truncate"> <span class="hidden sm:block w-24 text-center">Forma</span>
{{ t.descricaoTributo }} {{ t.descricaoContaTributo }} <span class="w-28 text-right">Valor</span>
({{ t.pagamentos?.length ?? 0 }} pagamentos) <span class="w-28" />
</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>
</AccordionHeader> </div>
<AccordionContent> <p class="text-sm font-bold text-emerald-600 dark:text-emerald-400 w-28 text-right whitespace-nowrap">
<DataTable :value="t.pagamentos" size="small" show-gridlines scrollable class="mt-2"> {{ formatarMoeda(pag.valor) }}
<Column field="ndoc" header="Nº Doc" /> </p>
<Column field="refer" header="Período" /> <div class="w-28 flex justify-end">
<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-download"
label="Comprovante" label="Comprovante"
text
size="small" size="small"
:loading="carregandoComprovante === data.id" text
:disabled="!data.idContaCorrente" :loading="carregandoComprovante === pag.id"
@click="emitirComprovante(data)" @click="baixarComprovante(pag)"
/> />
</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

@ -2,7 +2,6 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useAuth } from '@/composables/useAuth' import { useAuth } from '@/composables/useAuth'
import { portalService } from '@/services/portalService' import { portalService } from '@/services/portalService'
import { usePrefeituraStore } from '@/stores/prefeituraStore'
definePageMeta({ definePageMeta({
layout: 'portal', layout: 'portal',
@ -10,34 +9,13 @@ definePageMeta({
}) })
const { nomeUsuario } = useAuth() const { nomeUsuario } = useAuth()
const prefeitura = usePrefeituraStore()
const router = useRouter() const router = useRouter()
// MOCKS APRESENTAÇÃO remover antes do deploy
const MOCK_ATIVO = true
const RESUMO_MOCK = {
totalDebitos: 1250.90,
certidoesAtivas: 2,
ultimoPagamento: '2025-05-15',
valorUltimoPagamento: 430.00,
debitosVencidos: 1,
}
const ATIVIDADES_MOCK = [
{ tipo: 'PAGAMENTO', descricao: 'IPTU 2025 — Parcela 3/10 paga', data: '15/05/2025' },
{ tipo: 'CERTIDAO', descricao: 'Certidão Negativa emitida', data: '10/05/2025' },
{ tipo: 'DEBITO', descricao: 'Guia IPTU 2025 — Cota 4 emitida', data: '02/05/2025' },
{ tipo: 'CADASTRO', descricao: 'E-mail cadastral atualizado', data: '20/04/2025' },
]
//
const resumo = ref(null) const resumo = ref(null)
const atividades = ref([]) const atividades = ref([])
const carregando = ref(true) const carregando = ref(true)
onMounted(carregar) onMounted(async () => {
async function carregar() {
carregando.value = true
try { try {
const [resResumo, resAtividades] = await Promise.all([ const [resResumo, resAtividades] = await Promise.all([
portalService.getPainelResumo(), portalService.getPainelResumo(),
@ -45,42 +23,28 @@ async function carregar() {
]) ])
resumo.value = resResumo.data resumo.value = resResumo.data
atividades.value = resAtividades.data?.content ?? [] atividades.value = resAtividades.data?.content ?? []
if (MOCK_ATIVO) {
if (!resumo.value) resumo.value = RESUMO_MOCK
if (atividades.value.length === 0) atividades.value = ATIVIDADES_MOCK
}
} catch { } catch {
if (MOCK_ATIVO) { // silencioso exibe zeros
resumo.value = RESUMO_MOCK
atividades.value = ATIVIDADES_MOCK
}
} finally { } finally {
carregando.value = false carregando.value = false
} }
} })
const acesRapidos = [ const acesRapidos = [
{ icon: 'pi-receipt', label: 'Extrato Débitos', to: '/portal/debitos', cor: 'text-primary' }, { icon: 'pi-receipt', label: 'Emitir Guia', 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-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-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' },
] ]
function formatarMoeda(valor) { function formatarMoeda(valor) {
const n = Number(valor ?? 0) return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(valor ?? 0)
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(Number.isNaN(n) ? 0 : n)
}
function formatarData(data) {
if (!data) return ''
return new Date(data + 'T00:00:00').toLocaleDateString('pt-BR')
} }
const iconeAtividade = { const iconeAtividade = {
DEBITO: 'pi-receipt', DEBITO: 'pi-receipt',
CERTIDAO: 'pi-file-check', CERTIDAO: 'pi-file-check',
ALVARA: 'pi-briefcase',
PAGAMENTO: 'pi-credit-card', PAGAMENTO: 'pi-credit-card',
CADASTRO: 'pi-user', CADASTRO: 'pi-user',
} }
@ -89,25 +53,14 @@ const iconeAtividade = {
<template> <template>
<div class="space-y-8"> <div class="space-y-8">
<div class="flex items-center justify-between gap-4 flex-wrap">
<div> <div>
<p v-if="prefeitura.nomePrefeitura" class="text-xs font-semibold text-primary/70 dark:text-primary/50 uppercase tracking-widest mb-1">
{{ prefeitura.nomePrefeitura }}
</p>
<h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100"> <h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100">
Olá, {{ nomeUsuario || 'Contribuinte' }} 👋 Olá, {{ nomeUsuario || 'Contribuinte' }} 👋
</h1> </h1>
<p class="text-slate-500 dark:text-slate-400 mt-1">Bem-vindo ao seu painel de gestão fiscal.</p> <p class="text-slate-500 dark:text-slate-400 mt-1">Bem-vindo ao seu painel de gestão fiscal.</p>
</div> </div>
<img
v-if="prefeitura.pathLogo"
:src="prefeitura.pathLogo"
:alt="prefeitura.nomePrefeitura ?? 'Logo do município'"
class="h-14 w-auto object-contain opacity-80 dark:opacity-60"
/>
</div>
<div class="grid grid-cols-2 lg:grid-cols-3 gap-4"> <div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5 space-y-3"> <div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5 space-y-3">
<div class="w-10 h-10 bg-red-50 dark:bg-red-900/20 rounded-lg flex items-center justify-center"> <div class="w-10 h-10 bg-red-50 dark:bg-red-900/20 rounded-lg flex items-center justify-center">
@ -135,6 +88,19 @@ const iconeAtividade = {
</div> </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-3">
<div class="w-10 h-10 bg-amber-50 dark:bg-amber-900/20 rounded-lg flex items-center justify-center">
<i class="pi pi-briefcase text-amber-600 dark:text-amber-400 text-sm" aria-hidden="true" />
</div>
<div>
<p class="text-2xl font-bold text-slate-800 dark:text-slate-100">
<span v-if="carregando" class="inline-block w-8 h-7 bg-slate-200 dark:bg-slate-700 rounded animate-pulse" />
<template v-else>{{ resumo?.alvarasAndamento ?? 0 }}</template>
</p>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Alvarás em andamento</p>
</div>
</div>
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5 space-y-3"> <div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5 space-y-3">
<div class="w-10 h-10 bg-primary/10 dark:bg-primary/20 rounded-lg flex items-center justify-center"> <div class="w-10 h-10 bg-primary/10 dark:bg-primary/20 rounded-lg flex items-center justify-center">
<i class="pi pi-credit-card text-primary text-sm" aria-hidden="true" /> <i class="pi pi-credit-card text-primary text-sm" aria-hidden="true" />
@ -142,14 +108,9 @@ const iconeAtividade = {
<div> <div>
<p class="text-2xl font-bold text-slate-800 dark:text-slate-100"> <p class="text-2xl font-bold text-slate-800 dark:text-slate-100">
<span v-if="carregando" class="inline-block w-16 h-7 bg-slate-200 dark:bg-slate-700 rounded animate-pulse" /> <span v-if="carregando" class="inline-block w-16 h-7 bg-slate-200 dark:bg-slate-700 rounded animate-pulse" />
<template v-else>{{ formatarMoeda(resumo?.valorUltimoPagamento) }}</template> <template v-else>{{ formatarMoeda(resumo?.ultimoPagamento) }}</template>
</p>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
Último pagamento
<template v-if="!carregando && resumo?.ultimoPagamento">
· {{ formatarData(resumo.ultimoPagamento) }}
</template>
</p> </p>
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">Último pagamento</p>
</div> </div>
</div> </div>

View File

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

View File

@ -1,238 +0,0 @@
<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: '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]))
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 === 2"
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 === 2 && taxa.possuiDocComprobatorio"
icon="pi pi-verified"
label="Autor."
size="small"
outlined
:loading="carregandoPdf === `autorizacao-${taxa.id}`"
@click="baixar(taxa, 'autorizacao')"
/>
</div>
</div>
</div>
<div
v-if="totalPaginas > 1"
class="flex items-center justify-between px-5 py-3 border-t border-slate-200 dark:border-slate-700 text-sm text-slate-500 dark:text-slate-400"
>
<span>{{ totalElementos }} registro(s)</span>
<div class="flex gap-2">
<Button label="Anterior" size="small" severity="secondary" outlined :disabled="pagina === 0" @click="paginaAnterior" />
<span class="px-2 self-center">{{ pagina + 1 }} / {{ totalPaginas }}</span>
<Button label="Próxima" size="small" severity="secondary" outlined :disabled="pagina >= totalPaginas - 1" @click="proximaPagina" />
</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -1,8 +1,10 @@
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useAuth } from '@/composables/useAuth'
import { primeiroAcessoService } from '@/services/primeiroAcessoService' import { primeiroAcessoService } from '@/services/primeiroAcessoService'
const router = useRouter() const router = useRouter()
const { login } = useAuth()
const etapa = ref(0) const etapa = ref(0)
const carregando = ref(false) const carregando = ref(false)
@ -10,12 +12,21 @@ const erro = ref('')
const documento = ref('') const documento = ref('')
const contribuinteNome = ref('') const contribuinteNome = ref('')
const emailMascarado = ref('')
const canais = ref([])
const canalSelecionado = ref(null)
const codigo = ref('')
const tokenValidacao = ref('')
const senha = ref('')
const senhaConfirm = ref('')
const docDigitos = computed(() => documento.value.replace(/\D/g, '')) const docDigitos = computed(() => documento.value.replace(/\D/g, ''))
const docValido = computed(() => docDigitos.value.length === 11 || docDigitos.value.length === 14) const docValido = computed(() => docDigitos.value.length === 11 || docDigitos.value.length === 14)
const subtitulos = ['Identificação', 'Confirmação', 'Pronto!'] const senhaForte = computed(() => senha.value.length >= 8)
const senhasIguais = computed(() => senha.value === senhaConfirm.value)
async function identificar() { async function identificar() {
if (!docValido.value) return if (!docValido.value) return
@ -24,37 +35,81 @@ async function identificar() {
try { try {
const res = await primeiroAcessoService.verificarDocumento(docDigitos.value) const res = await primeiroAcessoService.verificarDocumento(docDigitos.value)
contribuinteNome.value = res.data.nome contribuinteNome.value = res.data.nome
const canalEmail = res.data.canais?.find(c => c.tipo === 'EMAIL') canais.value = res.data.canais
emailMascarado.value = canalEmail?.valor ?? '' canalSelecionado.value = canais.value[0] ?? null
etapa.value = 1 etapa.value = 1
} catch (e) { } catch (e) {
erro.value = e?.data?.description ?? 'Documento não encontrado ou não habilitado para acesso ao portal.' erro.value = e?.data?.description ?? 'Documento não encontrado ou não habilitado para este fluxo.'
} finally { } finally {
carregando.value = false carregando.value = false
} }
} }
async function solicitar() { async function enviarCodigo() {
if (!canalSelecionado.value) return
carregando.value = true carregando.value = true
erro.value = '' erro.value = ''
try { try {
await primeiroAcessoService.solicitarAcesso(docDigitos.value) await primeiroAcessoService.solicitarCodigo(docDigitos.value, canalSelecionado.value.tipo)
etapa.value = 2 etapa.value = 2
} catch (e) { } catch (e) {
erro.value = e?.data?.description ?? 'Não foi possível solicitar o acesso. Tente novamente.' erro.value = e?.data?.description ?? 'Não foi possível enviar o código. Tente novamente.'
} finally { } finally {
carregando.value = false carregando.value = false
} }
} }
async function validarCodigo() {
if (codigo.value.length < 6) return
carregando.value = true
erro.value = ''
try {
const res = await primeiroAcessoService.validarCodigo(docDigitos.value, codigo.value)
tokenValidacao.value = res.data.token
etapa.value = 3
} catch (e) {
erro.value = e?.data?.description ?? 'Código inválido ou expirado. Solicite um novo.'
} finally {
carregando.value = false
}
}
async function definirSenha() {
if (!senhaForte.value || !senhasIguais.value) return
carregando.value = true
erro.value = ''
try {
await primeiroAcessoService.definirSenha(tokenValidacao.value, senha.value)
etapa.value = 4
} catch (e) {
erro.value = e?.data?.description ?? 'Não foi possível definir a senha. Tente novamente.'
} finally {
carregando.value = false
}
}
async function entrarKeycloak() {
carregando.value = true
erro.value = ''
try {
await login(docDigitos.value, '/portal/painel')
} catch (e) {
carregando.value = false
erro.value = e?.data?.statusMessage ?? 'Não foi possível iniciar o login.'
}
}
const iconeCanal = { EMAIL: 'pi-envelope', SMS: 'pi-mobile', WHATSAPP: 'pi-whatsapp' }
const labelCanal = { EMAIL: 'E-mail', SMS: 'SMS', WHATSAPP: 'WhatsApp' }
</script> </script>
<template> <template>
<div class="min-h-[calc(100vh-8rem)] flex items-center justify-center px-4 py-12"> <div class="min-h-[calc(100vh-8rem)] flex items-center justify-center px-4 py-12">
<div class="w-full max-w-md"> <div class="w-full max-w-md">
<div v-if="etapa < 2" class="flex items-center justify-center gap-2 mb-8"> <div v-if="etapa < 4" class="flex items-center justify-center gap-2 mb-8">
<div <div
v-for="i in 2" v-for="i in 4"
:key="i" :key="i"
class="h-1.5 rounded-full transition-all duration-300" class="h-1.5 rounded-full transition-all duration-300"
:class="[ :class="[
@ -73,7 +128,9 @@ async function solicitar() {
</div> </div>
<div> <div>
<h1 class="text-white font-bold text-base leading-tight">Primeiro Acesso</h1> <h1 class="text-white font-bold text-base leading-tight">Primeiro Acesso</h1>
<p class="text-white/80 text-xs mt-0.5">{{ subtitulos[etapa] }}</p> <p class="text-white/80 text-xs mt-0.5">
{{ ['Identificação', 'Canal de envio', 'Código de verificação', 'Crie sua senha', 'Pronto!'][etapa] }}
</p>
</div> </div>
</div> </div>
</div> </div>
@ -82,7 +139,7 @@ async function solicitar() {
<template v-if="etapa === 0"> <template v-if="etapa === 0">
<p class="text-sm text-slate-600 dark:text-slate-300"> <p class="text-sm text-slate-600 dark:text-slate-300">
Informe seu CPF ou CNPJ para verificarmos seu cadastro e iniciar a criação do seu acesso ao portal. Informe seu CPF ou CNPJ para verificarmos seu cadastro e iniciar a criação de senha.
</p> </p>
<div> <div>
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">CPF ou CNPJ</label> <label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">CPF ou CNPJ</label>
@ -91,82 +148,102 @@ async function solicitar() {
<p v-if="erro" role="alert" class="text-sm text-red-600 dark:text-red-400 flex items-center gap-1.5"> <p v-if="erro" role="alert" class="text-sm text-red-600 dark:text-red-400 flex items-center gap-1.5">
<i class="pi pi-exclamation-circle" aria-hidden="true" /> {{ erro }} <i class="pi pi-exclamation-circle" aria-hidden="true" /> {{ erro }}
</p> </p>
<Button <Button label="Continuar" icon="pi pi-arrow-right" icon-pos="right" class="w-full" size="large" :loading="carregando" :disabled="!docValido" @click="identificar" />
label="Continuar"
icon="pi pi-arrow-right"
icon-pos="right"
class="w-full"
size="large"
:loading="carregando"
:disabled="!docValido || carregando"
@click="identificar"
/>
</template> </template>
<template v-else-if="etapa === 1"> <template v-else-if="etapa === 1">
<div class="space-y-3"> <div>
<p class="text-sm text-slate-600 dark:text-slate-300 mb-1">Olá, <strong class="text-slate-800 dark:text-slate-100">{{ contribuinteNome }}</strong>.</p>
<p class="text-sm text-slate-600 dark:text-slate-300"> <p class="text-sm text-slate-600 dark:text-slate-300">
Olá, <strong class="text-slate-800 dark:text-slate-100">{{ contribuinteNome }}</strong>. Enviaremos um código de verificação. Escolha como prefere receber:
</p>
<p class="text-sm text-slate-600 dark:text-slate-300">
Vamos criar seu acesso ao portal. Ao confirmar, você receberá um e-mail
<template v-if="emailMascarado">
para <strong class="text-slate-800 dark:text-slate-100">{{ emailMascarado }}</strong>
</template>
com um link para definir sua senha.
</p> </p>
</div> </div>
<div class="space-y-2">
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl p-4 flex gap-3"> <button
<i class="pi pi-envelope text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" aria-hidden="true" /> v-for="canal in canais"
<p class="text-sm text-blue-700 dark:text-blue-300"> :key="canal.tipo"
O link de acesso é enviado pelo Keycloak e expira em 24 horas. Verifique também a caixa de spam. class="w-full flex items-center gap-3 p-4 rounded-xl border transition-colors text-left"
</p> :class="canalSelecionado?.tipo === canal.tipo
? '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'"
@click="canalSelecionado = canal"
>
<div class="w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0"
:class="canalSelecionado?.tipo === canal.tipo ? 'bg-primary text-white' : 'bg-slate-100 dark:bg-slate-700 text-slate-500 dark:text-slate-400'">
<i :class="['pi', iconeCanal[canal.tipo] ?? 'pi-send', 'text-sm']" aria-hidden="true" />
</div>
<div>
<p class="font-semibold text-slate-800 dark:text-slate-100 text-sm">{{ labelCanal[canal.tipo] ?? canal.tipo }}</p>
<p class="text-xs text-slate-500 dark:text-slate-400">{{ canal.valor }}</p>
</div>
</button>
</div> </div>
<p v-if="erro" role="alert" class="text-sm text-red-600 dark:text-red-400 flex items-center gap-1.5"> <p v-if="erro" role="alert" class="text-sm text-red-600 dark:text-red-400 flex items-center gap-1.5">
<i class="pi pi-exclamation-circle" aria-hidden="true" /> {{ erro }} <i class="pi pi-exclamation-circle" aria-hidden="true" /> {{ erro }}
</p> </p>
<div class="flex gap-3"> <div class="flex gap-3">
<Button label="Voltar" severity="secondary" outlined class="flex-1" :disabled="carregando" @click="etapa = 0" /> <Button label="Voltar" severity="secondary" outlined class="flex-1" @click="etapa = 0" />
<Button <Button label="Enviar código" icon="pi pi-send" class="flex-1" :loading="carregando" :disabled="!canalSelecionado" @click="enviarCodigo" />
label="Solicitar acesso"
icon="pi pi-send"
class="flex-1"
:loading="carregando"
:disabled="carregando"
@click="solicitar"
/>
</div> </div>
</template> </template>
<template v-else-if="etapa === 2">
<p class="text-sm text-slate-600 dark:text-slate-300">
Enviamos um código de 6 dígitos para
<strong class="text-slate-800 dark:text-slate-100">{{ canalSelecionado?.valor }}</strong>.
Ele expira em 10 minutos.
</p>
<div>
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">Código de verificação</label>
<InputOtp v-model="codigo" :length="6" class="justify-center gap-2" integer-only @keyup.enter="validarCodigo" />
</div>
<p v-if="erro" role="alert" class="text-sm text-red-600 dark:text-red-400 flex items-center gap-1.5">
<i class="pi pi-exclamation-circle" aria-hidden="true" /> {{ erro }}
</p>
<Button label="Verificar código" class="w-full" size="large" :loading="carregando" :disabled="codigo.length < 6" @click="validarCodigo" />
<button class="w-full text-center text-sm text-primary hover:underline" @click="enviarCodigo">
Não recebi o código reenviar
</button>
</template>
<template v-else-if="etapa === 3">
<p class="text-sm text-slate-600 dark:text-slate-300">
Crie uma senha segura com pelo menos 8 caracteres.
</p>
<div class="space-y-4">
<div>
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">Nova senha</label>
<Password v-model="senha" :feedback="true" toggle-mask placeholder="Mínimo 8 caracteres" class="w-full" input-class="w-full" size="large" />
</div>
<div>
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">Confirmar senha</label>
<Password v-model="senhaConfirm" :feedback="false" toggle-mask placeholder="Repita a senha" class="w-full" input-class="w-full" size="large" :invalid="senhaConfirm.length > 0 && !senhasIguais" />
<p v-if="senhaConfirm.length > 0 && !senhasIguais" class="mt-1 text-xs text-red-600 dark:text-red-400">As senhas não coincidem.</p>
</div>
</div>
<p v-if="erro" role="alert" class="text-sm text-red-600 dark:text-red-400 flex items-center gap-1.5">
<i class="pi pi-exclamation-circle" aria-hidden="true" /> {{ erro }}
</p>
<Button label="Criar senha" icon="pi pi-check" class="w-full" size="large" :loading="carregando" :disabled="!senhaForte || !senhasIguais" @click="definirSenha" />
</template>
<template v-else> <template v-else>
<div class="text-center py-4"> <div class="text-center py-4">
<div class="w-16 h-16 bg-emerald-100 dark:bg-emerald-900/30 rounded-2xl flex items-center justify-center mx-auto mb-4"> <div class="w-16 h-16 bg-emerald-100 dark:bg-emerald-900/30 rounded-2xl flex items-center justify-center mx-auto mb-4">
<i class="pi pi-check-circle text-emerald-600 dark:text-emerald-400 text-3xl" aria-hidden="true" /> <i class="pi pi-check-circle text-emerald-600 dark:text-emerald-400 text-3xl" aria-hidden="true" />
</div> </div>
<h2 class="text-lg font-bold text-slate-800 dark:text-slate-100 mb-2">E-mail enviado!</h2> <h2 class="text-lg font-bold text-slate-800 dark:text-slate-100 mb-2">Senha criada com sucesso!</h2>
<p class="text-sm text-slate-500 dark:text-slate-400"> <p class="text-sm text-slate-500 dark:text-slate-400">
Acesse o e-mail cadastrado e clique no link para definir sua senha e acessar o portal. Você pode acessar o portal com seu CPF/CNPJ e a nova senha.
</p>
<p v-if="emailMascarado" class="mt-2 text-sm font-medium text-slate-700 dark:text-slate-300">
{{ emailMascarado }}
</p> </p>
</div> </div>
<Button <Button label="Ir para o login" icon="pi pi-sign-in" class="w-full" size="large" :loading="carregando" @click="entrarKeycloak" />
label="Ir para o login"
icon="pi pi-sign-in"
class="w-full"
size="large"
@click="router.push('/')"
/>
</template> </template>
</div> </div>
</div> </div>
<div v-if="etapa < 2" class="text-center mt-4"> <div v-if="etapa < 4" class="text-center mt-4">
<button <button
class="inline-flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 transition-colors py-3 px-4 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800" class="inline-flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 transition-colors py-3 px-4 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800"
@click="router.push('/')" @click="router.push('/')"

View File

@ -1,153 +1,32 @@
<script setup> <script setup>
import { ref, computed, watch } from 'vue' import { ref, computed } 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'
import { validarCpf, validarCnpj, formatDate } from '@/utils/formatador'
const router = useRouter() const router = useRouter()
const route = useRoute()
const { isAuthenticated, documento: docUsuarioLogado, nomeUsuario } = useAuth() const { isAuthenticated, documento: docUsuarioLogado, nomeUsuario } = useAuth()
const voltarPara = computed(() =>
route.query.from === 'portal' ? '/portal/certidoes' : '/servicos'
)
const labelVoltar = computed(() =>
route.query.from === 'portal' ? 'Voltar às certidões' : 'Voltar aos serviços'
)
const documento = ref(isAuthenticated.value ? docUsuarioLogado.value : '') const documento = ref(isAuthenticated.value ? docUsuarioLogado.value : '')
const idModeloSelecionado = ref(null) const tipoCertidao = ref('negativa')
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 certidaoEmitida = ref(null)
const certidaoExistente = ref(null)
const carregandoDownloadExistente = ref(false)
const mensagemErro = ref('') const mensagemErro = ref('')
const erros = ref({})
const schemaEmissao = z.object({ const tiposCertidao = [
idModeloSelecionado: z { value: 'negativa', label: 'Certidão Negativa', descricao: 'Confirma que não há débitos pendentes.' },
.number({ required_error: 'Selecione o modelo de certidão.' }) { value: 'positiva_efeitos_negativa', label: 'Positiva com Efeitos de Negativa', descricao: 'Débitos com parcelamento em dia ou com exigibilidade suspensa.' },
.nullable() { value: 'positiva', label: 'Certidão Positiva', descricao: 'Confirma a existência de débitos.' },
.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, '')
if (d.length === 11) return validarCpf(d) return d.length === 11 || d.length === 14
if (d.length === 14) return validarCnpj(d)
return false
}) })
const docInvalido = computed(() => {
const d = documento.value.replace(/\D/g, '')
if (d.length === 11) return !validarCpf(d)
if (d.length === 14) return !validarCnpj(d)
return false
})
const modeloSelecionado = computed(() =>
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?.data?.description ?? e?.statusMessage ?? null
}
const COD_CERTIDAO_VALIDA_JA_EXISTE = '214'
// Respostas de erro do endpoint de emissão chegam como ArrayBuffer (responseType
// é 'arrayBuffer' para o PDF). Decodifica o envelope JSON para ler o StandardError.
function lerEnvelopeErro(e) {
let body = e?.data
if (body instanceof ArrayBuffer) {
try {
body = JSON.parse(new TextDecoder().decode(body))
} catch {
return null
}
}
return body ?? 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 {
@ -155,104 +34,34 @@ async function consultar() {
resultado.value = res.data resultado.value = res.data
etapa.value = 'resultado' etapa.value = 'resultado'
} catch (e) { } catch (e) {
mensagemErro.value = extrairErro(e) ?? 'Não foi possível consultar a situação fiscal. Tente novamente.' mensagemErro.value = e?.data?.description ?? '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 resValida = await certidaoService.verificarValida(documento.value, idModeloSelecionado.value) const buf = await certidaoService.emitir(documento.value, tipoCertidao.value)
const existente = resValida?.data?.data ?? resValida?.data const url = URL.createObjectURL(new Blob([buf], { type: 'application/pdf' }))
if (existente?.id != null) { const a = document.createElement('a')
certidaoExistente.value = existente a.href = url
etapa.value = 'certidaoExistente' a.download = `certidao-${tipoCertidao.value}-${documento.value}.pdf`
carregandoEmissao.value = false a.click()
return URL.revokeObjectURL(url)
}
} catch { } catch {
// Pré-checagem indisponível a emissão é bloqueada de forma autoritativa mensagemErro.value = 'Erro ao gerar o PDF. Tente novamente.'
// pelo backend, que devolve o erro CERTIDAO_VALIDA_JA_EXISTE tratado abaixo.
}
try {
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 a = document.createElement('a')
a.href = url
a.download = `certidao-${slug}-${documento.value.replace(/\D/g, '')}.pdf`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
setTimeout(() => URL.revokeObjectURL(url), 1000)
if (isAuthenticated.value || route.query.from === 'portal') {
router.push('/portal/certidoes')
} else {
certidaoEmitida.value = {
titulo: modeloSelecionado.value?.titulo,
finalidade: finalidade.value.trim(),
validade: validadeLabel.value,
nomeContribuinte: resultado.value?.nomeContribuinte ?? documento.value,
}
etapa.value = 'sucesso'
}
} catch (e) {
const erroBackend = lerEnvelopeErro(e)?.data
const existente = erroBackend?.response
if (erroBackend?.internalCode === COD_CERTIDAO_VALIDA_JA_EXISTE && existente?.id != null) {
certidaoExistente.value = existente
etapa.value = 'certidaoExistente'
return
}
mensagemErro.value = erroBackend?.description ?? extrairErro(e) ?? 'Erro ao gerar o PDF. Tente novamente.'
} finally { } finally {
carregandoEmissao.value = false carregandoEmissao.value = false
} }
} }
async function downloadCertidaoExistente() {
carregandoDownloadExistente.value = true
mensagemErro.value = ''
try {
const buf = await certidaoService.downloadExistente(certidaoExistente.value.id)
const slug = (certidaoExistente.value?.tituloModelo ?? 'certidao').replace(/\s+/g, '-').toLowerCase()
const url = URL.createObjectURL(new Blob([buf], { type: 'application/pdf' }))
const a = document.createElement('a')
a.href = url
a.download = `certidao-${slug}-${documento.value.replace(/\D/g, '')}.pdf`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
setTimeout(() => URL.revokeObjectURL(url), 1000)
} catch (e) {
mensagemErro.value = extrairErro(e) ?? 'Erro ao baixar a certidão. Tente novamente.'
} finally {
carregandoDownloadExistente.value = false
}
}
function reiniciar() { function reiniciar() {
documento.value = isAuthenticated.value ? docUsuarioLogado.value : '' documento.value = ''
idModeloSelecionado.value = null
finalidade.value = ''
resultado.value = null resultado.value = null
certidaoEmitida.value = null
certidaoExistente.value = null
mensagemErro.value = '' mensagemErro.value = ''
erros.value = {}
etapa.value = 'formulario' etapa.value = 'formulario'
resetModelos()
if (docValido.value) carregarModelos()
} }
</script> </script>
@ -261,10 +70,10 @@ function reiniciar() {
<button <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 mb-8 py-1" 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 mb-8 py-1"
@click="router.push(voltarPara)" @click="router.push('/servicos')"
> >
<i class="pi pi-arrow-left text-xs" aria-hidden="true" /> <i class="pi pi-arrow-left text-xs" aria-hidden="true" />
{{ labelVoltar }} Voltar aos serviços
</button> </button>
<div class="flex items-center gap-4 mb-8"> <div class="flex items-center gap-4 mb-8">
@ -288,52 +97,30 @@ function reiniciar() {
:disabled="isAuthenticated" :disabled="isAuthenticated"
@keyup.enter="consultar" @keyup.enter="consultar"
/> />
<p v-if="docInvalido && !isAuthenticated" class="mt-1.5 text-xs text-red-600 dark:text-red-400 flex items-center gap-1.5"> <p v-if="isAuthenticated" class="mt-1.5 text-xs text-slate-500 dark:text-slate-400 flex items-center gap-1.5">
<i class="pi pi-exclamation-circle" aria-hidden="true" /> <i class="pi pi-info-circle text-xs text-primary" aria-hidden="true" />
CPF ou CNPJ inválido. Logado como <strong class="text-slate-700 dark:text-slate-200">{{ nomeUsuario }}</strong> documento bloqueado para segurança.
</p>
<p v-if="isAuthenticated" class="mt-1.5 text-xs text-slate-500 dark:text-slate-400 flex items-start gap-1.5">
<i class="pi pi-info-circle text-xs text-primary mt-0.5 shrink-0" aria-hidden="true" />
<span>Logado como <strong class="text-slate-700 dark:text-slate-200">{{ nomeUsuario }}</strong> documento bloqueado para segurança.</span>
</p> </p>
</div> </div>
<div v-if="docValido"> <div>
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5"> <p class="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-3">Tipo de certidão</p>
Modelo de certidão <div class="space-y-2">
</label> <label
<Select v-for="tipo in tiposCertidao"
v-model="idModeloSelecionado" :key="tipo.value"
:options="modelos" class="flex items-start gap-3 p-4 rounded-xl border cursor-pointer transition-colors"
option-label="titulo" :class="tipoCertidao === tipo.value
option-value="id" ? 'border-primary bg-primary/5 dark:bg-primary/10'
placeholder="Selecione o modelo de certidão" : 'border-slate-200 dark:border-slate-600 hover:border-slate-300 dark:hover:border-slate-500'"
class="w-full" >
:class="{ 'p-invalid': erros.idModeloSelecionado }" <RadioButton v-model="tipoCertidao" :value="tipo.value" :input-id="tipo.value" class="mt-0.5 flex-shrink-0" />
:loading="carregandoModelos" <div>
:disabled="carregandoModelos || modelos.length === 0" <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>
<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> </label>
<InputText </div>
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">
@ -347,7 +134,7 @@ function reiniciar() {
class="w-full" class="w-full"
size="large" size="large"
:loading="carregandoConsulta" :loading="carregandoConsulta"
:disabled="!docValido || carregandoModelos || modelos.length === 0" :disabled="!docValido"
@click="consultar" @click="consultar"
/> />
</div> </div>
@ -388,13 +175,10 @@ 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">
{{ modeloSelecionado?.titulo ?? '—' }} {{ tiposCertidao.find(t => t.value === tipoCertidao)?.label }}
</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">{{ validadeLabel }}</span> Validade: <span class="font-medium">180 dias a partir da emissão</span>
</p> </p>
</div> </div>
@ -417,126 +201,10 @@ function reiniciar() {
icon="pi pi-download" icon="pi pi-download"
class="flex-1" class="flex-1"
:loading="carregandoEmissao" :loading="carregandoEmissao"
:disabled="carregandoEmissao"
@click="emitir" @click="emitir"
/> />
</div> </div>
</div> </div>
<div v-else-if="etapa === 'certidaoExistente'" class="space-y-4">
<div class="bg-white dark:bg-slate-800 rounded-2xl border border-amber-200 dark:border-amber-700/50 p-8 flex flex-col items-center text-center gap-4">
<div class="w-16 h-16 bg-amber-50 dark:bg-amber-900/20 rounded-2xl flex items-center justify-center">
<i class="pi pi-exclamation-triangle text-amber-500 dark:text-amber-400 text-3xl" aria-hidden="true" />
</div>
<div>
<p class="text-lg font-bold text-slate-800 dark:text-slate-100"> existe uma certidão válida</p>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">
Existe uma certidão do mesmo tipo ainda dentro do prazo de validade.<br>
Você pode baixar a certidão existente em vez de emitir uma nova.
</p>
</div>
</div>
<div class="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 p-6 space-y-3">
<p class="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">Certidão existente</p>
<div class="space-y-2">
<div class="flex justify-between gap-4">
<span class="text-sm text-slate-500 dark:text-slate-400">Modelo</span>
<span class="text-sm font-medium text-slate-800 dark:text-slate-100 text-right">{{ certidaoExistente?.tituloModelo }}</span>
</div>
<div class="flex justify-between gap-4">
<span class="text-sm text-slate-500 dark:text-slate-400"> certidão</span>
<span class="text-sm font-medium text-slate-800 dark:text-slate-100 text-right">{{ certidaoExistente?.numero }}</span>
</div>
<div class="flex justify-between gap-4">
<span class="text-sm text-slate-500 dark:text-slate-400">Válida até</span>
<span class="text-sm font-semibold text-emerald-600 dark:text-emerald-400 text-right">{{ formatDate(certidaoExistente?.dataValidade) }}</span>
</div>
</div>
</div>
<p v-if="mensagemErro" role="alert" class="text-sm text-red-600 dark:text-red-400 flex items-center gap-1.5">
<i class="pi pi-exclamation-circle" aria-hidden="true" />
{{ mensagemErro }}
</p>
<div class="flex gap-3">
<Button
label="Nova consulta"
severity="secondary"
icon="pi pi-arrow-left"
outlined
class="flex-1"
@click="reiniciar"
/>
<Button
label="Baixar certidão"
icon="pi pi-download"
class="flex-1"
:loading="carregandoDownloadExistente"
:disabled="carregandoDownloadExistente"
@click="downloadCertidaoExistente"
/>
</div>
</div>
<div v-else-if="etapa === 'sucesso'" class="space-y-4">
<div class="bg-white dark:bg-slate-800 rounded-2xl border border-emerald-200 dark:border-emerald-700/50 p-8 flex flex-col items-center text-center gap-4">
<div class="w-16 h-16 bg-emerald-100 dark:bg-emerald-900/30 rounded-2xl flex items-center justify-center">
<i class="pi pi-check-circle text-emerald-600 dark:text-emerald-400 text-3xl" aria-hidden="true" />
</div>
<div>
<p class="text-lg font-bold text-slate-800 dark:text-slate-100">Certidão emitida com sucesso!</p>
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">
O PDF foi baixado automaticamente para o seu dispositivo.
</p>
</div>
</div>
<div class="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 p-6 space-y-3">
<p class="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">Detalhes da certidão emitida</p>
<div class="space-y-2">
<div class="flex justify-between gap-4">
<span class="text-sm text-slate-500 dark:text-slate-400">Contribuinte</span>
<span class="text-sm font-medium text-slate-800 dark:text-slate-100 text-right">{{ certidaoEmitida?.nomeContribuinte }}</span>
</div>
<div class="flex justify-between gap-4">
<span class="text-sm text-slate-500 dark:text-slate-400">Modelo</span>
<span class="text-sm font-medium text-slate-800 dark:text-slate-100 text-right">{{ certidaoEmitida?.titulo }}</span>
</div>
<div v-if="certidaoEmitida?.finalidade" class="flex justify-between gap-4">
<span class="text-sm text-slate-500 dark:text-slate-400">Finalidade</span>
<span class="text-sm font-medium text-slate-800 dark:text-slate-100 text-right">{{ certidaoEmitida.finalidade }}</span>
</div>
<div class="flex justify-between gap-4">
<span class="text-sm text-slate-500 dark:text-slate-400">Validade</span>
<span class="text-sm font-medium text-slate-800 dark:text-slate-100 text-right">{{ certidaoEmitida?.validade }}</span>
</div>
</div>
</div>
<div class="flex gap-3">
<Button
label="Emitir nova certidão"
severity="secondary"
icon="pi pi-refresh"
outlined
class="flex-1"
@click="reiniciar"
/>
<Button
label="Voltar aos serviços"
icon="pi pi-home"
class="flex-1"
severity="secondary"
@click="router.push('/servicos')"
/>
</div>
</div>
</div> </div>
</template> </template>

View File

@ -4,16 +4,8 @@ import { useAuth } from '@/composables/useAuth'
import { iptuService } from '@/services/iptuService' import { iptuService } from '@/services/iptuService'
const router = useRouter() const router = useRouter()
const route = useRoute()
const { isAuthenticated, documento: docUsuarioLogado, nomeUsuario } = useAuth() const { isAuthenticated, documento: docUsuarioLogado, nomeUsuario } = useAuth()
const voltarPara = computed(() =>
route.query.from === 'portal' ? '/portal/debitos' : '/servicos'
)
const labelVoltar = computed(() =>
route.query.from === 'portal' ? 'Voltar aos débitos' : 'Voltar aos serviços'
)
const modoConsulta = ref('documento') const modoConsulta = ref('documento')
const documento = ref(isAuthenticated.value ? docUsuarioLogado.value : '') const documento = ref(isAuthenticated.value ? docUsuarioLogado.value : '')
const inscricao = ref('') const inscricao = ref('')
@ -37,7 +29,7 @@ async function consultar() {
? await iptuService.consultarPorDocumento(documento.value) ? await iptuService.consultarPorDocumento(documento.value)
: await iptuService.consultarPorInscricao(inscricao.value) : await iptuService.consultarPorInscricao(inscricao.value)
imoveis.value = res.data?.content ?? res.data ?? [] imoveis.value = res.data ?? []
if (imoveis.value.length === 1) imovelSelecionado.value = imoveis.value[0] if (imoveis.value.length === 1) imovelSelecionado.value = imoveis.value[0]
etapa.value = 'resultado' etapa.value = 'resultado'
} catch (e) { } catch (e) {
@ -100,10 +92,10 @@ function formatarMoeda(valor) {
<button <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 mb-8 py-1" 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 mb-8 py-1"
@click="router.push(voltarPara)" @click="router.push('/servicos')"
> >
<i class="pi pi-arrow-left text-xs" aria-hidden="true" /> <i class="pi pi-arrow-left text-xs" aria-hidden="true" />
{{ labelVoltar }} Voltar aos serviços
</button> </button>
<div class="flex items-center gap-4 mb-8"> <div class="flex items-center gap-4 mb-8">
@ -164,9 +156,9 @@ function formatarMoeda(valor) {
size="large" size="large"
@keyup.enter="consultar" @keyup.enter="consultar"
/> />
<p v-if="modoConsulta === 'documento' && isAuthenticated" class="mt-1.5 text-xs text-slate-500 dark:text-slate-400 flex items-start gap-1.5"> <p v-if="modoConsulta === 'documento' && isAuthenticated" class="mt-1.5 text-xs text-slate-500 dark:text-slate-400 flex items-center gap-1.5">
<i class="pi pi-info-circle text-xs text-primary mt-0.5 shrink-0" aria-hidden="true" /> <i class="pi pi-info-circle text-xs text-primary" aria-hidden="true" />
<span>Logado como <strong class="text-slate-700 dark:text-slate-200">{{ nomeUsuario }}</strong> documento bloqueado para segurança.</span> Logado como <strong class="text-slate-700 dark:text-slate-200">{{ nomeUsuario }}</strong> documento bloqueado para segurança.
</p> </p>
</div> </div>
@ -188,19 +180,7 @@ function formatarMoeda(valor) {
<div v-else-if="etapa === 'resultado'" class="space-y-4"> <div v-else-if="etapa === 'resultado'" class="space-y-4">
<div v-if="imoveis.length === 0" class="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 p-8 text-center"> <div v-if="imoveis.length > 1" class="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 p-6">
<div class="w-14 h-14 bg-primary/10 dark:bg-primary/20 rounded-full flex items-center justify-center mx-auto mb-4">
<i class="pi pi-search text-primary text-2xl" aria-hidden="true" />
</div>
<p class="text-base font-semibold text-slate-800 dark:text-slate-100 mb-1">Nenhum imóvel encontrado</p>
<p class="text-sm text-slate-500 dark:text-slate-400">
Não foram encontrados imóveis cadastrados para o
{{ modoConsulta === 'documento' ? 'documento informado' : 'número de inscrição informado' }}.
Verifique os dados e tente novamente.
</p>
</div>
<div v-else-if="imoveis.length > 1" class="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 p-6">
<p class="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-3"> <p class="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-3">
{{ imoveis.length }} imóveis encontrados selecione um {{ imoveis.length }} imóveis encontrados selecione um
</p> </p>
@ -233,7 +213,6 @@ function formatarMoeda(valor) {
size="small" size="small"
outlined outlined
:loading="carregandoPdf === `carne-${imovelSelecionado.inscricaoImobiliaria}`" :loading="carregandoPdf === `carne-${imovelSelecionado.inscricaoImobiliaria}`"
:disabled="!!carregandoPdf"
@click="emitirCarne(imovelSelecionado)" @click="emitirCarne(imovelSelecionado)"
/> />
</div> </div>
@ -260,7 +239,6 @@ function formatarMoeda(valor) {
text text
aria-label="Emitir boleto" aria-label="Emitir boleto"
:loading="carregandoPdf === `boleto-${debito.id}`" :loading="carregandoPdf === `boleto-${debito.id}`"
:disabled="!!carregandoPdf"
@click="emitirBoleto(debito)" @click="emitirBoleto(debito)"
/> />
</div> </div>

View File

@ -1,9 +0,0 @@
const FETCH_HEADERS = { 'X-Requested-With': 'fetch' }
export const avisoService = {
listar(dominio) {
return $fetch(`/api/proxy/publico/avisos/${dominio ?? 'portal'}`, {
headers: FETCH_HEADERS,
})
},
}

View File

@ -12,31 +12,10 @@ export const certidaoService = {
}) })
}, },
listarModelos(documento, params = {}) { emitir(documento, tipoCertidao) {
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, idModelo, finalidade }, query: { documento, tipoCertidao },
responseType: 'arrayBuffer',
})
},
verificarValida(documento, idModelo) {
return $fetch(proxyUrl('/publico/certidao/valida'), {
headers: FETCH_HEADERS,
query: { documento, idModelo },
})
},
downloadExistente(idCertidao) {
return $fetch(proxyUrl(`/publico/certidao/emitida/${idCertidao}/arquivo`), {
headers: FETCH_HEADERS,
responseType: 'arrayBuffer', responseType: 'arrayBuffer',
}) })
}, },

View File

@ -1,4 +1,6 @@
// 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' }
@ -6,15 +8,8 @@ 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 })
}, },
@ -26,45 +21,17 @@ export const portalService = {
}) })
}, },
// ─── Débitos / Extrato ─────────────────────────────────────────────────── // ─── Débitos ─────────────────────────────────────────────────────────────
getDebitosExtrato(params = {}) { getDebitos(params = {}) {
return $fetch(proxyUrl('/contribuinte/debitos'), { return $fetch(proxyUrl('/contribuinte/debitos'), {
headers: FETCH_HEADERS, headers: FETCH_HEADERS,
query: limparParams(params), query: params,
}) })
}, },
getTransacoes(idContaCorrente) { emitirGuia(idDebito) {
return $fetch(proxyUrl(`/contribuinte/debitos/${idContaCorrente}/transacoes`), { headers: FETCH_HEADERS }) return $fetch(proxyUrl(`/contribuinte/debitos/${idDebito}/guia`), {
},
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',
}) })
}, },
@ -81,44 +48,24 @@ export const portalService = {
}) })
}, },
// ─── Pagamentos / Extrato ───────────────────────────────────────────────── // ─── Alvarás ─────────────────────────────────────────────────────────────
getPagamentosExtrato(params = {}) { getAlvaras(params = {}) {
return $fetch(proxyUrl('/contribuinte/alvaras'), {
headers: FETCH_HEADERS,
query: params,
})
},
// ─── Pagamentos ──────────────────────────────────────────────────────────
getPagamentos(params = {}) {
return $fetch(proxyUrl('/contribuinte/pagamentos'), { return $fetch(proxyUrl('/contribuinte/pagamentos'), {
headers: FETCH_HEADERS, headers: FETCH_HEADERS,
query: limparParams(params), query: params,
}) })
}, },
gerarExtratoPagamentosPdf(dto) { getComprovante(idPagamento) {
return $fetch(proxyUrl('/contribuinte/pagamentos/extrato-pdf'), { return $fetch(proxyUrl(`/contribuinte/pagamentos/${idPagamento}/comprovante`), {
method: 'POST',
headers: FETCH_HEADERS,
body: dto,
responseType: 'arrayBuffer',
})
},
getComprovante(idContaCorrente) {
return $fetch(proxyUrl(`/contribuinte/pagamentos/${idContaCorrente}/comprovante`), {
headers: FETCH_HEADERS,
responseType: 'arrayBuffer',
})
},
// ─── Guias emitidas ──────────────────────────────────────────────────────
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',
}) })

View File

@ -12,15 +12,6 @@ export const primeiroAcessoService = {
}) })
}, },
solicitarAcesso(documento) {
return $fetch(proxyUrl('/publico/primeiro-acesso/solicitar'), {
method: 'POST',
headers: FETCH_HEADERS,
body: { documento },
})
},
// TODO Opção A (futuro — fluxo OTP para verificação de identidade antes de criar conta)
solicitarCodigo(documento, canal) { solicitarCodigo(documento, canal) {
return $fetch(proxyUrl('/publico/primeiro-acesso/codigo'), { return $fetch(proxyUrl('/publico/primeiro-acesso/codigo'), {
method: 'POST', method: 'POST',

View File

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

View File

@ -1,42 +0,0 @@
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 }
}

View File

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

View File

@ -1,76 +0,0 @@
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 }
export function validarCpf(cpf) {
const d = String(cpf).replace(/\D/g, '')
if (d.length !== 11 || /^(\d)\1{10}$/.test(d)) return false
const calc = (len) => {
let sum = 0
for (let i = 0; i < len; i++) sum += parseInt(d[i]) * (len + 1 - i)
const r = sum % 11
return r < 2 ? 0 : 11 - r
}
return calc(9) === parseInt(d[9]) && calc(10) === parseInt(d[10])
}
export function validarCnpj(cnpj) {
const d = String(cnpj).replace(/\D/g, '')
if (d.length !== 14 || /^(\d)\1{13}$/.test(d)) return false
const calc = (len) => {
const weights = len === 12
? [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]
: [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]
let sum = 0
for (let i = 0; i < len; i++) sum += parseInt(d[i]) * weights[i]
const r = sum % 11
return r < 2 ? 0 : 11 - r
}
return calc(12) === parseInt(d[12]) && calc(13) === parseInt(d[13])
}

View File

@ -1,38 +0,0 @@
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'
}
}