Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 11bc109e4b | |||
| 7fd1460bca | |||
| c6adda9bfb | |||
| 5e528f234b | |||
| e4c468e61e | |||
| 94d49e73a2 | |||
| 222b098a35 | |||
| a034446cf1 | |||
| a48eea53bc | |||
| fa557af73c | |||
| 425146d59a | |||
| 0c9930d8f7 | |||
| ccc428f7b5 | |||
| c650f5a28f | |||
| e282628952 | |||
| 3fb5cdb6a8 | |||
| b638af1a39 | |||
| 8b5c37abe9 | |||
| 4ee054f98f | |||
| f3e46cca4c |
@ -86,6 +86,39 @@ Consulta a situação fiscal do contribuinte.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
#### `GET /publico/certidao/modelos`
|
||||||
|
|
||||||
|
Lista modelos de certidão **públicos** disponíveis para o contribuinte, filtrados pelo `cadastro.tipo` (PF=1, PJ=2) — paridade com o combo do core.
|
||||||
|
|
||||||
|
**Query params:**
|
||||||
|
| Param | Tipo | Obrigatório | Descrição |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `documento` | string | sim | CPF/CNPJ sem formatação — resolve o destinatário via cadastro |
|
||||||
|
| `titulo` | string | não | Filtro parcial no título do modelo |
|
||||||
|
| `page` | int | não | Página (default: 0) |
|
||||||
|
| `size` | int | não | Tamanho (default: 50) |
|
||||||
|
|
||||||
|
**Response `data`:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"paginasTotais": 1,
|
||||||
|
"elementosTotais": 2,
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"titulo": "Certidão Negativa de Débitos",
|
||||||
|
"descricaoTipoCertidao": "Certidão Negativa",
|
||||||
|
"validadeDias": 180,
|
||||||
|
"destinatario": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Filtros aplicados no backend: `destinatario` = tipo do cadastro · `publico=1` · vigência válida · modelo com arquivo DOCX anexado.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
#### `GET /publico/certidao/emitir`
|
#### `GET /publico/certidao/emitir`
|
||||||
|
|
||||||
Emite a certidão em PDF.
|
Emite a certidão em PDF.
|
||||||
@ -94,7 +127,8 @@ Emite a certidão em PDF.
|
|||||||
| Param | Tipo | Obrigatório | Descrição |
|
| Param | Tipo | Obrigatório | Descrição |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `documento` | string | sim | CPF/CNPJ sem formatação |
|
| `documento` | string | sim | CPF/CNPJ sem formatação |
|
||||||
| `tipoCertidao` | string | sim | `NEGATIVA` · `POSITIVA` · `POSITIVA_EFEITOS_NEGATIVA` |
|
| `idModelo` | long | sim | ID do modelo selecionado em `/modelos` |
|
||||||
|
| `finalidade` | string | não | Finalidade informada pelo contribuinte (default: "Emissão pelo portal público") |
|
||||||
|
|
||||||
**Response:** `application/pdf` (binário direto, sem envelope)
|
**Response:** `application/pdf` (binário direto, sem envelope)
|
||||||
|
|
||||||
@ -396,45 +430,59 @@ Lista das últimas atividades do contribuinte.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Débitos
|
### Débitos (Extrato)
|
||||||
|
|
||||||
#### `GET /contribuinte/debitos`
|
#### `GET /contribuinte/debitos`
|
||||||
|
|
||||||
Lista os débitos do contribuinte com filtros opcionais.
|
Lista débitos agrupados por conta/tributo (`ContaCorrenteTributoDTO`), com os mesmos filtros do extrato interno.
|
||||||
|
|
||||||
**Query params:**
|
**Query params:** `idTaxa`, `idContaTributo`, `periodoIni` (YYYYMM), `periodoFim` (YYYYMM), `inscMunicipal`, `idEstadoConta` (1=débito, 2=zero, 3=crédito)
|
||||||
| Param | Tipo | Obrigatório | Descrição |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `tipo` | string | não | `IPTU` · `ISS` · `TAXA` · `MULTA` |
|
|
||||||
| `status` | string | não | `VENCIDO` · `A_VENCER` · `PARCELADO` |
|
|
||||||
|
|
||||||
**Response `data`:**
|
**Response `data`:** array de `ContaCorrenteTributoDTO` com lista `debitos` aninhada.
|
||||||
```json
|
|
||||||
{
|
|
||||||
"content": [
|
|
||||||
{
|
|
||||||
"id": "deb1",
|
|
||||||
"descricao": "IPTU 2025 — Cota 4/10",
|
|
||||||
"tipo": "IPTU",
|
|
||||||
"referencia": "ABR/2025",
|
|
||||||
"vencimento": "30/04/2025",
|
|
||||||
"valor": 125.90,
|
|
||||||
"valorAtualizado": 138.49,
|
|
||||||
"status": "VENCIDO"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`valorAtualizado` inclui juros e multa (pode ser igual a `valor` se em dia).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### `GET /contribuinte/debitos/{id}/guia`
|
#### `GET /contribuinte/debitos/tributos/{codigo}`
|
||||||
|
|
||||||
Emite a guia de pagamento (boleto/DAM) de um débito específico em PDF.
|
Busca tributo por código para filtros.
|
||||||
|
|
||||||
**Path param:** `id` — ID do débito
|
---
|
||||||
|
|
||||||
|
#### `GET /contribuinte/debitos/contas-tributo/{codigo}`
|
||||||
|
|
||||||
|
Busca conta tributo por código para filtros.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `GET /contribuinte/debitos/{idContaCorrente}/transacoes`
|
||||||
|
|
||||||
|
Transações da conta corrente (somente se pertencer ao contribuinte logado).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `POST /contribuinte/debitos/gerar-guia`
|
||||||
|
|
||||||
|
Gera guia para débitos selecionados. Body: `GerarGuiaDebitosRequestDTO`.
|
||||||
|
|
||||||
|
**Response `data`:** `GerarGuiaResponseDTO` (`idDoctoArr`, `numeroGuia`, etc.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `GET /contribuinte/debitos/guia/{idDoctoArr}`
|
||||||
|
|
||||||
|
PDF de guia já emitida (validação de ownership).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `GET /contribuinte/debitos/{idContaCorrente}/guia`
|
||||||
|
|
||||||
|
Gera e retorna PDF de guia para um único débito.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `POST /contribuinte/debitos/extrato-pdf`
|
||||||
|
|
||||||
|
Gera PDF do extrato de débitos. Body: `GerarExtratoDebitosRequestDTO`.
|
||||||
|
|
||||||
**Response:** `application/pdf`
|
**Response:** `application/pdf`
|
||||||
|
|
||||||
@ -514,47 +562,58 @@ Lista os processos de alvará do contribuinte.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Pagamentos
|
### Pagamentos (Extrato)
|
||||||
|
|
||||||
#### `GET /contribuinte/pagamentos`
|
#### `GET /contribuinte/pagamentos`
|
||||||
|
|
||||||
Histórico de pagamentos do contribuinte, filtrável por ano.
|
Lista pagamentos agrupados por tributo (`ExtratoPagamentoTributoDTO`).
|
||||||
|
|
||||||
**Query params:**
|
**Query params:** `idTaxa`, `idContaTributo`, `pagInicio`, `pagFim`, `periodoIni`, `periodoFim`, `ano` (atalho para intervalo anual)
|
||||||
| Param | Tipo | Obrigatório | Descrição |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `ano` | integer | não | Ano de referência (padrão: ano atual) |
|
|
||||||
|
|
||||||
**Response `data`:**
|
**Response `data`:** array com `pagamentos` aninhados (principal, multa, juros, desconto, total).
|
||||||
```json
|
|
||||||
{
|
|
||||||
"content": [
|
|
||||||
{
|
|
||||||
"id": "pag1",
|
|
||||||
"descricao": "IPTU 2025 — Cota 3/10",
|
|
||||||
"referencia": "MAR/2025",
|
|
||||||
"dataPagamento": "28/03/2025",
|
|
||||||
"formaPagamento": "PIX",
|
|
||||||
"valor": 125.90
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`formaPagamento` possíveis: `BOLETO` · `PIX` · `CARTAO` · `TRANSFERENCIA` · `ESPECIE`
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
#### `GET /contribuinte/pagamentos/{id}/comprovante`
|
#### `POST /contribuinte/pagamentos/extrato-pdf`
|
||||||
|
|
||||||
Baixa o comprovante de um pagamento em PDF.
|
Gera PDF do extrato de pagamentos. Body: `GerarExtratoPagamentosRequestDTO`.
|
||||||
|
|
||||||
**Path param:** `id` — ID do pagamento
|
|
||||||
|
|
||||||
**Response:** `application/pdf`
|
**Response:** `application/pdf`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
#### `GET /contribuinte/pagamentos/{idContaCorrente}/comprovante`
|
||||||
|
|
||||||
|
Baixa comprovante de pagamento em PDF (quando houver `LancamentoTaxa` vinculado).
|
||||||
|
|
||||||
|
**Response:** `application/pdf` ou `404`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Guias Emitidas
|
||||||
|
|
||||||
|
#### `GET /contribuinte/guias`
|
||||||
|
|
||||||
|
Lista guias do contribuinte logado (CPF/CNPJ injetado no backend). Paginado.
|
||||||
|
|
||||||
|
**Query params:** `numeroGuia`, `dataEmissaoInicio/Fim`, `dataVencimentoInicio/Fim`, `status`, `valorMinimo`, `valorMaximo`, `page`, `size`
|
||||||
|
|
||||||
|
**Response `data`:** `PageDTO<GuiaConsultaDTO>`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `GET /contribuinte/guias/{id}`
|
||||||
|
|
||||||
|
Detalhes completos da guia (`GuiaPagamentoDTO`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `GET /contribuinte/guias/{id}/pdf`
|
||||||
|
|
||||||
|
PDF da guia.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Dados Cadastrais
|
### Dados Cadastrais
|
||||||
|
|
||||||
#### `GET /contribuinte/dados`
|
#### `GET /contribuinte/dados`
|
||||||
@ -673,8 +732,9 @@ O `preferred_username` do JWT é o CPF/CNPJ sem formatação — usado como iden
|
|||||||
| Endpoint | Frontend pronto | Backend pronto | Mock |
|
| Endpoint | Frontend pronto | Backend pronto | Mock |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `GET /publico/prefeitura/{dominio}` | ✓ | ✓ | — |
|
| `GET /publico/prefeitura/{dominio}` | ✓ | ✓ | — |
|
||||||
| `GET /publico/certidao/consultar` | ✓ | pendente | ✓ |
|
| `GET /publico/certidao/consultar` | ✓ | ✓ | — |
|
||||||
| `GET /publico/certidao/emitir` | ✓ | pendente | ✓ |
|
| `GET /publico/certidao/modelos` | ✓ | ✓ | — |
|
||||||
|
| `GET /publico/certidao/emitir` | ✓ | ✓ | — |
|
||||||
| `GET /publico/iptu/consultar` | ✓ | pendente | ✓ |
|
| `GET /publico/iptu/consultar` | ✓ | pendente | ✓ |
|
||||||
| `GET /publico/iptu/carne` | ✓ | pendente | ✓ |
|
| `GET /publico/iptu/carne` | ✓ | pendente | ✓ |
|
||||||
| `GET /publico/iptu/boleto` | ✓ | pendente | ✓ |
|
| `GET /publico/iptu/boleto` | ✓ | pendente | ✓ |
|
||||||
|
|||||||
40
package-lock.json
generated
40
package-lock.json
generated
@ -1686,24 +1686,6 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@nuxt/schema": {
|
|
||||||
"version": "4.4.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@nuxt/schema/-/schema-4.4.6.tgz",
|
|
||||||
"integrity": "sha512-7FDMuD+skbFMgfF2ORYKEAKEuEFbu2oS60dln5uVtn94c8DHWCseJSrT3FUHzVUlVwyhztPU6stzB44dEoWAzw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@vue/shared": "^3.5.34",
|
|
||||||
"defu": "^6.1.7",
|
|
||||||
"pathe": "^2.0.3",
|
|
||||||
"pkg-types": "^2.3.1",
|
|
||||||
"std-env": "^4.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^14.18.0 || >=16.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@nuxt/telemetry": {
|
"node_modules/@nuxt/telemetry": {
|
||||||
"version": "2.8.0",
|
"version": "2.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/@nuxt/telemetry/-/telemetry-2.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/@nuxt/telemetry/-/telemetry-2.8.0.tgz",
|
||||||
@ -5587,17 +5569,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/commander": {
|
|
||||||
"version": "13.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz",
|
|
||||||
"integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/commondir": {
|
"node_modules/commondir": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
|
||||||
@ -7940,7 +7911,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -7961,7 +7931,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -7982,7 +7951,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -8003,7 +7971,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -8024,7 +7991,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -8045,7 +8011,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -8066,7 +8031,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -8087,7 +8051,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -8108,7 +8071,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -8129,7 +8091,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@ -8150,7 +8111,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|||||||
BIN
public/brasao.png
Normal file
BIN
public/brasao.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
@ -3,6 +3,8 @@ 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) => {
|
||||||
@ -14,11 +16,16 @@ 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({
|
||||||
@ -26,6 +33,8 @@ 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 }
|
||||||
|
|||||||
@ -46,6 +46,8 @@ 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',
|
||||||
@ -56,17 +58,48 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
setResponseStatus(event, res.status)
|
setResponseStatus(event, res.status)
|
||||||
|
const skipResponseHeaders = new Set(['transfer-encoding', 'content-encoding', 'content-length'])
|
||||||
for (const [name, value] of res.headers.entries()) {
|
for (const [name, value] of res.headers.entries()) {
|
||||||
if (name === 'transfer-encoding') continue
|
if (skipResponseHeaders.has(name.toLowerCase())) continue
|
||||||
setResponseHeader(event, name, value)
|
setResponseHeader(event, name, value)
|
||||||
}
|
}
|
||||||
return res._data
|
return res._data
|
||||||
} 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) {
|
||||||
setResponseStatus(event, fetchErr.response.status ?? 500)
|
const status = fetchErr.response.status ?? 500
|
||||||
return fetchErr.response._data
|
const raw = 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)
|
||||||
}
|
}
|
||||||
throw err
|
const totalLen = chunks.reduce((n, c) => n + c.length, 0)
|
||||||
|
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.' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@ -18,6 +18,8 @@ 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({
|
||||||
@ -30,6 +32,8 @@ 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()}`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -47,6 +51,7 @@ export async function exchangeCodeForTokens(opts: {
|
|||||||
redirect_uri: opts.redirectUri,
|
redirect_uri: opts.redirectUri,
|
||||||
code_verifier: opts.codeVerifier,
|
code_verifier: opts.codeVerifier,
|
||||||
})
|
})
|
||||||
|
try {
|
||||||
return await $fetch<TokenResponse>(
|
return await $fetch<TokenResponse>(
|
||||||
`${realmBase()}/protocol/openid-connect/token`,
|
`${realmBase()}/protocol/openid-connect/token`,
|
||||||
{
|
{
|
||||||
@ -55,6 +60,13 @@ export async function exchangeCodeForTokens(opts: {
|
|||||||
body: body.toString(),
|
body: body.toString(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const fetchErr = err as { data?: { error?: string; error_description?: string }; message?: string }
|
||||||
|
const kcError = fetchErr.data?.error
|
||||||
|
const kcDesc = fetchErr.data?.error_description
|
||||||
|
const detail = kcDesc ?? kcError ?? fetchErr.message ?? 'erro desconhecido'
|
||||||
|
throw new Error(`Keycloak token: ${detail}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshTokens(refreshToken: string): Promise<TokenResponse> {
|
export async function refreshTokens(refreshToken: string): Promise<TokenResponse> {
|
||||||
|
|||||||
@ -17,6 +17,8 @@ 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 {
|
||||||
@ -25,6 +27,9 @@ 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 {
|
||||||
@ -35,7 +40,9 @@ 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
|
||||||
|
|
||||||
await useRedis().set(cacheKey, JSON.stringify(info), 'EX', CACHE_TTL_SECONDS)
|
if (redisAvailable) {
|
||||||
|
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)
|
||||||
|
|||||||
BIN
src/assets/images/brasao.png
Normal file
BIN
src/assets/images/brasao.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
@ -86,6 +86,7 @@ function onHide() {
|
|||||||
class="w-full"
|
class="w-full"
|
||||||
size="large"
|
size="large"
|
||||||
:loading="carregando"
|
:loading="carregando"
|
||||||
|
:disabled="carregando"
|
||||||
@click="entrar"
|
@click="entrar"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, onMounted } from 'vue'
|
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
const aberto = ref(false)
|
const aberto = ref(false)
|
||||||
const nivelFonte = ref(0)
|
const nivelFonte = ref(0)
|
||||||
@ -18,16 +18,38 @@ 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) => {
|
||||||
@ -45,7 +67,9 @@ 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>
|
||||||
|
|
||||||
|
|||||||
124
src/components/extrato/ModalDetalhesGuia.vue
Normal file
124
src/components/extrato/ModalDetalhesGuia.vue
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { portalService } from '@/services/portalService'
|
||||||
|
import { formatCurrency, formatDate, abrirPdf } from '@/utils/formatador'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visible: { type: Boolean, default: false },
|
||||||
|
guiaId: { type: Number, default: null },
|
||||||
|
statusCodigo: { type: Number, default: null },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:visible'])
|
||||||
|
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const isLoadingPdf = ref(false)
|
||||||
|
const guia = ref(null)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.visible, props.guiaId],
|
||||||
|
([vis, id]) => {
|
||||||
|
if (vis && id) carregarDetalhes()
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
async function carregarDetalhes() {
|
||||||
|
if (!props.guiaId) return
|
||||||
|
isLoading.value = true
|
||||||
|
guia.value = null
|
||||||
|
try {
|
||||||
|
const res = await portalService.buscarGuia(props.guiaId)
|
||||||
|
guia.value = res.data
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fechar() {
|
||||||
|
emit('update:visible', false)
|
||||||
|
guia.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatarNumeroGuia(numero) {
|
||||||
|
return numero ? String(numero).padStart(10, '0') : '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSeverityStatus(codigo) {
|
||||||
|
return { 0: 'danger', 1: 'info', 2: 'success', 3: 'warn' }[codigo ?? props.statusCodigo] ?? 'secondary'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function visualizarPdf() {
|
||||||
|
if (!props.guiaId) return
|
||||||
|
isLoadingPdf.value = true
|
||||||
|
try {
|
||||||
|
const buf = await portalService.baixarGuiaEmitidaPdf(props.guiaId)
|
||||||
|
abrirPdf(buf)
|
||||||
|
} finally {
|
||||||
|
isLoadingPdf.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
:visible="visible"
|
||||||
|
modal
|
||||||
|
:header="guia ? `Detalhes da Guia #${formatarNumeroGuia(guia.numGuia)}` : 'Detalhes da Guia'"
|
||||||
|
:style="{ width: 'min(95vw, 56rem)' }"
|
||||||
|
@update:visible="(v) => emit('update:visible', v)"
|
||||||
|
>
|
||||||
|
<div v-if="isLoading" class="flex justify-center p-8">
|
||||||
|
<ProgressSpinner />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="guia" class="space-y-4">
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-slate-500">Número</p>
|
||||||
|
<p class="font-bold">{{ formatarNumeroGuia(guia.numGuia) }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-slate-500">Status</p>
|
||||||
|
<Tag :value="guia.statusDescricao ?? 'Emitida'" :severity="getSeverityStatus()" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-slate-500">Emissão</p>
|
||||||
|
<p>{{ formatDate(guia.dataEmissao) }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-slate-500">Vencimento</p>
|
||||||
|
<p>{{ formatDate(guia.dataVencimento) }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-slate-500">Valor Total</p>
|
||||||
|
<p class="font-bold">{{ formatCurrency(guia.valorTotal) }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="guia.linhaDigitavel" class="sm:col-span-3">
|
||||||
|
<p class="text-xs text-slate-500">Linha Digitável</p>
|
||||||
|
<p class="font-mono text-xs break-all">{{ guia.linhaDigitavel }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable :value="guia.itens ?? []" size="small" show-gridlines>
|
||||||
|
<Column field="tributo" header="Tributo" />
|
||||||
|
<Column field="periodoRef" header="Período" />
|
||||||
|
<Column field="doc" header="Documento" />
|
||||||
|
<Column field="valorTotal" header="Total" body-class="text-right">
|
||||||
|
<template #body="{ data }">{{ formatCurrency(data.valorTotal) }}</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<Button label="Fechar" severity="secondary" outlined @click="fechar" />
|
||||||
|
<Button label="Visualizar PDF" icon="pi pi-file-pdf" :loading="isLoadingPdf" @click="visualizarPdf" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.font-mono {
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
71
src/components/extrato/ModalTransacoesContaCorrente.vue
Normal file
71
src/components/extrato/ModalTransacoesContaCorrente.vue
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { portalService } from '@/services/portalService'
|
||||||
|
import { formatCurrency, formatDate } from '@/utils/formatador'
|
||||||
|
|
||||||
|
const visivel = ref(false)
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const transacoes = ref([])
|
||||||
|
const mensagemErro = ref('')
|
||||||
|
|
||||||
|
async function abrir(idContaCorrente) {
|
||||||
|
visivel.value = true
|
||||||
|
isLoading.value = true
|
||||||
|
transacoes.value = []
|
||||||
|
mensagemErro.value = ''
|
||||||
|
try {
|
||||||
|
const res = await portalService.getTransacoes(idContaCorrente)
|
||||||
|
transacoes.value = res.data ?? []
|
||||||
|
} catch (e) {
|
||||||
|
mensagemErro.value = e?.data?.description ?? 'Erro ao buscar transações.'
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ abrir })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="visivel"
|
||||||
|
modal
|
||||||
|
header="Transações da Conta Corrente"
|
||||||
|
:style="{ width: 'min(95vw, 56rem)' }"
|
||||||
|
>
|
||||||
|
<div v-if="isLoading" class="flex justify-center p-8">
|
||||||
|
<ProgressSpinner />
|
||||||
|
</div>
|
||||||
|
<p v-else-if="mensagemErro" class="text-sm text-red-600">{{ mensagemErro }}</p>
|
||||||
|
<DataTable
|
||||||
|
v-else
|
||||||
|
:value="transacoes"
|
||||||
|
show-gridlines
|
||||||
|
size="small"
|
||||||
|
scrollable
|
||||||
|
scroll-height="400px"
|
||||||
|
empty-message="Nenhuma transação encontrada."
|
||||||
|
>
|
||||||
|
<Column field="id" header="ID" style="width: 60px" />
|
||||||
|
<Column field="data" header="Data" style="width: 100px">
|
||||||
|
<template #body="{ data }">{{ formatDate(data.data) }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="tipoTransacao" header="Tipo" />
|
||||||
|
<Column field="numDocOrigem" header="Doc. Origem" style="width: 110px" />
|
||||||
|
<Column field="valorPrincipal" header="Principal" body-class="text-right">
|
||||||
|
<template #body="{ data }">{{ formatCurrency(data.valorPrincipal) }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="valorMulta" header="Multa" body-class="text-right">
|
||||||
|
<template #body="{ data }">{{ formatCurrency(data.valorMulta) }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="valorJuros" header="Juros" body-class="text-right">
|
||||||
|
<template #body="{ data }">{{ formatCurrency(data.valorJuros) }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="valorTotal" header="Total" body-class="text-right">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<strong>{{ formatCurrency(data.valorTotal) }}</strong>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
133
src/components/taxas/CamposEmissaoTaxa.vue
Normal file
133
src/components/taxas/CamposEmissaoTaxa.vue
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
<script setup>
|
||||||
|
import { resolveCampoInformativo } from '@/utils/atributoMascara'
|
||||||
|
import { calcularValorTotal } from '@/utils/formulaCalculo'
|
||||||
|
import { formatarMoedaInput, obterValorNumerico } from '@/utils/formatacao'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
itensTributo: { type: Array, default: () => [] },
|
||||||
|
itensInformativosDoc: { type: Array, default: () => [] },
|
||||||
|
formulaSelecionada: { type: Object, default: null },
|
||||||
|
valoresItens: { type: Object, required: true },
|
||||||
|
valorTaxa: { type: String, default: '' },
|
||||||
|
erros: { type: Object, default: () => ({}) },
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:valoresItens', 'update:valorTaxa'])
|
||||||
|
|
||||||
|
const itensEntrada = computed(() =>
|
||||||
|
props.itensTributo.filter(i => i.tipoVariavel === 1).map(i => ({
|
||||||
|
...i,
|
||||||
|
campo: resolveCampoInformativo(i),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
const itensConstantes = computed(() => props.itensTributo.filter(i => i.tipoVariavel === 2))
|
||||||
|
|
||||||
|
const todosInformativos = computed(() => {
|
||||||
|
const formula = props.itensTributo.filter(i => i.tipoVariavel === 3)
|
||||||
|
const doc = props.itensInformativosDoc.map(i => ({ ...i, campo: resolveCampoInformativo(i) }))
|
||||||
|
return [...formula.map(i => ({ ...i, campo: resolveCampoInformativo(i) })), ...doc]
|
||||||
|
})
|
||||||
|
|
||||||
|
const temFormula = computed(() => props.itensTributo.length > 0)
|
||||||
|
|
||||||
|
const valorTotalCalculado = computed(() => {
|
||||||
|
if (!props.formulaSelecionada?.formula) return '0,00'
|
||||||
|
return calcularValorTotal(
|
||||||
|
props.formulaSelecionada.formula,
|
||||||
|
props.itensTributo,
|
||||||
|
props.valoresItens,
|
||||||
|
obterValorNumerico,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function updateValorItem(itemId, valor) {
|
||||||
|
emit('update:valoresItens', { ...props.valoresItens, [itemId]: valor })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onValorTaxaInput(event) {
|
||||||
|
emit('update:valorTaxa', formatarMoedaInput(event.target.value))
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<template v-if="temFormula">
|
||||||
|
<div v-if="itensConstantes.length" class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div v-for="item in itensConstantes" :key="item.id" class="text-sm">
|
||||||
|
<span class="text-slate-500 dark:text-slate-400">{{ item.descricao }}:</span>
|
||||||
|
<span class="ml-2 font-medium text-slate-700 dark:text-slate-200">
|
||||||
|
{{ valoresItens[item.id] || '0,00' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="item in itensEntrada" :key="item.id">
|
||||||
|
<label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1.5">
|
||||||
|
{{ item.descricao }}<span v-if="item.obrigatorio !== false"> *</span>
|
||||||
|
</label>
|
||||||
|
<InputText
|
||||||
|
v-if="item.campo.tipoCampo === 'text'"
|
||||||
|
:model-value="valoresItens[item.id]"
|
||||||
|
class="w-full"
|
||||||
|
size="small"
|
||||||
|
@update:model-value="updateValorItem(item.id, $event)"
|
||||||
|
/>
|
||||||
|
<InputText
|
||||||
|
v-else
|
||||||
|
:model-value="valoresItens[item.id]"
|
||||||
|
class="w-full"
|
||||||
|
size="small"
|
||||||
|
inputmode="decimal"
|
||||||
|
@input="updateValorItem(item.id, formatarMoedaInput($event.target.value))"
|
||||||
|
/>
|
||||||
|
<p v-if="erros[`item_${item.id}`]" class="text-xs text-red-500 mt-1">{{ erros[`item_${item.id}`] }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="formulaSelecionada?.formula"
|
||||||
|
class="rounded-lg bg-slate-50 dark:bg-slate-700/50 px-4 py-3 flex justify-between items-center"
|
||||||
|
>
|
||||||
|
<span class="text-sm text-slate-600 dark:text-slate-300">Valor calculado</span>
|
||||||
|
<span class="text-lg font-bold text-slate-800 dark:text-slate-100">R$ {{ valorTotalCalculado }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1.5">
|
||||||
|
Valor da taxa *
|
||||||
|
</label>
|
||||||
|
<InputText
|
||||||
|
:model-value="valorTaxa"
|
||||||
|
class="w-full"
|
||||||
|
size="small"
|
||||||
|
inputmode="decimal"
|
||||||
|
placeholder="0,00"
|
||||||
|
@input="onValorTaxaInput"
|
||||||
|
/>
|
||||||
|
<p v-if="erros.valorTaxa" class="text-xs text-red-500 mt-1">{{ erros.valorTaxa }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="item in todosInformativos" :key="`info-${item.id}`">
|
||||||
|
<label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1.5">
|
||||||
|
{{ item.descricao || item.atributo }}<span v-if="item.obrigatorio !== false"> *</span>
|
||||||
|
</label>
|
||||||
|
<InputText
|
||||||
|
v-if="item.campo.tipoCampo !== 'date'"
|
||||||
|
:model-value="valoresItens[item.id]"
|
||||||
|
class="w-full"
|
||||||
|
size="small"
|
||||||
|
@update:model-value="updateValorItem(item.id, $event)"
|
||||||
|
/>
|
||||||
|
<InputText
|
||||||
|
v-else
|
||||||
|
type="date"
|
||||||
|
:model-value="valoresItens[item.id]"
|
||||||
|
class="w-full"
|
||||||
|
size="small"
|
||||||
|
@update:model-value="updateValorItem(item.id, $event)"
|
||||||
|
/>
|
||||||
|
<p v-if="erros[`item_${item.id}`]" class="text-xs text-red-500 mt-1">{{ erros[`item_${item.id}`] }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -18,6 +18,7 @@ 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: {
|
||||||
@ -25,6 +26,13 @@ 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 {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
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
|
||||||
@ -10,15 +11,33 @@ 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 },
|
body: { documento, returnTo, primary: primary || undefined, dark },
|
||||||
})
|
})
|
||||||
if (import.meta.client) {
|
if (import.meta.client) {
|
||||||
window.location.href = res.authUrl
|
window.location.href = res.authUrl
|
||||||
|
|||||||
381
src/composables/useEmissaoTaxaPortal.js
Normal file
381
src/composables/useEmissaoTaxaPortal.js
Normal file
@ -0,0 +1,381 @@
|
|||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { portalService } from '@/services/portalService'
|
||||||
|
import { taxaService } from '@/services/taxaService'
|
||||||
|
import { resolveCampoInformativo } from '@/utils/atributoMascara'
|
||||||
|
import {
|
||||||
|
formatarMoeda,
|
||||||
|
formatarDataParaAPI,
|
||||||
|
obterValorNumerico,
|
||||||
|
periodoMesParaAPI,
|
||||||
|
baixarPdf,
|
||||||
|
} from '@/utils/formatacao'
|
||||||
|
import { calcularValorTotal } from '@/utils/formulaCalculo'
|
||||||
|
|
||||||
|
export function useEmissaoTaxaPortal() {
|
||||||
|
const contribuinte = ref(null)
|
||||||
|
const catalogo = ref([])
|
||||||
|
const tributoSelecionadoId = ref(null)
|
||||||
|
const tributo = ref(null)
|
||||||
|
const itensTributo = ref([])
|
||||||
|
const itensInformativosDoc = ref([])
|
||||||
|
const formulaSelecionada = ref(null)
|
||||||
|
|
||||||
|
const periodoReferencia = ref('')
|
||||||
|
const vencimentoDebito = ref('')
|
||||||
|
const vencimentoGuia = ref('')
|
||||||
|
const valorTaxa = ref('')
|
||||||
|
const valoresItens = ref({})
|
||||||
|
const observacao = ref('')
|
||||||
|
|
||||||
|
const dadosVencimento = ref(null)
|
||||||
|
const dadosMultaJuros = ref(null)
|
||||||
|
const resultadoEmissao = ref(null)
|
||||||
|
|
||||||
|
const carregando = ref(false)
|
||||||
|
const carregandoCatalogo = ref(false)
|
||||||
|
const carregandoTributo = ref(false)
|
||||||
|
const carregandoCalculo = ref(false)
|
||||||
|
const carregandoEmissao = ref(false)
|
||||||
|
const mensagemErro = ref('')
|
||||||
|
const erros = ref({})
|
||||||
|
|
||||||
|
const temItensCalculo = computed(() => itensTributo.value.length > 0)
|
||||||
|
|
||||||
|
const valorTotalCalculado = computed(() =>
|
||||||
|
calcularValorTotal(
|
||||||
|
formulaSelecionada.value?.formula,
|
||||||
|
itensTributo.value,
|
||||||
|
valoresItens.value,
|
||||||
|
obterValorNumerico,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const totalizadores = computed(() => {
|
||||||
|
if (!dadosMultaJuros.value) return null
|
||||||
|
const mj = dadosMultaJuros.value
|
||||||
|
const principal = temItensCalculo.value ? valorTotalCalculado.value : valorTaxa.value
|
||||||
|
const totalDescontos = (mj.descontoValorPrincipal || 0) + (mj.descontoValorMulta || 0) + (mj.descontoValorJuros || 0)
|
||||||
|
return {
|
||||||
|
principal: formatarMoeda(obterValorNumerico(principal)),
|
||||||
|
principalAtualizado: formatarMoeda(mj.valorPrincipalAtualizado || 0),
|
||||||
|
multa: formatarMoeda(mj.valorMulta || 0),
|
||||||
|
juros: formatarMoeda(mj.valorJuros || 0),
|
||||||
|
desconto: formatarMoeda(totalDescontos),
|
||||||
|
total: formatarMoeda(mj.valorTotalComDescontos ?? mj.valorTotal ?? 0),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function carregarDadosIniciais() {
|
||||||
|
carregando.value = true
|
||||||
|
mensagemErro.value = ''
|
||||||
|
try {
|
||||||
|
const [resDados, resCatalogo] = await Promise.all([
|
||||||
|
portalService.getDadosCadastrais(),
|
||||||
|
taxaService.getCatalogo(),
|
||||||
|
])
|
||||||
|
contribuinte.value = resDados.data
|
||||||
|
catalogo.value = resCatalogo.data ?? []
|
||||||
|
} catch (e) {
|
||||||
|
mensagemErro.value = e?.data?.description ?? 'Não foi possível carregar os dados.'
|
||||||
|
} finally {
|
||||||
|
carregando.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelCatalogo(item) {
|
||||||
|
if (!item) return ''
|
||||||
|
return item.sigla ? `${item.sigla} — ${item.descricaoResumida || item.descricao}` : item.descricao
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSelecionarTributo(id) {
|
||||||
|
tributoSelecionadoId.value = id
|
||||||
|
tributo.value = null
|
||||||
|
itensTributo.value = []
|
||||||
|
itensInformativosDoc.value = []
|
||||||
|
formulaSelecionada.value = null
|
||||||
|
valoresItens.value = {}
|
||||||
|
valorTaxa.value = ''
|
||||||
|
dadosVencimento.value = null
|
||||||
|
dadosMultaJuros.value = null
|
||||||
|
vencimentoDebito.value = ''
|
||||||
|
vencimentoGuia.value = ''
|
||||||
|
|
||||||
|
if (!id) return
|
||||||
|
|
||||||
|
carregandoTributo.value = true
|
||||||
|
try {
|
||||||
|
const res = await taxaService.getTributo(id)
|
||||||
|
aplicarTributo(res.data)
|
||||||
|
} catch (e) {
|
||||||
|
mensagemErro.value = e?.data?.description ?? 'Erro ao carregar a taxa selecionada.'
|
||||||
|
} finally {
|
||||||
|
carregandoTributo.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function aplicarTributo(taxa) {
|
||||||
|
tributo.value = taxa
|
||||||
|
if (taxa.formulasCalculo?.length > 0) {
|
||||||
|
const formulaAtiva = taxa.formulasCalculo.find(f => !f.dataFim) || taxa.formulasCalculo[0]
|
||||||
|
formulaSelecionada.value = formulaAtiva || null
|
||||||
|
itensTributo.value = formulaAtiva?.itensCalculo || []
|
||||||
|
} else {
|
||||||
|
formulaSelecionada.value = null
|
||||||
|
itensTributo.value = []
|
||||||
|
}
|
||||||
|
itensInformativosDoc.value = taxa.atributosInformativosDoc || []
|
||||||
|
|
||||||
|
const novosValores = {}
|
||||||
|
itensTributo.value.forEach(item => {
|
||||||
|
if (item.tipoVariavel === 2) {
|
||||||
|
novosValores[item.id] = item.valor ? formatarMoeda(item.valor) : '0,00'
|
||||||
|
} else if (item.tipoVariavel === 1 && item.tipoDado === 2) {
|
||||||
|
novosValores[item.id] = '0,00'
|
||||||
|
} else {
|
||||||
|
novosValores[item.id] = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
itensInformativosDoc.value.forEach(item => {
|
||||||
|
const campo = resolveCampoInformativo(item)
|
||||||
|
novosValores[item.id] = campo.tipoCampo === 'number' || item.tipoDado === 2 ? '0,00' : ''
|
||||||
|
})
|
||||||
|
valoresItens.value = novosValores
|
||||||
|
}
|
||||||
|
|
||||||
|
async function calcularVencimento() {
|
||||||
|
if (!tributo.value || !periodoReferencia.value) return null
|
||||||
|
const periodoApi = periodoMesParaAPI(periodoReferencia.value)
|
||||||
|
if (!periodoApi) return null
|
||||||
|
|
||||||
|
carregandoCalculo.value = true
|
||||||
|
try {
|
||||||
|
const res = await taxaService.calcularVencimento({
|
||||||
|
tributoId: tributo.value.idTaxa,
|
||||||
|
periodoReferencia: periodoApi,
|
||||||
|
})
|
||||||
|
dadosVencimento.value = res.data
|
||||||
|
vencimentoDebito.value = res.data?.dataVencimento ?? ''
|
||||||
|
if (res.data?.dataVencimentoGuia) {
|
||||||
|
vencimentoGuia.value = res.data.dataVencimentoGuia
|
||||||
|
}
|
||||||
|
return res.data
|
||||||
|
} catch (e) {
|
||||||
|
mensagemErro.value = e?.data?.description ?? 'Erro ao calcular vencimento.'
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
carregandoCalculo.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function calcularMultaJuros() {
|
||||||
|
if (!tributo.value || !periodoReferencia.value || !vencimentoDebito.value) return null
|
||||||
|
|
||||||
|
const periodoApi = periodoMesParaAPI(periodoReferencia.value)
|
||||||
|
const valorPrincipal = temItensCalculo.value
|
||||||
|
? obterValorNumerico(valorTotalCalculado.value)
|
||||||
|
: obterValorNumerico(valorTaxa.value)
|
||||||
|
|
||||||
|
if (valorPrincipal <= 0) return null
|
||||||
|
|
||||||
|
carregandoCalculo.value = true
|
||||||
|
try {
|
||||||
|
const res = await taxaService.calcularMultaJuros({
|
||||||
|
tributoId: tributo.value.idTaxa,
|
||||||
|
periodoReferencia: periodoApi,
|
||||||
|
dataVencimentoDebito: vencimentoDebito.value,
|
||||||
|
valorPrincipal,
|
||||||
|
dataVencimentoGuia: vencimentoGuia.value || vencimentoDebito.value,
|
||||||
|
})
|
||||||
|
dadosMultaJuros.value = res.data
|
||||||
|
return res.data
|
||||||
|
} catch (e) {
|
||||||
|
mensagemErro.value = e?.data?.description ?? 'Erro ao calcular multa e juros.'
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
carregandoCalculo.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPeriodoChange() {
|
||||||
|
dadosMultaJuros.value = null
|
||||||
|
await calcularVencimento()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recalcularTotais() {
|
||||||
|
if (!periodoReferencia.value || !vencimentoDebito.value) return
|
||||||
|
await calcularMultaJuros()
|
||||||
|
}
|
||||||
|
|
||||||
|
function montarAtributosPayload() {
|
||||||
|
const itensEntrada = itensTributo.value.filter(i => i.tipoVariavel === 1)
|
||||||
|
const informativos = [
|
||||||
|
...itensTributo.value.filter(i => i.tipoVariavel === 3),
|
||||||
|
...itensInformativosDoc.value,
|
||||||
|
]
|
||||||
|
return [...itensEntrada, ...informativos].map(item => ({
|
||||||
|
idItemCalculo: item.idTaxaCalc != null ? item.id : null,
|
||||||
|
idAtributo: item.idAtributo ?? null,
|
||||||
|
atributo: item.atributo,
|
||||||
|
valor: String(valoresItens.value[item.id] ?? ''),
|
||||||
|
tipoDado: item.tipoDado ?? item.tipoVariavel ?? null,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function validar() {
|
||||||
|
erros.value = {}
|
||||||
|
if (!tributoSelecionadoId.value) {
|
||||||
|
erros.value.tributo = 'Selecione uma taxa'
|
||||||
|
}
|
||||||
|
if (!periodoReferencia.value) {
|
||||||
|
erros.value.periodoReferencia = 'Período de referência é obrigatório'
|
||||||
|
}
|
||||||
|
if (!vencimentoDebito.value) {
|
||||||
|
erros.value.vencimentoDebito = 'Vencimento do débito é obrigatório'
|
||||||
|
}
|
||||||
|
if (!vencimentoGuia.value) {
|
||||||
|
erros.value.vencimentoGuia = 'Vencimento da guia é obrigatório'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (temItensCalculo.value) {
|
||||||
|
itensTributo.value.filter(i => i.tipoVariavel === 1).forEach(item => {
|
||||||
|
if (item.obrigatorio === false) return
|
||||||
|
const valor = valoresItens.value[item.id]
|
||||||
|
if (!valor || obterValorNumerico(valor) <= 0) {
|
||||||
|
erros.value[`item_${item.id}`] = `${item.descricao} deve ser maior que zero`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const schema = z.string().refine(v => v.trim() !== '' && obterValorNumerico(v) > 0, {
|
||||||
|
message: 'Valor da taxa é obrigatório',
|
||||||
|
})
|
||||||
|
const r = schema.safeParse(valorTaxa.value || '')
|
||||||
|
if (!r.success) erros.value.valorTaxa = r.error.issues[0].message
|
||||||
|
}
|
||||||
|
|
||||||
|
const informativos = [
|
||||||
|
...itensTributo.value.filter(i => i.tipoVariavel === 3),
|
||||||
|
...itensInformativosDoc.value,
|
||||||
|
]
|
||||||
|
informativos.forEach(item => {
|
||||||
|
if (item.obrigatorio === false) return
|
||||||
|
const valor = valoresItens.value[item.id]
|
||||||
|
const label = item.descricao || item.atributo
|
||||||
|
if (item.tipoDado === 2) {
|
||||||
|
if (!valor || obterValorNumerico(valor) <= 0) {
|
||||||
|
erros.value[`item_${item.id}`] = `${label} é obrigatório`
|
||||||
|
}
|
||||||
|
} else if (!valor || String(valor).trim() === '') {
|
||||||
|
erros.value[`item_${item.id}`] = `${label} é obrigatório`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return Object.keys(erros.value).length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async function emitirTaxa() {
|
||||||
|
mensagemErro.value = ''
|
||||||
|
if (!validar()) return false
|
||||||
|
|
||||||
|
const mj = dadosMultaJuros.value || await calcularMultaJuros()
|
||||||
|
if (!mj) {
|
||||||
|
mensagemErro.value = 'Não foi possível calcular os valores da taxa.'
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const periodoApi = periodoMesParaAPI(periodoReferencia.value)
|
||||||
|
const valorPrincipal = temItensCalculo.value
|
||||||
|
? obterValorNumerico(valorTotalCalculado.value)
|
||||||
|
: obterValorNumerico(valorTaxa.value)
|
||||||
|
const totalDescontos = (mj.descontoValorPrincipal || 0) + (mj.descontoValorMulta || 0) + (mj.descontoValorJuros || 0)
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
tributoId: tributo.value.idTaxa,
|
||||||
|
taxaCalcId: formulaSelecionada.value?.id ?? undefined,
|
||||||
|
periodoReferencia: periodoApi,
|
||||||
|
dataVencimentoGuia: formatarDataParaAPI(vencimentoGuia.value),
|
||||||
|
dataVencimentoDebito: formatarDataParaAPI(vencimentoDebito.value),
|
||||||
|
observacao: observacao.value?.trim() || undefined,
|
||||||
|
atributosTaxa: montarAtributosPayload(),
|
||||||
|
valores: {
|
||||||
|
valorPrincipal,
|
||||||
|
valorPrincipalAtualizado: mj.valorPrincipalAtualizado || valorPrincipal,
|
||||||
|
valorMulta: mj.valorMulta || 0,
|
||||||
|
valorJuros: mj.valorJuros || 0,
|
||||||
|
totalDescontos,
|
||||||
|
valorTotalComDescontos: mj.valorTotalComDescontos ?? mj.valorTotal ?? 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
carregandoEmissao.value = true
|
||||||
|
try {
|
||||||
|
const res = await taxaService.lancar(payload)
|
||||||
|
resultadoEmissao.value = res.data
|
||||||
|
return true
|
||||||
|
} catch (e) {
|
||||||
|
mensagemErro.value = e?.data?.description ?? 'Erro ao emitir a taxa.'
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
carregandoEmissao.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function imprimirGuia() {
|
||||||
|
if (!resultadoEmissao.value?.taxaId) return
|
||||||
|
try {
|
||||||
|
const buf = await taxaService.baixarGuia(resultadoEmissao.value.taxaId)
|
||||||
|
baixarPdf(buf, `guia-${resultadoEmissao.value.numeroProtocolo || resultadoEmissao.value.taxaId}.pdf`)
|
||||||
|
} catch {
|
||||||
|
mensagemErro.value = 'Erro ao gerar a guia.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reiniciar() {
|
||||||
|
tributoSelecionadoId.value = null
|
||||||
|
tributo.value = null
|
||||||
|
periodoReferencia.value = ''
|
||||||
|
vencimentoDebito.value = ''
|
||||||
|
vencimentoGuia.value = ''
|
||||||
|
valorTaxa.value = ''
|
||||||
|
valoresItens.value = {}
|
||||||
|
observacao.value = ''
|
||||||
|
dadosVencimento.value = null
|
||||||
|
dadosMultaJuros.value = null
|
||||||
|
resultadoEmissao.value = null
|
||||||
|
erros.value = {}
|
||||||
|
mensagemErro.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
contribuinte,
|
||||||
|
catalogo,
|
||||||
|
tributoSelecionadoId,
|
||||||
|
tributo,
|
||||||
|
itensTributo,
|
||||||
|
itensInformativosDoc,
|
||||||
|
formulaSelecionada,
|
||||||
|
periodoReferencia,
|
||||||
|
vencimentoDebito,
|
||||||
|
vencimentoGuia,
|
||||||
|
valorTaxa,
|
||||||
|
valoresItens,
|
||||||
|
observacao,
|
||||||
|
totalizadores,
|
||||||
|
resultadoEmissao,
|
||||||
|
carregando,
|
||||||
|
carregandoCatalogo,
|
||||||
|
carregandoTributo,
|
||||||
|
carregandoCalculo,
|
||||||
|
carregandoEmissao,
|
||||||
|
mensagemErro,
|
||||||
|
erros,
|
||||||
|
labelCatalogo,
|
||||||
|
carregarDadosIniciais,
|
||||||
|
onSelecionarTributo,
|
||||||
|
onPeriodoChange,
|
||||||
|
recalcularTotais,
|
||||||
|
emitirTaxa,
|
||||||
|
imprimirGuia,
|
||||||
|
reiniciar,
|
||||||
|
}
|
||||||
|
}
|
||||||
227
src/composables/useExtratoDebitosPortal.js
Normal file
227
src/composables/useExtratoDebitosPortal.js
Normal file
@ -0,0 +1,227 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
197
src/composables/useExtratoPagamentosPortal.js
Normal file
197
src/composables/useExtratoPagamentosPortal.js
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { portalService } from '@/services/portalService'
|
||||||
|
import { formatDateISO, baixarPdf } from '@/utils/formatador'
|
||||||
|
|
||||||
|
function formatDateForDisplay(date, monthOnly = false) {
|
||||||
|
if (!date) return '-'
|
||||||
|
const d = new Date(date)
|
||||||
|
if (isNaN(d.getTime())) return '-'
|
||||||
|
if (monthOnly) {
|
||||||
|
return `${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`
|
||||||
|
}
|
||||||
|
return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function montarParametrosTributoConta(lista) {
|
||||||
|
if (!lista?.length) {
|
||||||
|
return {
|
||||||
|
codTributo: '-',
|
||||||
|
descricaoTributo: '-',
|
||||||
|
codContaTributo: '-',
|
||||||
|
descricaoContaTributo: '-',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const chaves = new Map()
|
||||||
|
for (const item of lista) {
|
||||||
|
const key = `${item.idTributo ?? ''}_${item.idContaTributo ?? ''}`
|
||||||
|
if (!chaves.has(key)) chaves.set(key, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chaves.size === 1) {
|
||||||
|
const t = [...chaves.values()][0]
|
||||||
|
return {
|
||||||
|
codTributo: t.idTributo != null ? String(t.idTributo) : (t.identificador ?? '-'),
|
||||||
|
descricaoTributo: t.descricaoTributo || '-',
|
||||||
|
codContaTributo: t.idContaTributo != null ? String(t.idContaTributo) : '-',
|
||||||
|
descricaoContaTributo: t.descricaoContaTributo || '-',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tributos = [...new Set(lista.map(t => t.descricaoTributo).filter(Boolean))]
|
||||||
|
return {
|
||||||
|
codTributo: 'Diversos',
|
||||||
|
descricaoTributo: tributos.length ? tributos.join(', ') : 'Diversos tributos',
|
||||||
|
codContaTributo: '-',
|
||||||
|
descricaoContaTributo: '-',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useExtratoPagamentosPortal() {
|
||||||
|
const tributos = ref([])
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const isLoadingPdf = ref(false)
|
||||||
|
const mensagemErro = ref('')
|
||||||
|
|
||||||
|
const filtro = ref({
|
||||||
|
debitosInicio: null,
|
||||||
|
debitosFim: null,
|
||||||
|
pagInicio: null,
|
||||||
|
pagFim: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const totais = computed(() => {
|
||||||
|
let principal = 0, multa = 0, juros = 0, desconto = 0, total = 0
|
||||||
|
for (const t of tributos.value) {
|
||||||
|
for (const p of t.pagamentos || []) {
|
||||||
|
principal += p.lancado ?? 0
|
||||||
|
multa += p.multa ?? 0
|
||||||
|
juros += p.juros ?? 0
|
||||||
|
desconto += p.desconto ?? 0
|
||||||
|
total += p.total ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { principal, multa, juros, desconto, total }
|
||||||
|
})
|
||||||
|
|
||||||
|
async function consultar() {
|
||||||
|
isLoading.value = true
|
||||||
|
mensagemErro.value = ''
|
||||||
|
tributos.value = []
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
pagInicio: formatDateISO(filtro.value.pagInicio) || undefined,
|
||||||
|
pagFim: formatDateISO(filtro.value.pagFim) || undefined,
|
||||||
|
periodoIni: formatDateISO(filtro.value.debitosInicio) || undefined,
|
||||||
|
periodoFim: formatDateISO(filtro.value.debitosFim) || undefined,
|
||||||
|
}
|
||||||
|
const res = await portalService.getPagamentosExtrato(params)
|
||||||
|
const dados = res.data ?? []
|
||||||
|
tributos.value = dados.map((item, idx) => ({
|
||||||
|
idTributo: item.idTributo,
|
||||||
|
idContaTributo: item.idContaTributo,
|
||||||
|
identificador: item.identificador,
|
||||||
|
descricaoTributo: item.descricaoTributo,
|
||||||
|
descricaoContaTributo: item.descricaoContaTributo,
|
||||||
|
totalPagamentos: item.totalPagamentos ?? 0,
|
||||||
|
pagamentos: (item.pagamentos || []).map((p, i) => ({
|
||||||
|
id: `${item.idContaTributo}_${i}_${p.idContaCorrente ?? i}`,
|
||||||
|
idContaCorrente: p.idContaCorrente,
|
||||||
|
ndoc: p.numDoc,
|
||||||
|
nguia: p.numGuia,
|
||||||
|
vencimento: p.dataVencimento,
|
||||||
|
pagamento: p.dataPagamento,
|
||||||
|
refer: p.periodoRef,
|
||||||
|
lancado: p.valorPrincipal ?? 0,
|
||||||
|
multa: p.valorMulta ?? 0,
|
||||||
|
juros: p.valorJuros ?? 0,
|
||||||
|
desconto: p.valorDesconto ?? 0,
|
||||||
|
total: p.valorTotal ?? 0,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
} catch (e) {
|
||||||
|
mensagemErro.value = e?.data?.description ?? 'Erro ao consultar pagamentos.'
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function limparFiltros() {
|
||||||
|
filtro.value = {
|
||||||
|
debitosInicio: null,
|
||||||
|
debitosFim: null,
|
||||||
|
pagInicio: null,
|
||||||
|
pagFim: null,
|
||||||
|
}
|
||||||
|
tributos.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function gerarPdf() {
|
||||||
|
if (!tributos.value.length) {
|
||||||
|
mensagemErro.value = 'Consulte os pagamentos antes de gerar o PDF.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const listaPagamentos = tributos.value.map(t => ({
|
||||||
|
idTributo: t.idTributo,
|
||||||
|
identificador: t.identificador,
|
||||||
|
descricaoTributo: t.descricaoTributo,
|
||||||
|
siglaTributo: t.siglaTributo,
|
||||||
|
idContaTributo: t.idContaTributo,
|
||||||
|
descricaoContaTributo: t.descricaoContaTributo,
|
||||||
|
totalPagamentos: t.totalPagamentos,
|
||||||
|
pagamentos: (t.pagamentos || []).map(p => ({
|
||||||
|
numDoc: p.ndoc,
|
||||||
|
numGuia: p.nguia,
|
||||||
|
dataVencimento: p.vencimento,
|
||||||
|
dataPagamento: p.pagamento,
|
||||||
|
periodoRef: p.refer,
|
||||||
|
valorPrincipal: p.lancado,
|
||||||
|
valorJuros: p.juros,
|
||||||
|
valorMulta: p.multa,
|
||||||
|
valorDesconto: p.desconto,
|
||||||
|
valorTotal: p.total,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const dto = {
|
||||||
|
listaPagamentos,
|
||||||
|
...montarParametrosTributoConta(tributos.value),
|
||||||
|
periodoDebitosInicio: formatDateForDisplay(filtro.value.debitosInicio, true),
|
||||||
|
periodoDebitosFim: formatDateForDisplay(filtro.value.debitosFim, true),
|
||||||
|
periodoPagamentosInicio: formatDateForDisplay(filtro.value.pagInicio),
|
||||||
|
periodoPagamentosFim: formatDateForDisplay(filtro.value.pagFim),
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoadingPdf.value = true
|
||||||
|
try {
|
||||||
|
const buf = await portalService.gerarExtratoPagamentosPdf(dto)
|
||||||
|
baixarPdf(buf, 'extrato-pagamentos.pdf')
|
||||||
|
} catch (e) {
|
||||||
|
mensagemErro.value = e?.data?.description ?? 'Erro ao gerar PDF.'
|
||||||
|
} finally {
|
||||||
|
isLoadingPdf.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function baixarComprovante(pag) {
|
||||||
|
if (!pag.idContaCorrente) return null
|
||||||
|
try {
|
||||||
|
return await portalService.getComprovante(pag.idContaCorrente)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tributos,
|
||||||
|
isLoading,
|
||||||
|
isLoadingPdf,
|
||||||
|
mensagemErro,
|
||||||
|
filtro,
|
||||||
|
totais,
|
||||||
|
consultar,
|
||||||
|
limparFiltros,
|
||||||
|
gerarPdf,
|
||||||
|
baixarComprovante,
|
||||||
|
}
|
||||||
|
}
|
||||||
106
src/composables/useGuiasEmitidasPortal.js
Normal file
106
src/composables/useGuiasEmitidasPortal.js
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { portalService } from '@/services/portalService'
|
||||||
|
import { formatDateISO, abrirPdf } from '@/utils/formatador'
|
||||||
|
|
||||||
|
export function useGuiasEmitidasPortal() {
|
||||||
|
const guias = ref([])
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const mensagemErro = ref('')
|
||||||
|
const pagina = ref(0)
|
||||||
|
const totalPaginas = ref(0)
|
||||||
|
const totalElementos = ref(0)
|
||||||
|
const tamanhoPagina = ref(15)
|
||||||
|
|
||||||
|
const filtro = ref({
|
||||||
|
numeroGuia: '',
|
||||||
|
status: null,
|
||||||
|
valorMinimo: null,
|
||||||
|
valorMaximo: null,
|
||||||
|
dataEmissaoInicio: null,
|
||||||
|
dataEmissaoFim: null,
|
||||||
|
dataVencimentoInicio: null,
|
||||||
|
dataVencimentoFim: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ label: 'Não Processado', value: 0 },
|
||||||
|
{ label: 'Emitida/Ativa', value: 1 },
|
||||||
|
{ label: 'Paga', value: 2 },
|
||||||
|
{ label: 'Cancelada', value: 3 },
|
||||||
|
]
|
||||||
|
|
||||||
|
async function consultar() {
|
||||||
|
isLoading.value = true
|
||||||
|
mensagemErro.value = ''
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
page: pagina.value,
|
||||||
|
size: tamanhoPagina.value,
|
||||||
|
numeroGuia: filtro.value.numeroGuia || undefined,
|
||||||
|
status: filtro.value.status ?? undefined,
|
||||||
|
valorMinimo: filtro.value.valorMinimo ?? undefined,
|
||||||
|
valorMaximo: filtro.value.valorMaximo ?? undefined,
|
||||||
|
dataEmissaoInicio: formatDateISO(filtro.value.dataEmissaoInicio) || undefined,
|
||||||
|
dataEmissaoFim: formatDateISO(filtro.value.dataEmissaoFim) || undefined,
|
||||||
|
dataVencimentoInicio: formatDateISO(filtro.value.dataVencimentoInicio) || undefined,
|
||||||
|
dataVencimentoFim: formatDateISO(filtro.value.dataVencimentoFim) || undefined,
|
||||||
|
}
|
||||||
|
const res = await portalService.listarGuias(params)
|
||||||
|
const pageData = res.data ?? {}
|
||||||
|
guias.value = pageData.data ?? []
|
||||||
|
totalPaginas.value = pageData.paginasTotais ?? 0
|
||||||
|
totalElementos.value = pageData.elementosTotais ?? 0
|
||||||
|
} catch (e) {
|
||||||
|
mensagemErro.value = e?.data?.description ?? 'Erro ao consultar guias.'
|
||||||
|
guias.value = []
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function limparFiltros() {
|
||||||
|
filtro.value = {
|
||||||
|
numeroGuia: '', status: null, valorMinimo: null, valorMaximo: null,
|
||||||
|
dataEmissaoInicio: null, dataEmissaoFim: null,
|
||||||
|
dataVencimentoInicio: null, dataVencimentoFim: null,
|
||||||
|
}
|
||||||
|
pagina.value = 0
|
||||||
|
consultar()
|
||||||
|
}
|
||||||
|
|
||||||
|
function mudarPagina(novaPagina) {
|
||||||
|
pagina.value = novaPagina
|
||||||
|
consultar()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function visualizarPdf(id) {
|
||||||
|
const buf = await portalService.baixarGuiaEmitidaPdf(id)
|
||||||
|
abrirPdf(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatarNumeroGuia(numero) {
|
||||||
|
return numero ? String(numero).padStart(10, '0') : '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSeverityStatus(codigo) {
|
||||||
|
return { 0: 'danger', 1: 'info', 2: 'success', 3: 'warn' }[codigo] ?? 'secondary'
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
guias,
|
||||||
|
isLoading,
|
||||||
|
mensagemErro,
|
||||||
|
pagina,
|
||||||
|
totalPaginas,
|
||||||
|
totalElementos,
|
||||||
|
tamanhoPagina,
|
||||||
|
filtro,
|
||||||
|
statusOptions,
|
||||||
|
consultar,
|
||||||
|
limparFiltros,
|
||||||
|
mudarPagina,
|
||||||
|
visualizarPdf,
|
||||||
|
formatarNumeroGuia,
|
||||||
|
getSeverityStatus,
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/error.vue
Normal file
58
src/error.vue
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
<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>
|
||||||
@ -1,44 +1,83 @@
|
|||||||
<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' },
|
{ path: '/portal/painel', label: 'Painel', icon: 'pi-home' },
|
||||||
{ path: '/portal/debitos', label: 'Débitos' },
|
{ path: '/portal/debitos', label: 'Débitos', icon: 'pi-receipt' },
|
||||||
{ path: '/portal/certidoes', label: 'Certidões' },
|
{ path: '/portal/guias', label: 'Guias Emitidas', icon: 'pi-file' },
|
||||||
{ path: '/portal/alvaras', label: 'Alvarás' },
|
{ path: '/portal/taxas', label: 'Taxas', icon: 'pi-file-export' },
|
||||||
{ path: '/portal/pagamentos', label: 'Pagamentos' },
|
{ path: '/portal/certidoes', label: 'Certidões', icon: 'pi-file-check' },
|
||||||
{ path: '/portal/dados', label: 'Dados Cadastrais' },
|
{ path: '/portal/pagamentos', label: 'Pagamentos', icon: 'pi-credit-card' },
|
||||||
|
{ 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 justify-between">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center gap-3">
|
||||||
|
|
||||||
|
<!-- 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-3"
|
class="flex items-center gap-2.5 min-w-0 flex-shrink-0"
|
||||||
aria-label="Ir para o painel principal"
|
aria-label="Ir para o painel principal"
|
||||||
>
|
>
|
||||||
<div class="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
|
<img
|
||||||
|
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>
|
||||||
<span class="font-semibold text-slate-800 dark:text-slate-100">Portal do Contribuinte</span>
|
<div class="flex flex-col leading-tight min-w-0">
|
||||||
|
<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 aria-label="Menu do contribuinte" class="hidden md:flex items-center gap-1">
|
<!-- Nav desktop -->
|
||||||
|
<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-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"
|
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"
|
||||||
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"
|
||||||
>
|
>
|
||||||
@ -46,8 +85,9 @@ function sair() {
|
|||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="flex items-center gap-3">
|
<!-- Ações desktop -->
|
||||||
<span class="hidden sm:block text-sm text-slate-600 dark:text-slate-300" aria-live="polite">
|
<div class="hidden md:flex items-center gap-3 flex-shrink-0 ml-auto">
|
||||||
|
<span class="text-sm text-slate-600 dark:text-slate-300 truncate max-w-40" aria-live="polite">
|
||||||
{{ nomeUsuario }}
|
{{ nomeUsuario }}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
@ -60,10 +100,90 @@ 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>
|
||||||
|
|
||||||
<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 mobile -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
|||||||
@ -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" @click="solicitar" />
|
<Button label="Enviar solicitação" icon="pi pi-send" class="flex-1" :loading="carregando" :disabled="carregando" @click="solicitar" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
5
src/pages/entrar.vue
Normal file
5
src/pages/entrar.vue
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
<script setup>
|
||||||
|
const { isAuthenticated } = useAuth()
|
||||||
|
|
||||||
|
await navigateTo(isAuthenticated.value ? '/portal/painel' : '/', { replace: true })
|
||||||
|
</script>
|
||||||
@ -1,15 +1,17 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, watch, nextTick } from 'vue'
|
import { ref, computed, watch, nextTick, onMounted } 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()
|
||||||
@ -18,6 +20,12 @@ const documento = ref('')
|
|||||||
const erro = ref('')
|
const erro = ref('')
|
||||||
const carregando = ref(false)
|
const carregando = ref(false)
|
||||||
|
|
||||||
|
const AUTH_ERROR_MESSAGES = {
|
||||||
|
exchange_failed: 'Login cancelado: o servidor não conseguiu validar a sessão com o Keycloak. O client secret local provavelmente está incorreto — peça o valor atual ao admin.',
|
||||||
|
invalid_state: 'Sessão de login expirada. Tente entrar novamente.',
|
||||||
|
missing_params: 'Resposta inválida do login. Tente entrar novamente.',
|
||||||
|
}
|
||||||
|
|
||||||
// Ref ao DocumentoInput — usado pelo botão "Entrar" do AppHeader pra focar o campo
|
// Ref ao DocumentoInput — usado pelo botão "Entrar" do AppHeader pra focar o campo
|
||||||
const documentoRef = ref(null)
|
const documentoRef = ref(null)
|
||||||
|
|
||||||
@ -53,8 +61,8 @@ const heroBgStyle = computed(() => {
|
|||||||
|
|
||||||
const heroHasPhoto = computed(() => !!heroBgUrl.value)
|
const heroHasPhoto = computed(() => !!heroBgUrl.value)
|
||||||
|
|
||||||
// Dados mockados — conectar ao endpoint /publico/avisos/{dominio} futuramente
|
// ─── FALLBACK — exibido quando nenhum aviso está cadastrado no banco ──────────
|
||||||
const avisos = ref([
|
const AVISOS_FALLBACK = [
|
||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
tipo: 'prazo',
|
tipo: 'prazo',
|
||||||
@ -82,7 +90,36 @@ const avisos = ref([
|
|||||||
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' },
|
||||||
@ -97,9 +134,9 @@ const servicosPublicos = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
const servicosAutenticados = [
|
const servicosAutenticados = [
|
||||||
{ icon: 'pi-receipt', titulo: 'Débitos e Guias', descricao: 'Emita guias de pagamento de todos os seus tributos.', to: '/portal/debitos' },
|
{ icon: 'pi-receipt', titulo: 'Extrato de Débitos', descricao: 'Consulte débitos e emita guias de pagamento.', to: '/portal/debitos' },
|
||||||
|
{ icon: 'pi-file', titulo: 'Guias Emitidas', descricao: 'Consulte e baixe guias de pagamento já emitidas.', to: '/portal/guias' },
|
||||||
{ icon: 'pi-file-edit', titulo: 'Certidões Ativas', descricao: 'Acesse e reemita certidões emitidas anteriormente.', to: '/portal/certidoes' },
|
{ icon: 'pi-file-edit', titulo: 'Certidões Ativas', descricao: 'Acesse e reemita certidões emitidas anteriormente.', to: '/portal/certidoes' },
|
||||||
{ icon: 'pi-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' },
|
||||||
]
|
]
|
||||||
@ -267,6 +304,7 @@ 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>
|
||||||
@ -314,12 +352,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].bg,
|
(corAviso[aviso.cor] ?? corAviso.blue).bg,
|
||||||
corAviso[aviso.cor].borda,
|
(corAviso[aviso.cor] ?? corAviso.blue).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].icone]" />
|
<i :class="['pi', aviso.icone, 'text-lg', (corAviso[aviso.cor] ?? corAviso.blue).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>
|
||||||
|
|||||||
@ -1,146 +0,0 @@
|
|||||||
<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 nº {{ 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>
|
|
||||||
@ -1,12 +1,35 @@
|
|||||||
<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)
|
||||||
@ -20,9 +43,16 @@ async function carregar() {
|
|||||||
mensagemErro.value = ''
|
mensagemErro.value = ''
|
||||||
try {
|
try {
|
||||||
const res = await portalService.getCertidoes()
|
const res = await portalService.getCertidoes()
|
||||||
certidoes.value = res.data?.content ?? []
|
certidoes.value = res.data ?? []
|
||||||
|
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
|
||||||
}
|
}
|
||||||
@ -59,7 +89,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')" />
|
<Button label="Nova certidão" icon="pi pi-plus" size="small" @click="router.push('/servicos/certidao?from=portal')" />
|
||||||
</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">
|
||||||
@ -84,7 +114,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')" />
|
<Button label="Emitir certidão" size="small" @click="router.push('/servicos/certidao?from=portal')" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else>
|
<div v-else>
|
||||||
@ -105,8 +135,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">Nº {{ cert.numero }}</p>
|
<p class="text-xs text-slate-400 dark:text-slate-500 mt-0.5">Nº {{ cert.numero }}</p>
|
||||||
</div>
|
</div>
|
||||||
<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.dataEmissao) }}</p>
|
||||||
<p class="hidden sm:block text-sm text-slate-500 dark:text-slate-400 w-28 text-right">{{ cert.dataValidade }}</p>
|
<p class="hidden sm:block text-sm text-slate-500 dark:text-slate-400 w-28 text-right">{{ formatDate(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 }}
|
||||||
@ -119,7 +149,7 @@ const statusMap = {
|
|||||||
size="small"
|
size="small"
|
||||||
outlined
|
outlined
|
||||||
:loading="carregandoPdf === cert.id"
|
:loading="carregandoPdf === cert.id"
|
||||||
:disabled="cert.status === 'CANCELADA'"
|
:disabled="cert.status === 'CANCELADA' || !!carregandoPdf"
|
||||||
@click="reemitir(cert)"
|
@click="reemitir(cert)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -30,6 +30,16 @@ 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 = ''
|
||||||
@ -93,7 +103,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">{{ dados.documento }}</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>
|
||||||
</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>
|
||||||
@ -113,13 +123,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.endereco?.logradouro }}, {{ dados.endereco?.numero }}
|
{{ dados.logradouro }}, {{ dados.numero }}
|
||||||
<template v-if="dados.endereco?.complemento"> — {{ dados.endereco.complemento }}</template>
|
<template v-if="dados.complemento"> — {{ dados.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.endereco?.bairro }} — {{ dados.endereco?.cidade }}/{{ dados.endereco?.uf }}
|
{{ dados.bairro }}<template v-if="dados.cidade"> — {{ dados.cidade }}</template>/{{ dados.uf }}
|
||||||
</p>
|
</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-1">CEP: {{ dados.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>
|
||||||
@ -172,7 +182,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" @click="salvarContato" />
|
<Button label="Salvar" icon="pi pi-check" class="flex-1" :loading="salvando" :disabled="salvando" @click="salvarContato" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,233 +1,226 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { portalService } from '@/services/portalService'
|
import { useExtratoDebitosPortal } from '@/composables/useExtratoDebitosPortal'
|
||||||
|
import { formatCurrency, formatDate } from '@/utils/formatador'
|
||||||
|
import ModalTransacoesContaCorrente from '@/components/extrato/ModalTransacoesContaCorrente.vue'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'portal',
|
layout: 'portal',
|
||||||
middleware: 'auth',
|
middleware: 'auth',
|
||||||
})
|
})
|
||||||
|
|
||||||
const debitos = ref([])
|
const modalTransacoes = ref(null)
|
||||||
const carregando = ref(true)
|
|
||||||
const carregandoGuia = ref(null)
|
|
||||||
const filtroTipo = ref(null)
|
|
||||||
const filtroStatus = ref(null)
|
|
||||||
const mensagemErro = ref('')
|
|
||||||
|
|
||||||
const tiposDisponiveis = ['IPTU', 'ISS', 'TAXA', 'MULTA', 'DIVIDA_ATIVA']
|
const {
|
||||||
const statusDisponiveis = [
|
resultados,
|
||||||
{ value: 'VENCIDO', label: 'Vencido', classe: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400' },
|
isLoading,
|
||||||
{ value: 'A_VENCER', label: 'A vencer', classe: 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400' },
|
isLoadingGuia,
|
||||||
{ value: 'PARCELADO', label: 'Parcelado', classe: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400' },
|
isLoadingExtrato,
|
||||||
]
|
mensagemErro,
|
||||||
|
dataVencimento,
|
||||||
|
erros,
|
||||||
|
filtro,
|
||||||
|
opcoesEstadoConta,
|
||||||
|
temSelecionado,
|
||||||
|
temDebitoSelecionado,
|
||||||
|
totalizadores,
|
||||||
|
consultar,
|
||||||
|
limparFiltros,
|
||||||
|
gerarGuia,
|
||||||
|
gerarExtratoPdf,
|
||||||
|
getEstadoSeverity,
|
||||||
|
} = useExtratoDebitosPortal()
|
||||||
|
|
||||||
const statusClasse = Object.fromEntries(statusDisponiveis.map(s => [s.value, s.classe]))
|
onMounted(() => consultar())
|
||||||
const statusLabel = Object.fromEntries(statusDisponiveis.map(s => [s.value, s.label]))
|
|
||||||
|
|
||||||
onMounted(() => carregar())
|
function getSegundoNome(texto) {
|
||||||
|
const palavras = (texto || '').trim().split(/\s+/)
|
||||||
async function carregar() {
|
return palavras.length > 1 ? palavras[1] : texto
|
||||||
carregando.value = true
|
|
||||||
mensagemErro.value = ''
|
|
||||||
try {
|
|
||||||
const params = {}
|
|
||||||
if (filtroTipo.value) params.tipo = filtroTipo.value
|
|
||||||
if (filtroStatus.value) params.status = filtroStatus.value
|
|
||||||
const res = await portalService.getDebitos(params)
|
|
||||||
debitos.value = res.data?.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">Débitos e Guias</h1>
|
<h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100">Extrato de Débitos</h1>
|
||||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">Consulte seus débitos e emita guias de pagamento.</p>
|
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
|
||||||
</div>
|
Consulte seus débitos, selecione parcelas e emita guias de pagamento.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4 flex flex-wrap gap-3 items-end">
|
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4 space-y-4">
|
||||||
<div class="flex-1 min-w-[160px]">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1.5">Tipo</label>
|
<div>
|
||||||
<Select
|
<label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1.5">Período Início</label>
|
||||||
v-model="filtroTipo"
|
<DatePicker v-model="filtro.periodoIni" view="month" date-format="mm/yy" show-icon class="w-full" size="small" />
|
||||||
:options="tiposDisponiveis"
|
|
||||||
placeholder="Todos"
|
|
||||||
show-clear
|
|
||||||
class="w-full"
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-[160px]">
|
<div>
|
||||||
<label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1.5">Status</label>
|
<label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1.5">Período Fim</label>
|
||||||
|
<DatePicker v-model="filtro.periodoFim" view="month" date-format="mm/yy" show-icon class="w-full" size="small" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1.5">Estado da Conta</label>
|
||||||
<Select
|
<Select
|
||||||
v-model="filtroStatus"
|
v-model="filtro.estadoConta"
|
||||||
:options="statusDisponiveis"
|
:options="opcoesEstadoConta"
|
||||||
option-label="label"
|
option-label="label"
|
||||||
option-value="value"
|
option-value="value"
|
||||||
placeholder="Todos"
|
placeholder="Todos"
|
||||||
show-clear
|
|
||||||
class="w-full"
|
class="w-full"
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
</div>
|
||||||
<Button label="Filtrar" icon="pi pi-filter" size="small" @click="aplicarFiltro" />
|
<div class="flex gap-2 flex-wrap">
|
||||||
<Button label="Limpar" severity="secondary" size="small" outlined @click="limparFiltros" />
|
<Button label="Consultar" icon="pi pi-search" size="small" :loading="isLoading" @click="consultar" />
|
||||||
|
<Button label="Limpar" icon="pi pi-eraser" size="small" severity="secondary" outlined @click="limparFiltros" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Transition
|
<Message v-if="mensagemErro" severity="warn" :closable="false">{{ mensagemErro }}</Message>
|
||||||
enter-active-class="transition-all duration-200"
|
|
||||||
enter-from-class="opacity-0 -translate-y-2"
|
|
||||||
enter-to-class="opacity-100 translate-y-0"
|
|
||||||
leave-active-class="transition-all duration-150"
|
|
||||||
leave-from-class="opacity-100 translate-y-0"
|
|
||||||
leave-to-class="opacity-0 -translate-y-2"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-if="temSelecionados"
|
|
||||||
class="bg-primary/8 dark:bg-primary/15 border border-primary/20 rounded-xl p-4 flex items-center justify-between gap-4"
|
|
||||||
>
|
|
||||||
<p class="text-sm font-semibold text-primary">
|
|
||||||
Total selecionado: {{ formatarMoeda(totalSelecionado) }}
|
|
||||||
</p>
|
|
||||||
<Button label="Emitir guia unificada" icon="pi pi-download" size="small" />
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
|
<div v-if="isLoading" class="space-y-3">
|
||||||
|
<div v-for="i in 3" :key="i" class="h-24 bg-slate-200 dark:bg-slate-700 rounded-xl animate-pulse" />
|
||||||
<div v-if="carregando" class="divide-y divide-slate-100 dark:divide-slate-700">
|
|
||||||
<div v-for="i in 5" :key="i" class="p-5 flex items-center gap-4">
|
|
||||||
<div class="w-4 h-4 bg-slate-200 dark:bg-slate-700 rounded animate-pulse" />
|
|
||||||
<div class="flex-1 space-y-2">
|
|
||||||
<div class="h-3.5 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-1/3" />
|
|
||||||
<div class="h-3 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-1/4" />
|
|
||||||
</div>
|
|
||||||
<div class="h-3.5 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-20" />
|
|
||||||
<div class="h-6 bg-slate-200 dark:bg-slate-700 rounded-full animate-pulse w-16" />
|
|
||||||
<div class="h-8 bg-slate-200 dark:bg-slate-700 rounded-lg animate-pulse w-24" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="debitos.length === 0 && !mensagemErro" class="p-12 text-center">
|
<div v-else-if="resultados.length === 0 && !mensagemErro" class="bg-white dark:bg-slate-800 rounded-xl border p-12 text-center">
|
||||||
<i class="pi pi-check-circle text-emerald-400 dark:text-emerald-500 text-4xl mb-3 block" aria-hidden="true" />
|
<i class="pi pi-check-circle text-emerald-400 text-4xl mb-3 block" />
|
||||||
<p class="font-semibold text-slate-700 dark:text-slate-200">Nenhum débito encontrado</p>
|
<p class="font-semibold text-slate-700 dark:text-slate-200">Nenhum débito encontrado</p>
|
||||||
<p class="text-sm text-slate-400 dark:text-slate-500 mt-1">Sua situação fiscal está regularizada.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="mensagemErro" class="p-8 text-center">
|
<template v-else-if="resultados.length">
|
||||||
<i class="pi pi-exclamation-triangle text-amber-400 text-3xl mb-3 block" aria-hidden="true" />
|
<Accordion :multiple="true" :active-index="[0]">
|
||||||
<p class="text-sm text-slate-600 dark:text-slate-300">{{ mensagemErro }}</p>
|
<AccordionPanel v-for="(tributo, index) in resultados" :key="tributo.idContaTributo" :value="String(index)">
|
||||||
<Button label="Tentar novamente" severity="secondary" size="small" class="mt-4" @click="carregar" />
|
<AccordionHeader>
|
||||||
</div>
|
<div class="flex items-center justify-between w-full gap-2 pr-2">
|
||||||
|
<span class="text-sm font-semibold truncate">
|
||||||
<div v-else>
|
Conta {{ tributo.idContaTributo }} — {{ tributo.descricaoContaTributo }} / {{ tributo.descricaoTributo }}
|
||||||
<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>
|
||||||
<Checkbox :binary="true" @change="toggleTodos($event.target.checked)" />
|
<span class="text-sm shrink-0">
|
||||||
<span class="flex-1">Descrição</span>
|
Total: <strong>{{ formatCurrency(tributo.totalPagamentos) }}</strong>
|
||||||
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</AccordionHeader>
|
||||||
<div class="w-28 flex justify-end">
|
<AccordionContent>
|
||||||
<Button
|
<DataTable
|
||||||
label="Emitir guia"
|
v-model:selection="tributo.selecionados"
|
||||||
icon="pi pi-download"
|
:value="tributo.debitos"
|
||||||
|
data-key="id"
|
||||||
size="small"
|
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" />
|
||||||
|
<Column field="numDoc" header="Nº Doc" style="min-width: 90px" />
|
||||||
|
<Column field="estadoConta" header="Estado" style="min-width: 90px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag :value="getSegundoNome(data.estadoConta)" :severity="getEstadoSeverity(data.codigoEstadoConta)" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="periodoRef" header="Período" style="min-width: 80px" />
|
||||||
|
<Column field="dataVencimento" header="Vencimento" style="min-width: 95px">
|
||||||
|
<template #body="{ data }">{{ formatDate(data.dataVencimento) }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="valorPrincipal" header="Principal" body-class="text-right" style="min-width: 100px">
|
||||||
|
<template #body="{ data }">{{ formatCurrency(data.valorPrincipal) }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="valorMulta" header="Multa" body-class="text-right" style="min-width: 90px">
|
||||||
|
<template #body="{ data }">{{ formatCurrency(data.valorMulta) }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="valorJuros" header="Juros" body-class="text-right" style="min-width: 90px">
|
||||||
|
<template #body="{ data }">{{ formatCurrency(data.valorJuros) }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="valorTotal" header="Total" body-class="text-right" style="min-width: 100px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<strong>{{ formatCurrency(data.valorTotal) }}</strong>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="" style="width: 3rem">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Button
|
||||||
|
icon="pi pi-search"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
@click="modalTransacoes?.abrir(data.idContaCorrente)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionPanel>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4 space-y-4">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-slate-500 uppercase mb-1.5">Vencimento da Guia</label>
|
||||||
|
<DatePicker
|
||||||
|
v-model="dataVencimento"
|
||||||
|
show-icon
|
||||||
|
:min-date="new Date()"
|
||||||
|
date-format="dd/mm/yy"
|
||||||
|
class="w-full"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<p v-if="erros.dataVencimento" class="text-xs text-red-500 mt-1">{{ erros.dataVencimento }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="sm:col-span-3 grid grid-cols-2 sm:grid-cols-4 gap-3 items-end">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-slate-500">Principal</p>
|
||||||
|
<p class="font-bold">{{ formatCurrency(totalizadores.principal) }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-slate-500">Multa</p>
|
||||||
|
<p class="font-bold">{{ formatCurrency(totalizadores.multa) }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-slate-500">Juros</p>
|
||||||
|
<p class="font-bold">{{ formatCurrency(totalizadores.juros) }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-slate-500">Total a Pagar</p>
|
||||||
|
<p class="font-bold text-primary">{{ formatCurrency(totalizadores.valorTotal) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2 justify-between">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
label="Imprimir Extrato (todos)"
|
||||||
|
icon="pi pi-print"
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
:loading="isLoadingExtrato"
|
||||||
|
@click="gerarExtratoPdf(false)"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="temSelecionado"
|
||||||
|
label="Imprimir Selecionados"
|
||||||
|
icon="pi pi-print"
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
outlined
|
outlined
|
||||||
class="whitespace-nowrap"
|
:loading="isLoadingExtrato"
|
||||||
:loading="carregandoGuia === debito.id"
|
@click="gerarExtratoPdf(true)"
|
||||||
@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>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<ModalTransacoesContaCorrente ref="modalTransacoes" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
170
src/pages/portal/guias.vue
Normal file
170
src/pages/portal/guias.vue
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useGuiasEmitidasPortal } from '@/composables/useGuiasEmitidasPortal'
|
||||||
|
import { formatCurrency, formatDate } from '@/utils/formatador'
|
||||||
|
import ModalDetalhesGuia from '@/components/extrato/ModalDetalhesGuia.vue'
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'portal',
|
||||||
|
middleware: 'auth',
|
||||||
|
})
|
||||||
|
|
||||||
|
const modalVisible = ref(false)
|
||||||
|
const guiaSelecionadaId = ref(null)
|
||||||
|
const guiaSelecionadaStatus = ref(null)
|
||||||
|
|
||||||
|
const {
|
||||||
|
guias,
|
||||||
|
isLoading,
|
||||||
|
mensagemErro,
|
||||||
|
pagina,
|
||||||
|
totalPaginas,
|
||||||
|
totalElementos,
|
||||||
|
filtro,
|
||||||
|
statusOptions,
|
||||||
|
consultar,
|
||||||
|
limparFiltros,
|
||||||
|
mudarPagina,
|
||||||
|
visualizarPdf,
|
||||||
|
formatarNumeroGuia,
|
||||||
|
getSeverityStatus,
|
||||||
|
} = useGuiasEmitidasPortal()
|
||||||
|
|
||||||
|
onMounted(() => consultar())
|
||||||
|
|
||||||
|
function detalhar(guia) {
|
||||||
|
guiaSelecionadaId.value = guia.id
|
||||||
|
guiaSelecionadaStatus.value = guia.statusCodigo
|
||||||
|
modalVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function paginaAnterior() {
|
||||||
|
if (pagina.value > 0) mudarPagina(pagina.value - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function proximaPagina() {
|
||||||
|
if (pagina.value < totalPaginas.value - 1) mudarPagina(pagina.value + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function aplicarFiltros() {
|
||||||
|
pagina.value = 0
|
||||||
|
consultar()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100">Guias Emitidas</h1>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">Consulte e baixe as guias de pagamento emitidas em seu nome.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4 space-y-4">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div class="lg:col-span-2">
|
||||||
|
<label class="block text-xs font-semibold text-slate-500 uppercase mb-1.5">Nº 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>Nº 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>
|
||||||
@ -1,147 +1,151 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { portalService } from '@/services/portalService'
|
import { useExtratoPagamentosPortal } from '@/composables/useExtratoPagamentosPortal'
|
||||||
|
import { formatCurrency, formatDate, abrirPdf } from '@/utils/formatador'
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
layout: 'portal',
|
layout: 'portal',
|
||||||
middleware: 'auth',
|
middleware: 'auth',
|
||||||
})
|
})
|
||||||
|
|
||||||
const pagamentos = ref([])
|
|
||||||
const carregando = ref(true)
|
|
||||||
const carregandoComprovante = ref(null)
|
const carregandoComprovante = ref(null)
|
||||||
const mensagemErro = ref('')
|
|
||||||
const filtroAno = ref(new Date().getFullYear())
|
|
||||||
|
|
||||||
const anosDisponiveis = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - i)
|
const {
|
||||||
|
tributos,
|
||||||
|
isLoading,
|
||||||
|
isLoadingPdf,
|
||||||
|
mensagemErro,
|
||||||
|
filtro,
|
||||||
|
totais,
|
||||||
|
consultar,
|
||||||
|
limparFiltros,
|
||||||
|
gerarPdf,
|
||||||
|
baixarComprovante,
|
||||||
|
} = useExtratoPagamentosPortal()
|
||||||
|
|
||||||
onMounted(() => carregar())
|
onMounted(() => consultar())
|
||||||
|
|
||||||
async function carregar() {
|
async function emitirComprovante(pag) {
|
||||||
carregando.value = true
|
|
||||||
mensagemErro.value = ''
|
|
||||||
try {
|
|
||||||
const res = await portalService.getPagamentos({ ano: filtroAno.value })
|
|
||||||
pagamentos.value = res.data?.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 portalService.getComprovante(pag.id)
|
const buf = await baixarComprovante(pag)
|
||||||
const url = URL.createObjectURL(new Blob([buf], { type: 'application/pdf' }))
|
if (buf?.byteLength) {
|
||||||
const a = document.createElement('a')
|
abrirPdf(buf)
|
||||||
a.href = url
|
} else {
|
||||||
a.download = `comprovante-${pag.id}.pdf`
|
mensagemErro.value = 'Comprovante não disponível para este pagamento.'
|
||||||
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">Histórico de Pagamentos</h1>
|
<h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100">Extrato de Pagamentos</h1>
|
||||||
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">Todos os seus pagamentos com comprovantes para download.</p>
|
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">Histórico de pagamentos com detalhamento por tributo.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4 space-y-4">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase mb-1.5">Período Débitos (início)</label>
|
||||||
|
<DatePicker v-model="filtro.debitosInicio" view="month" date-format="mm/yy" show-icon class="w-full" size="small" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase mb-1.5">Período Débitos (fim)</label>
|
||||||
|
<DatePicker v-model="filtro.debitosFim" view="month" date-format="mm/yy" show-icon class="w-full" size="small" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase mb-1.5">Pagamento (início)</label>
|
||||||
|
<DatePicker v-model="filtro.pagInicio" show-icon class="w-full" size="small" date-format="dd/mm/yy" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase mb-1.5">Pagamento (fim)</label>
|
||||||
|
<DatePicker v-model="filtro.pagFim" show-icon class="w-full" size="small" date-format="dd/mm/yy" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="flex gap-2 flex-wrap">
|
<div class="flex gap-2 flex-wrap">
|
||||||
<button
|
<Button label="Consultar" icon="pi pi-search" size="small" :loading="isLoading" @click="consultar" />
|
||||||
v-for="ano in anosDisponiveis"
|
<Button label="Limpar" icon="pi pi-eraser" size="small" severity="secondary" outlined @click="limparFiltros" />
|
||||||
:key="ano"
|
|
||||||
:class="['px-4 py-2 rounded-lg text-sm font-semibold border transition-colors', filtroAno === ano ? 'bg-primary text-white border-primary' : 'bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-700 hover:border-slate-300']"
|
|
||||||
@click="filtroAno = ano; carregar()"
|
|
||||||
>
|
|
||||||
{{ ano }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 overflow-hidden">
|
|
||||||
|
|
||||||
<div v-if="carregando" class="divide-y divide-slate-100 dark:divide-slate-700">
|
|
||||||
<div v-for="i in 5" :key="i" class="p-5 flex items-center gap-4">
|
|
||||||
<div class="flex-1 space-y-2">
|
|
||||||
<div class="h-3.5 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-2/5" />
|
|
||||||
<div class="h-3 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-1/4" />
|
|
||||||
</div>
|
|
||||||
<div class="h-4 bg-slate-200 dark:bg-slate-700 rounded animate-pulse w-20" />
|
|
||||||
<div class="h-8 bg-slate-200 dark:bg-slate-700 rounded-lg animate-pulse w-28" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="mensagemErro" class="p-8 text-center">
|
<Message v-if="mensagemErro" severity="warn" :closable="false">{{ mensagemErro }}</Message>
|
||||||
<p class="text-sm text-slate-600 dark:text-slate-300">{{ mensagemErro }}</p>
|
|
||||||
<Button label="Tentar novamente" severity="secondary" size="small" class="mt-4" @click="carregar" />
|
<div v-if="isLoading" class="space-y-3">
|
||||||
|
<div v-for="i in 3" :key="i" class="h-20 bg-slate-200 dark:bg-slate-700 rounded-xl animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="pagamentos.length === 0" class="p-12 text-center">
|
<div v-else-if="tributos.length === 0 && !mensagemErro" class="bg-white dark:bg-slate-800 rounded-xl border p-12 text-center">
|
||||||
<i class="pi pi-credit-card text-slate-300 dark:text-slate-600 text-4xl mb-3 block" aria-hidden="true" />
|
<p class="font-semibold text-slate-700 dark:text-slate-200">Nenhum pagamento encontrado</p>
|
||||||
<p class="font-semibold text-slate-700 dark:text-slate-200">Nenhum pagamento em {{ filtroAno }}</p>
|
|
||||||
<p class="text-sm text-slate-400 dark:text-slate-500 mt-1">Pagamentos realizados aparecerão aqui.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else>
|
<template v-else-if="tributos.length">
|
||||||
<div class="flex items-center gap-4 px-5 py-3 bg-slate-50 dark:bg-slate-700/50 border-b border-slate-200 dark:border-slate-700 text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide">
|
<Accordion :multiple="true" :active-index="[0]">
|
||||||
<span class="flex-1">Descrição</span>
|
<AccordionPanel v-for="(t, index) in tributos" :key="t.idContaTributo ?? index" :value="String(index)">
|
||||||
<span class="hidden sm:block w-28 text-right">Data</span>
|
<AccordionHeader>
|
||||||
<span class="hidden sm:block w-24 text-center">Forma</span>
|
<span class="text-sm font-semibold truncate">
|
||||||
<span class="w-28 text-right">Valor</span>
|
{{ t.descricaoTributo }} — {{ t.descricaoContaTributo }}
|
||||||
<span class="w-28" />
|
({{ t.pagamentos?.length ?? 0 }} pagamentos)
|
||||||
</div>
|
|
||||||
<div class="divide-y divide-slate-100 dark:divide-slate-700">
|
|
||||||
<div
|
|
||||||
v-for="pag in pagamentos"
|
|
||||||
:key="pag.id"
|
|
||||||
class="flex items-center gap-4 px-5 py-4 hover:bg-slate-50 dark:hover:bg-slate-700/30 transition-colors"
|
|
||||||
>
|
|
||||||
<div class="flex-1 min-w-0">
|
|
||||||
<p class="text-sm font-semibold text-slate-800 dark:text-slate-100 truncate">{{ pag.descricao }}</p>
|
|
||||||
<p class="text-xs text-slate-400 dark:text-slate-500 mt-0.5">Ref: {{ pag.referencia }}</p>
|
|
||||||
</div>
|
|
||||||
<p class="hidden sm:block text-sm text-slate-500 dark:text-slate-400 w-28 text-right whitespace-nowrap">{{ pag.dataPagamento }}</p>
|
|
||||||
<div class="hidden sm:flex w-24 justify-center">
|
|
||||||
<span class="inline-flex items-center gap-1.5 text-xs text-slate-500 dark:text-slate-400 bg-slate-100 dark:bg-slate-700 px-2 py-1 rounded-full">
|
|
||||||
<i :class="['pi', formaPagMap[pag.formaPagamento]?.icone ?? 'pi-circle', 'text-xs']" aria-hidden="true" />
|
|
||||||
{{ formaPagMap[pag.formaPagamento]?.label ?? pag.formaPagamento }}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</AccordionHeader>
|
||||||
<p class="text-sm font-bold text-emerald-600 dark:text-emerald-400 w-28 text-right whitespace-nowrap">
|
<AccordionContent>
|
||||||
{{ formatarMoeda(pag.valor) }}
|
<DataTable :value="t.pagamentos" size="small" show-gridlines scrollable class="mt-2">
|
||||||
</p>
|
<Column field="ndoc" header="Nº Doc" />
|
||||||
<div class="w-28 flex justify-end">
|
<Column field="refer" header="Período" />
|
||||||
|
<Column field="vencimento" header="Vencimento">
|
||||||
|
<template #body="{ data }">{{ formatDate(data.vencimento) }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="pagamento" header="Pagamento">
|
||||||
|
<template #body="{ data }">{{ formatDate(data.pagamento) }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="lancado" header="Principal" body-class="text-right">
|
||||||
|
<template #body="{ data }">{{ formatCurrency(data.lancado) }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="multa" header="Multa" body-class="text-right">
|
||||||
|
<template #body="{ data }">{{ formatCurrency(data.multa) }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="juros" header="Juros" body-class="text-right">
|
||||||
|
<template #body="{ data }">{{ formatCurrency(data.juros) }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="desconto" header="Desconto" body-class="text-right">
|
||||||
|
<template #body="{ data }">{{ formatCurrency(data.desconto) }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="total" header="Total" body-class="text-right">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<strong class="text-emerald-600">{{ formatCurrency(data.total) }}</strong>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="" style="width: 7rem">
|
||||||
|
<template #body="{ data }">
|
||||||
<Button
|
<Button
|
||||||
icon="pi pi-download"
|
icon="pi pi-print"
|
||||||
label="Comprovante"
|
label="Comprovante"
|
||||||
size="small"
|
|
||||||
text
|
text
|
||||||
:loading="carregandoComprovante === pag.id"
|
size="small"
|
||||||
@click="baixarComprovante(pag)"
|
:loading="carregandoComprovante === data.id"
|
||||||
|
:disabled="!data.idContaCorrente"
|
||||||
|
@click="emitirComprovante(data)"
|
||||||
/>
|
/>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionPanel>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl border p-4 flex flex-wrap items-end justify-between gap-4">
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-5 gap-4 text-sm">
|
||||||
|
<div><p class="text-slate-500 text-xs">Principal</p><p class="font-bold">{{ formatCurrency(totais.principal) }}</p></div>
|
||||||
|
<div><p class="text-slate-500 text-xs">Multa</p><p class="font-bold">{{ formatCurrency(totais.multa) }}</p></div>
|
||||||
|
<div><p class="text-slate-500 text-xs">Juros</p><p class="font-bold">{{ formatCurrency(totais.juros) }}</p></div>
|
||||||
|
<div><p class="text-slate-500 text-xs">Desconto</p><p class="font-bold">{{ formatCurrency(totais.desconto) }}</p></div>
|
||||||
|
<div><p class="text-slate-500 text-xs">Total</p><p class="font-bold text-emerald-600">{{ formatCurrency(totais.total) }}</p></div>
|
||||||
</div>
|
</div>
|
||||||
|
<Button label="Gerar PDF" icon="pi pi-file-pdf" size="small" :loading="isLoadingPdf" @click="gerarPdf" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
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',
|
||||||
@ -9,13 +10,34 @@ 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(async () => {
|
onMounted(carregar)
|
||||||
|
|
||||||
|
async function carregar() {
|
||||||
|
carregando.value = true
|
||||||
try {
|
try {
|
||||||
const [resResumo, resAtividades] = await Promise.all([
|
const [resResumo, resAtividades] = await Promise.all([
|
||||||
portalService.getPainelResumo(),
|
portalService.getPainelResumo(),
|
||||||
@ -23,28 +45,42 @@ onMounted(async () => {
|
|||||||
])
|
])
|
||||||
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 {
|
||||||
// silencioso — exibe zeros
|
if (MOCK_ATIVO) {
|
||||||
|
resumo.value = RESUMO_MOCK
|
||||||
|
atividades.value = ATIVIDADES_MOCK
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
carregando.value = false
|
carregando.value = false
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
const acesRapidos = [
|
const acesRapidos = [
|
||||||
{ icon: 'pi-receipt', label: 'Emitir Guia', to: '/portal/debitos', cor: 'text-primary' },
|
{ icon: 'pi-receipt', label: 'Extrato Débitos', to: '/portal/debitos', cor: 'text-primary' },
|
||||||
|
{ icon: 'pi-file', label: 'Guias Emitidas', to: '/portal/guias', cor: 'text-primary' },
|
||||||
|
{ icon: 'pi-file-export', label: 'Emitir Taxa', to: '/portal/taxas/emitir', cor: 'text-sky-600 dark:text-sky-400' },
|
||||||
{ icon: 'pi-file-check', label: 'Nova Certidão', to: '/portal/certidoes', cor: 'text-emerald-600 dark:text-emerald-400' },
|
{ icon: 'pi-file-check', label: 'Nova Certidão', to: '/portal/certidoes', cor: 'text-emerald-600 dark:text-emerald-400' },
|
||||||
{ icon: 'pi-briefcase', label: 'Acompanhar Alvará', to: '/portal/alvaras', cor: 'text-amber-600 dark:text-amber-400' },
|
{ icon: 'pi-credit-card', label: 'Pagamentos', to: '/portal/pagamentos', 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) {
|
||||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(valor ?? 0)
|
const n = Number(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',
|
||||||
}
|
}
|
||||||
@ -53,14 +89,25 @@ 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-4 gap-4">
|
<div class="grid grid-cols-2 lg:grid-cols-3 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">
|
||||||
@ -88,19 +135,6 @@ 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" />
|
||||||
@ -108,9 +142,14 @@ 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?.ultimoPagamento) }}</template>
|
<template v-else>{{ formatarMoeda(resumo?.valorUltimoPagamento) }}</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>
|
||||||
|
|
||||||
|
|||||||
233
src/pages/portal/taxas/emitir.vue
Normal file
233
src/pages/portal/taxas/emitir.vue
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import CamposEmissaoTaxa from '@/components/taxas/CamposEmissaoTaxa.vue'
|
||||||
|
import { useEmissaoTaxaPortal } from '@/composables/useEmissaoTaxaPortal'
|
||||||
|
import { formatarDocumento } from '@/utils/formatacao'
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'portal',
|
||||||
|
middleware: 'auth',
|
||||||
|
})
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const etapa = ref('formulario')
|
||||||
|
|
||||||
|
const {
|
||||||
|
contribuinte,
|
||||||
|
catalogo,
|
||||||
|
tributoSelecionadoId,
|
||||||
|
itensTributo,
|
||||||
|
itensInformativosDoc,
|
||||||
|
formulaSelecionada,
|
||||||
|
periodoReferencia,
|
||||||
|
vencimentoDebito,
|
||||||
|
vencimentoGuia,
|
||||||
|
valorTaxa,
|
||||||
|
valoresItens,
|
||||||
|
observacao,
|
||||||
|
totalizadores,
|
||||||
|
resultadoEmissao,
|
||||||
|
carregando,
|
||||||
|
carregandoTributo,
|
||||||
|
carregandoCalculo,
|
||||||
|
carregandoEmissao,
|
||||||
|
mensagemErro,
|
||||||
|
erros,
|
||||||
|
labelCatalogo,
|
||||||
|
carregarDadosIniciais,
|
||||||
|
onSelecionarTributo,
|
||||||
|
onPeriodoChange,
|
||||||
|
recalcularTotais,
|
||||||
|
emitirTaxa,
|
||||||
|
imprimirGuia,
|
||||||
|
reiniciar,
|
||||||
|
} = useEmissaoTaxaPortal()
|
||||||
|
|
||||||
|
onMounted(carregarDadosIniciais)
|
||||||
|
|
||||||
|
async function onTributoChange(id) {
|
||||||
|
await onSelecionarTributo(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
const ok = await emitirTaxa()
|
||||||
|
if (ok) etapa.value = 'resultado'
|
||||||
|
}
|
||||||
|
|
||||||
|
function novaEmissao() {
|
||||||
|
reiniciar()
|
||||||
|
etapa.value = 'formulario'
|
||||||
|
carregarDadosIniciais()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-6 max-w-3xl">
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<button
|
||||||
|
class="inline-flex items-center gap-2 text-sm text-slate-500 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200 transition-colors"
|
||||||
|
@click="router.push('/portal/taxas')"
|
||||||
|
>
|
||||||
|
<i class="pi pi-arrow-left text-xs" aria-hidden="true" />
|
||||||
|
Voltar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-slate-800 dark:text-slate-100">Emitir Taxa</h1>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400 mt-0.5">Emissão simplificada de taxas disponíveis para o seu cadastro.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="carregando" class="space-y-4">
|
||||||
|
<div v-for="i in 3" :key="i" class="h-20 bg-slate-200 dark:bg-slate-700 rounded-xl animate-pulse" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else-if="etapa === 'formulario'">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5 space-y-4">
|
||||||
|
<h2 class="text-sm font-semibold text-slate-700 dark:text-slate-200 uppercase tracking-wide">Contribuinte</h2>
|
||||||
|
<div class="grid sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1.5">CPF/CNPJ</label>
|
||||||
|
<InputText
|
||||||
|
:model-value="formatarDocumento(contribuinte?.documento)"
|
||||||
|
class="w-full"
|
||||||
|
size="small"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1.5">Nome</label>
|
||||||
|
<InputText
|
||||||
|
:model-value="contribuinte?.nomeCompleto"
|
||||||
|
class="w-full"
|
||||||
|
size="small"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-5 space-y-4">
|
||||||
|
<h2 class="text-sm font-semibold text-slate-700 dark:text-slate-200 uppercase tracking-wide">Dados da taxa</h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1.5">Taxa *</label>
|
||||||
|
<Select
|
||||||
|
:model-value="tributoSelecionadoId"
|
||||||
|
:options="catalogo"
|
||||||
|
:option-label="labelCatalogo"
|
||||||
|
option-value="id"
|
||||||
|
placeholder="Selecione a taxa"
|
||||||
|
show-clear
|
||||||
|
class="w-full"
|
||||||
|
size="small"
|
||||||
|
:loading="carregandoTributo"
|
||||||
|
@update:model-value="onTributoChange"
|
||||||
|
/>
|
||||||
|
<p v-if="erros.tributo" class="text-xs text-red-500 mt-1">{{ erros.tributo }}</p>
|
||||||
|
<p v-if="catalogo.length === 0" class="text-xs text-amber-600 dark:text-amber-400 mt-2">
|
||||||
|
Nenhuma taxa disponível para emissão no portal. Verifique com a prefeitura.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1.5">Período referência *</label>
|
||||||
|
<InputText
|
||||||
|
v-model="periodoReferencia"
|
||||||
|
type="month"
|
||||||
|
class="w-full"
|
||||||
|
size="small"
|
||||||
|
@change="onPeriodoChange"
|
||||||
|
/>
|
||||||
|
<p v-if="erros.periodoReferencia" class="text-xs text-red-500 mt-1">{{ erros.periodoReferencia }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1.5">Vencimento débito</label>
|
||||||
|
<InputText
|
||||||
|
:model-value="vencimentoDebito"
|
||||||
|
class="w-full"
|
||||||
|
size="small"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1.5">Vencimento guia *</label>
|
||||||
|
<InputText
|
||||||
|
v-model="vencimentoGuia"
|
||||||
|
type="date"
|
||||||
|
class="w-full"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<p v-if="erros.vencimentoGuia" class="text-xs text-red-500 mt-1">{{ erros.vencimentoGuia }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CamposEmissaoTaxa
|
||||||
|
v-if="tributoSelecionadoId"
|
||||||
|
:itens-tributo="itensTributo"
|
||||||
|
:itens-informativos-doc="itensInformativosDoc"
|
||||||
|
:formula-selecionada="formulaSelecionada"
|
||||||
|
:valores-itens="valoresItens"
|
||||||
|
:valor-taxa="valorTaxa"
|
||||||
|
:erros="erros"
|
||||||
|
@update:valores-itens="valoresItens = $event"
|
||||||
|
@update:valor-taxa="valorTaxa = $event"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-if="tributoSelecionadoId" class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
label="Calcular valores"
|
||||||
|
icon="pi pi-calculator"
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
:loading="carregandoCalculo"
|
||||||
|
@click="recalcularTotais"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="totalizadores" class="rounded-lg border border-slate-200 dark:border-slate-600 divide-y divide-slate-100 dark:divide-slate-700 text-sm">
|
||||||
|
<div class="flex justify-between px-4 py-2"><span class="text-slate-500">Principal</span><span>R$ {{ totalizadores.principal }}</span></div>
|
||||||
|
<div class="flex justify-between px-4 py-2"><span class="text-slate-500">Multa</span><span>R$ {{ totalizadores.multa }}</span></div>
|
||||||
|
<div class="flex justify-between px-4 py-2"><span class="text-slate-500">Juros</span><span>R$ {{ totalizadores.juros }}</span></div>
|
||||||
|
<div class="flex justify-between px-4 py-3 font-bold text-slate-800 dark:text-slate-100"><span>Total</span><span>R$ {{ totalizadores.total }}</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1.5">Observação</label>
|
||||||
|
<InputText v-model="observacao" class="w-full" size="small" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="mensagemErro" class="text-sm text-red-600 dark:text-red-400">{{ mensagemErro }}</p>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
label="Emitir taxa"
|
||||||
|
icon="pi pi-check"
|
||||||
|
:loading="carregandoEmissao"
|
||||||
|
:disabled="!tributoSelecionadoId"
|
||||||
|
@click="onSubmit"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl border border-emerald-200 dark:border-emerald-800 p-8 text-center space-y-4">
|
||||||
|
<div class="w-14 h-14 bg-emerald-100 dark:bg-emerald-900/30 rounded-full flex items-center justify-center mx-auto">
|
||||||
|
<i class="pi pi-check text-emerald-600 dark:text-emerald-400 text-2xl" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-bold text-slate-800 dark:text-slate-100">Taxa emitida com sucesso</h2>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">
|
||||||
|
Protocolo: <strong>{{ resultadoEmissao?.numeroProtocolo || '—' }}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap justify-center gap-3">
|
||||||
|
<Button label="Imprimir guia" icon="pi pi-file-pdf" @click="imprimirGuia" />
|
||||||
|
<Button label="Ver taxas emitidas" severity="secondary" outlined @click="router.push('/portal/taxas')" />
|
||||||
|
<Button label="Nova emissão" severity="secondary" text @click="novaEmissao" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
238
src/pages/portal/taxas/index.vue
Normal file
238
src/pages/portal/taxas/index.vue
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { taxaService } from '@/services/taxaService'
|
||||||
|
import { formatarMoeda, baixarPdf } from '@/utils/formatacao'
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'portal',
|
||||||
|
middleware: 'auth',
|
||||||
|
})
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const taxas = ref([])
|
||||||
|
const carregando = ref(true)
|
||||||
|
const carregandoPdf = ref(null)
|
||||||
|
const mensagemErro = ref('')
|
||||||
|
const pagina = ref(0)
|
||||||
|
const totalPaginas = ref(0)
|
||||||
|
const totalElementos = ref(0)
|
||||||
|
|
||||||
|
const filtroStatus = ref(null)
|
||||||
|
const filtroProtocolo = ref('')
|
||||||
|
|
||||||
|
const statusOpcoes = [
|
||||||
|
{ value: 1, label: 'Emitida', classe: 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400' },
|
||||||
|
{ value: 2, label: '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>
|
||||||
@ -1,10 +1,8 @@
|
|||||||
<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)
|
||||||
@ -12,21 +10,12 @@ 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 senhaForte = computed(() => senha.value.length >= 8)
|
const subtitulos = ['Identificação', 'Confirmação', 'Pronto!']
|
||||||
const senhasIguais = computed(() => senha.value === senhaConfirm.value)
|
|
||||||
|
|
||||||
async function identificar() {
|
async function identificar() {
|
||||||
if (!docValido.value) return
|
if (!docValido.value) return
|
||||||
@ -35,81 +24,37 @@ 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
|
||||||
canais.value = res.data.canais
|
const canalEmail = res.data.canais?.find(c => c.tipo === 'EMAIL')
|
||||||
canalSelecionado.value = canais.value[0] ?? null
|
emailMascarado.value = canalEmail?.valor ?? ''
|
||||||
etapa.value = 1
|
etapa.value = 1
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
erro.value = e?.data?.description ?? 'Documento não encontrado ou não habilitado para este fluxo.'
|
erro.value = e?.data?.description ?? 'Documento não encontrado ou não habilitado para acesso ao portal.'
|
||||||
} finally {
|
} finally {
|
||||||
carregando.value = false
|
carregando.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function enviarCodigo() {
|
async function solicitar() {
|
||||||
if (!canalSelecionado.value) return
|
|
||||||
carregando.value = true
|
carregando.value = true
|
||||||
erro.value = ''
|
erro.value = ''
|
||||||
try {
|
try {
|
||||||
await primeiroAcessoService.solicitarCodigo(docDigitos.value, canalSelecionado.value.tipo)
|
await primeiroAcessoService.solicitarAcesso(docDigitos.value)
|
||||||
etapa.value = 2
|
etapa.value = 2
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
erro.value = e?.data?.description ?? 'Não foi possível enviar o código. Tente novamente.'
|
erro.value = e?.data?.description ?? 'Não foi possível solicitar o acesso. 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 < 4" class="flex items-center justify-center gap-2 mb-8">
|
<div v-if="etapa < 2" class="flex items-center justify-center gap-2 mb-8">
|
||||||
<div
|
<div
|
||||||
v-for="i in 4"
|
v-for="i in 2"
|
||||||
: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="[
|
||||||
@ -128,9 +73,7 @@ const labelCanal = { EMAIL: 'E-mail', SMS: 'SMS', WHATSAPP: 'WhatsApp' }
|
|||||||
</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">
|
<p class="text-white/80 text-xs mt-0.5">{{ subtitulos[etapa] }}</p>
|
||||||
{{ ['Identificação', 'Canal de envio', 'Código de verificação', 'Crie sua senha', 'Pronto!'][etapa] }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -139,7 +82,7 @@ const labelCanal = { EMAIL: 'E-mail', SMS: 'SMS', WHATSAPP: 'WhatsApp' }
|
|||||||
|
|
||||||
<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 de senha.
|
Informe seu CPF ou CNPJ para verificarmos seu cadastro e iniciar a criação do seu acesso ao portal.
|
||||||
</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>
|
||||||
@ -148,102 +91,82 @@ const labelCanal = { EMAIL: 'E-mail', SMS: 'SMS', WHATSAPP: 'WhatsApp' }
|
|||||||
<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 label="Continuar" icon="pi pi-arrow-right" icon-pos="right" class="w-full" size="large" :loading="carregando" :disabled="!docValido" @click="identificar" />
|
<Button
|
||||||
|
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>
|
<div class="space-y-3">
|
||||||
<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">
|
||||||
Enviaremos um código de verificação. Escolha como prefere receber:
|
Olá, <strong class="text-slate-800 dark:text-slate-100">{{ contribuinteNome }}</strong>.
|
||||||
|
</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">
|
|
||||||
<button
|
<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">
|
||||||
v-for="canal in canais"
|
<i class="pi pi-envelope text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" aria-hidden="true" />
|
||||||
:key="canal.tipo"
|
<p class="text-sm text-blue-700 dark:text-blue-300">
|
||||||
class="w-full flex items-center gap-3 p-4 rounded-xl border transition-colors text-left"
|
O link de acesso é enviado pelo Keycloak e expira em 24 horas. Verifique também a caixa de spam.
|
||||||
:class="canalSelecionado?.tipo === canal.tipo
|
</p>
|
||||||
? '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" @click="etapa = 0" />
|
<Button label="Voltar" severity="secondary" outlined class="flex-1" :disabled="carregando" @click="etapa = 0" />
|
||||||
<Button label="Enviar código" icon="pi pi-send" class="flex-1" :loading="carregando" :disabled="!canalSelecionado" @click="enviarCodigo" />
|
<Button
|
||||||
|
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">Senha criada com sucesso!</h2>
|
<h2 class="text-lg font-bold text-slate-800 dark:text-slate-100 mb-2">E-mail enviado!</h2>
|
||||||
<p class="text-sm text-slate-500 dark:text-slate-400">
|
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||||
Você já pode acessar o portal com seu CPF/CNPJ e a nova senha.
|
Acesse o e-mail cadastrado e clique no link para definir sua senha e acessar o portal.
|
||||||
|
</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 label="Ir para o login" icon="pi pi-sign-in" class="w-full" size="large" :loading="carregando" @click="entrarKeycloak" />
|
<Button
|
||||||
|
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 < 4" class="text-center mt-4">
|
<div v-if="etapa < 2" 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('/')"
|
||||||
|
|||||||
@ -1,32 +1,153 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { z } from 'zod'
|
||||||
import { useAuth } from '@/composables/useAuth'
|
import { useAuth } from '@/composables/useAuth'
|
||||||
import { certidaoService } from '@/services/certidaoService'
|
import { certidaoService } from '@/services/certidaoService'
|
||||||
|
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 tipoCertidao = ref('negativa')
|
const idModeloSelecionado = ref(null)
|
||||||
|
const finalidade = ref('')
|
||||||
|
const modelos = ref([])
|
||||||
const etapa = ref('formulario')
|
const etapa = ref('formulario')
|
||||||
|
const carregandoModelos = ref(false)
|
||||||
const carregandoConsulta = ref(false)
|
const carregandoConsulta = ref(false)
|
||||||
const carregandoEmissao = ref(false)
|
const carregandoEmissao = ref(false)
|
||||||
const resultado = ref(null)
|
const resultado = ref(null)
|
||||||
|
const certidaoEmitida = ref(null)
|
||||||
|
const certidaoExistente = ref(null)
|
||||||
|
const carregandoDownloadExistente = ref(false)
|
||||||
const mensagemErro = ref('')
|
const mensagemErro = ref('')
|
||||||
|
const erros = ref({})
|
||||||
|
|
||||||
const tiposCertidao = [
|
const schemaEmissao = z.object({
|
||||||
{ value: 'negativa', label: 'Certidão Negativa', descricao: 'Confirma que não há débitos pendentes.' },
|
idModeloSelecionado: z
|
||||||
{ value: 'positiva_efeitos_negativa', label: 'Positiva com Efeitos de Negativa', descricao: 'Débitos com parcelamento em dia ou com exigibilidade suspensa.' },
|
.number({ required_error: 'Selecione o modelo de certidão.' })
|
||||||
{ value: 'positiva', label: 'Certidão Positiva', descricao: 'Confirma a existência de débitos.' },
|
.nullable()
|
||||||
]
|
.refine(v => v !== null, 'Selecione o modelo de certidão.'),
|
||||||
|
finalidade: z.string().trim().min(1, 'Finalidade é obrigatória.'),
|
||||||
|
})
|
||||||
|
|
||||||
const docValido = computed(() => {
|
const docValido = computed(() => {
|
||||||
const d = documento.value.replace(/\D/g, '')
|
const d = documento.value.replace(/\D/g, '')
|
||||||
return d.length === 11 || d.length === 14
|
if (d.length === 11) return validarCpf(d)
|
||||||
|
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 {
|
||||||
@ -34,34 +155,104 @@ async function consultar() {
|
|||||||
resultado.value = res.data
|
resultado.value = res.data
|
||||||
etapa.value = 'resultado'
|
etapa.value = 'resultado'
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
mensagemErro.value = e?.data?.description ?? 'Não foi possível consultar a situação fiscal. Tente novamente.'
|
mensagemErro.value = extrairErro(e) ?? 'Não foi possível consultar a situação fiscal. Tente novamente.'
|
||||||
} finally {
|
} finally {
|
||||||
carregandoConsulta.value = false
|
carregandoConsulta.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function emitir() {
|
async function emitir() {
|
||||||
|
if (!validarFormulario()) return
|
||||||
carregandoEmissao.value = true
|
carregandoEmissao.value = true
|
||||||
|
mensagemErro.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const buf = await certidaoService.emitir(documento.value, tipoCertidao.value)
|
const resValida = await certidaoService.verificarValida(documento.value, idModeloSelecionado.value)
|
||||||
|
const existente = resValida?.data?.data ?? resValida?.data
|
||||||
|
if (existente?.id != null) {
|
||||||
|
certidaoExistente.value = existente
|
||||||
|
etapa.value = 'certidaoExistente'
|
||||||
|
carregandoEmissao.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Pré-checagem indisponível — a emissão é bloqueada de forma autoritativa
|
||||||
|
// 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 url = URL.createObjectURL(new Blob([buf], { type: 'application/pdf' }))
|
||||||
const a = document.createElement('a')
|
const a = document.createElement('a')
|
||||||
a.href = url
|
a.href = url
|
||||||
a.download = `certidao-${tipoCertidao.value}-${documento.value}.pdf`
|
a.download = `certidao-${slug}-${documento.value.replace(/\D/g, '')}.pdf`
|
||||||
|
document.body.appendChild(a)
|
||||||
a.click()
|
a.click()
|
||||||
URL.revokeObjectURL(url)
|
document.body.removeChild(a)
|
||||||
} catch {
|
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||||
mensagemErro.value = 'Erro ao gerar o PDF. Tente novamente.'
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function reiniciar() {
|
async function downloadCertidaoExistente() {
|
||||||
documento.value = ''
|
carregandoDownloadExistente.value = true
|
||||||
resultado.value = null
|
|
||||||
mensagemErro.value = ''
|
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() {
|
||||||
|
documento.value = isAuthenticated.value ? docUsuarioLogado.value : ''
|
||||||
|
idModeloSelecionado.value = null
|
||||||
|
finalidade.value = ''
|
||||||
|
resultado.value = null
|
||||||
|
certidaoEmitida.value = null
|
||||||
|
certidaoExistente.value = null
|
||||||
|
mensagemErro.value = ''
|
||||||
|
erros.value = {}
|
||||||
etapa.value = 'formulario'
|
etapa.value = 'formulario'
|
||||||
|
resetModelos()
|
||||||
|
if (docValido.value) carregarModelos()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -70,10 +261,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('/servicos')"
|
@click="router.push(voltarPara)"
|
||||||
>
|
>
|
||||||
<i class="pi pi-arrow-left text-xs" aria-hidden="true" />
|
<i class="pi pi-arrow-left text-xs" aria-hidden="true" />
|
||||||
Voltar aos serviços
|
{{ labelVoltar }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="flex items-center gap-4 mb-8">
|
<div class="flex items-center gap-4 mb-8">
|
||||||
@ -97,30 +288,52 @@ function reiniciar() {
|
|||||||
:disabled="isAuthenticated"
|
:disabled="isAuthenticated"
|
||||||
@keyup.enter="consultar"
|
@keyup.enter="consultar"
|
||||||
/>
|
/>
|
||||||
<p v-if="isAuthenticated" class="mt-1.5 text-xs text-slate-500 dark:text-slate-400 flex items-center gap-1.5">
|
<p v-if="docInvalido && !isAuthenticated" class="mt-1.5 text-xs text-red-600 dark:text-red-400 flex items-center gap-1.5">
|
||||||
<i class="pi pi-info-circle text-xs text-primary" aria-hidden="true" />
|
<i class="pi pi-exclamation-circle" aria-hidden="true" />
|
||||||
Logado como <strong class="text-slate-700 dark:text-slate-200">{{ nomeUsuario }}</strong> — documento bloqueado para segurança.
|
CPF ou CNPJ inválido.
|
||||||
|
</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>
|
<div v-if="docValido">
|
||||||
<p class="text-sm font-semibold text-slate-700 dark:text-slate-200 mb-3">Tipo de certidão</p>
|
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">
|
||||||
<div class="space-y-2">
|
Modelo de certidão
|
||||||
<label
|
|
||||||
v-for="tipo in tiposCertidao"
|
|
||||||
:key="tipo.value"
|
|
||||||
class="flex items-start gap-3 p-4 rounded-xl border cursor-pointer transition-colors"
|
|
||||||
:class="tipoCertidao === tipo.value
|
|
||||||
? 'border-primary bg-primary/5 dark:bg-primary/10'
|
|
||||||
: 'border-slate-200 dark:border-slate-600 hover:border-slate-300 dark:hover:border-slate-500'"
|
|
||||||
>
|
|
||||||
<RadioButton v-model="tipoCertidao" :value="tipo.value" :input-id="tipo.value" class="mt-0.5 flex-shrink-0" />
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-semibold text-slate-800 dark:text-slate-100">{{ tipo.label }}</p>
|
|
||||||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-0.5">{{ tipo.descricao }}</p>
|
|
||||||
</div>
|
|
||||||
</label>
|
</label>
|
||||||
|
<Select
|
||||||
|
v-model="idModeloSelecionado"
|
||||||
|
:options="modelos"
|
||||||
|
option-label="titulo"
|
||||||
|
option-value="id"
|
||||||
|
placeholder="Selecione o modelo de certidão"
|
||||||
|
class="w-full"
|
||||||
|
:class="{ 'p-invalid': erros.idModeloSelecionado }"
|
||||||
|
:loading="carregandoModelos"
|
||||||
|
:disabled="carregandoModelos || modelos.length === 0"
|
||||||
|
/>
|
||||||
|
<p v-if="erros.idModeloSelecionado" class="mt-1.5 text-xs text-red-600 dark:text-red-400">
|
||||||
|
{{ erros.idModeloSelecionado }}
|
||||||
|
</p>
|
||||||
|
<p v-else-if="modeloSelecionado?.descricaoTipoCertidao" class="mt-1.5 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{{ modeloSelecionado.descricaoTipoCertidao }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="docValido">
|
||||||
|
<label class="block text-sm font-semibold text-slate-700 dark:text-slate-200 mb-1.5">
|
||||||
|
Finalidade da certidão
|
||||||
|
</label>
|
||||||
|
<InputText
|
||||||
|
v-model="finalidade"
|
||||||
|
class="w-full"
|
||||||
|
:class="{ 'p-invalid': erros.finalidade }"
|
||||||
|
placeholder="Informe a finalidade (ex.: licitação, financiamento)"
|
||||||
|
/>
|
||||||
|
<p v-if="erros.finalidade" class="mt-1.5 text-xs text-red-600 dark:text-red-400">
|
||||||
|
{{ erros.finalidade }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="mensagemErro" role="alert" class="text-sm text-red-600 dark:text-red-400 flex items-center gap-1.5">
|
<p v-if="mensagemErro" role="alert" class="text-sm text-red-600 dark:text-red-400 flex items-center gap-1.5">
|
||||||
@ -134,7 +347,7 @@ function reiniciar() {
|
|||||||
class="w-full"
|
class="w-full"
|
||||||
size="large"
|
size="large"
|
||||||
:loading="carregandoConsulta"
|
:loading="carregandoConsulta"
|
||||||
:disabled="!docValido"
|
:disabled="!docValido || carregandoModelos || modelos.length === 0"
|
||||||
@click="consultar"
|
@click="consultar"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -175,10 +388,13 @@ function reiniciar() {
|
|||||||
<div class="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 p-6">
|
<div class="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 p-6">
|
||||||
<p class="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1">Certidão a emitir</p>
|
<p class="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wide mb-1">Certidão a emitir</p>
|
||||||
<p class="font-semibold text-slate-800 dark:text-slate-100">
|
<p class="font-semibold text-slate-800 dark:text-slate-100">
|
||||||
{{ tiposCertidao.find(t => t.value === tipoCertidao)?.label }}
|
{{ modeloSelecionado?.titulo ?? '—' }}
|
||||||
|
</p>
|
||||||
|
<p v-if="finalidade" class="text-sm text-slate-600 dark:text-slate-300 mt-2">
|
||||||
|
Finalidade: {{ finalidade }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-slate-500 dark:text-slate-400 mt-4">
|
<p class="text-xs text-slate-500 dark:text-slate-400 mt-4">
|
||||||
Validade: <span class="font-medium">180 dias a partir da emissão</span>
|
Validade: <span class="font-medium">{{ validadeLabel }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -201,10 +417,126 @@ 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">Já 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">Nº 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>
|
||||||
|
|||||||
@ -4,8 +4,16 @@ 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('')
|
||||||
@ -29,7 +37,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 ?? []
|
imoveis.value = res.data?.content ?? 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) {
|
||||||
@ -92,10 +100,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('/servicos')"
|
@click="router.push(voltarPara)"
|
||||||
>
|
>
|
||||||
<i class="pi pi-arrow-left text-xs" aria-hidden="true" />
|
<i class="pi pi-arrow-left text-xs" aria-hidden="true" />
|
||||||
Voltar aos serviços
|
{{ labelVoltar }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="flex items-center gap-4 mb-8">
|
<div class="flex items-center gap-4 mb-8">
|
||||||
@ -156,9 +164,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-center gap-1.5">
|
<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">
|
||||||
<i class="pi pi-info-circle text-xs text-primary" aria-hidden="true" />
|
<i class="pi pi-info-circle text-xs text-primary mt-0.5 shrink-0" aria-hidden="true" />
|
||||||
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.</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -180,7 +188,19 @@ 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 > 1" class="bg-white dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700 p-6">
|
<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 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>
|
||||||
@ -213,6 +233,7 @@ 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>
|
||||||
@ -239,6 +260,7 @@ 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>
|
||||||
|
|||||||
9
src/services/avisoService.js
Normal file
9
src/services/avisoService.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
const FETCH_HEADERS = { 'X-Requested-With': 'fetch' }
|
||||||
|
|
||||||
|
export const avisoService = {
|
||||||
|
listar(dominio) {
|
||||||
|
return $fetch(`/api/proxy/publico/avisos/${dominio ?? 'portal'}`, {
|
||||||
|
headers: FETCH_HEADERS,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
@ -12,10 +12,31 @@ export const certidaoService = {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
emitir(documento, tipoCertidao) {
|
listarModelos(documento, params = {}) {
|
||||||
|
return $fetch(proxyUrl('/publico/certidao/modelos'), {
|
||||||
|
headers: FETCH_HEADERS,
|
||||||
|
query: { documento, ...params },
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
emitir(documento, idModelo, finalidade) {
|
||||||
return $fetch(proxyUrl('/publico/certidao/emitir'), {
|
return $fetch(proxyUrl('/publico/certidao/emitir'), {
|
||||||
headers: FETCH_HEADERS,
|
headers: FETCH_HEADERS,
|
||||||
query: { documento, tipoCertidao },
|
query: { documento, idModelo, finalidade },
|
||||||
|
responseType: 'arrayBuffer',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
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',
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,6 +1,4 @@
|
|||||||
// Todas as chamadas vão via /api/proxy/** (BFF injeta Bearer + tenant headers).
|
// Todas as chamadas vão via /api/proxy/** (BFF injeta Bearer + tenant headers).
|
||||||
// Cada método retorna o envelope cru do core-api: { data, message, statusCode, ... }
|
|
||||||
// — exceto rotas binárias (PDF), que retornam ArrayBuffer.
|
|
||||||
|
|
||||||
const FETCH_HEADERS = { 'X-Requested-With': 'fetch' }
|
const FETCH_HEADERS = { 'X-Requested-With': 'fetch' }
|
||||||
|
|
||||||
@ -8,8 +6,15 @@ function proxyUrl(path) {
|
|||||||
return `/api/proxy${path}`
|
return `/api/proxy${path}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function limparParams(params) {
|
||||||
|
const out = {}
|
||||||
|
for (const [k, v] of Object.entries(params)) {
|
||||||
|
if (v !== null && v !== undefined && v !== '') out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
export const portalService = {
|
export const portalService = {
|
||||||
// ─── Painel ──────────────────────────────────────────────────────────────
|
|
||||||
getPainelResumo() {
|
getPainelResumo() {
|
||||||
return $fetch(proxyUrl('/contribuinte/painel/resumo'), { headers: FETCH_HEADERS })
|
return $fetch(proxyUrl('/contribuinte/painel/resumo'), { headers: FETCH_HEADERS })
|
||||||
},
|
},
|
||||||
@ -21,17 +26,45 @@ export const portalService = {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
// ─── Débitos ─────────────────────────────────────────────────────────────
|
// ─── Débitos / Extrato ───────────────────────────────────────────────────
|
||||||
getDebitos(params = {}) {
|
getDebitosExtrato(params = {}) {
|
||||||
return $fetch(proxyUrl('/contribuinte/debitos'), {
|
return $fetch(proxyUrl('/contribuinte/debitos'), {
|
||||||
headers: FETCH_HEADERS,
|
headers: FETCH_HEADERS,
|
||||||
query: params,
|
query: limparParams(params),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
emitirGuia(idDebito) {
|
getTransacoes(idContaCorrente) {
|
||||||
return $fetch(proxyUrl(`/contribuinte/debitos/${idDebito}/guia`), {
|
return $fetch(proxyUrl(`/contribuinte/debitos/${idContaCorrente}/transacoes`), { headers: FETCH_HEADERS })
|
||||||
|
},
|
||||||
|
|
||||||
|
gerarGuiaDebitos(dto) {
|
||||||
|
return $fetch(proxyUrl('/contribuinte/debitos/gerar-guia'), {
|
||||||
|
method: 'POST',
|
||||||
headers: FETCH_HEADERS,
|
headers: FETCH_HEADERS,
|
||||||
|
body: dto,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
baixarGuiaPdf(idDoctoArr) {
|
||||||
|
return $fetch(proxyUrl(`/contribuinte/debitos/guia/${idDoctoArr}`), {
|
||||||
|
headers: FETCH_HEADERS,
|
||||||
|
responseType: 'arrayBuffer',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
emitirGuia(idContaCorrente) {
|
||||||
|
return $fetch(proxyUrl(`/contribuinte/debitos/${idContaCorrente}/guia`), {
|
||||||
|
headers: FETCH_HEADERS,
|
||||||
|
responseType: 'arrayBuffer',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
gerarExtratoDebitosPdf(dto) {
|
||||||
|
return $fetch(proxyUrl('/contribuinte/debitos/extrato-pdf'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: FETCH_HEADERS,
|
||||||
|
body: dto,
|
||||||
responseType: 'arrayBuffer',
|
responseType: 'arrayBuffer',
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@ -48,24 +81,44 @@ export const portalService = {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
// ─── Alvarás ─────────────────────────────────────────────────────────────
|
// ─── Pagamentos / Extrato ─────────────────────────────────────────────────
|
||||||
getAlvaras(params = {}) {
|
getPagamentosExtrato(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: params,
|
query: limparParams(params),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
getComprovante(idPagamento) {
|
gerarExtratoPagamentosPdf(dto) {
|
||||||
return $fetch(proxyUrl(`/contribuinte/pagamentos/${idPagamento}/comprovante`), {
|
return $fetch(proxyUrl('/contribuinte/pagamentos/extrato-pdf'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: FETCH_HEADERS,
|
||||||
|
body: dto,
|
||||||
|
responseType: 'arrayBuffer',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
getComprovante(idContaCorrente) {
|
||||||
|
return $fetch(proxyUrl(`/contribuinte/pagamentos/${idContaCorrente}/comprovante`), {
|
||||||
|
headers: FETCH_HEADERS,
|
||||||
|
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',
|
||||||
})
|
})
|
||||||
|
|||||||
@ -12,6 +12,15 @@ 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',
|
||||||
|
|||||||
67
src/services/taxaService.js
Normal file
67
src/services/taxaService.js
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
const FETCH_HEADERS = { 'X-Requested-With': 'fetch' }
|
||||||
|
|
||||||
|
function proxyUrl(path) {
|
||||||
|
return `/api/proxy${path}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const taxaService = {
|
||||||
|
listar(params = {}) {
|
||||||
|
return $fetch(proxyUrl('/contribuinte/taxas'), {
|
||||||
|
headers: FETCH_HEADERS,
|
||||||
|
query: params,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
getCatalogo() {
|
||||||
|
return $fetch(proxyUrl('/contribuinte/taxas/catalogo'), { headers: FETCH_HEADERS })
|
||||||
|
},
|
||||||
|
|
||||||
|
getTributo(id) {
|
||||||
|
return $fetch(proxyUrl(`/contribuinte/taxas/tributo/${id}`), { headers: FETCH_HEADERS })
|
||||||
|
},
|
||||||
|
|
||||||
|
calcularVencimento(payload) {
|
||||||
|
return $fetch(proxyUrl('/contribuinte/taxas/calcular-vencimento'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: FETCH_HEADERS,
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
calcularMultaJuros(payload) {
|
||||||
|
return $fetch(proxyUrl('/contribuinte/taxas/calcular-multa-juros'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: FETCH_HEADERS,
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
lancar(payload) {
|
||||||
|
return $fetch(proxyUrl('/contribuinte/taxas/lancar'), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: FETCH_HEADERS,
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
baixarGuia(id) {
|
||||||
|
return $fetch(proxyUrl(`/contribuinte/taxas/${id}/guia`), {
|
||||||
|
headers: FETCH_HEADERS,
|
||||||
|
responseType: 'arrayBuffer',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
baixarComprovante(id) {
|
||||||
|
return $fetch(proxyUrl(`/contribuinte/taxas/${id}/comprovante`), {
|
||||||
|
headers: FETCH_HEADERS,
|
||||||
|
responseType: 'arrayBuffer',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
baixarAutorizacao(id) {
|
||||||
|
return $fetch(proxyUrl(`/contribuinte/taxas/${id}/autorizacao`), {
|
||||||
|
headers: FETCH_HEADERS,
|
||||||
|
responseType: 'arrayBuffer',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
}
|
||||||
42
src/utils/atributoMascara.js
Normal file
42
src/utils/atributoMascara.js
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
export const TIPO_DADO_TEXTO = 1
|
||||||
|
export const TIPO_DADO_NUMERICO = 2
|
||||||
|
export const TIPO_DADO_DATA = 3
|
||||||
|
|
||||||
|
export function isMascaraNumerica(mascara) {
|
||||||
|
if (!mascara || typeof mascara !== 'string') return false
|
||||||
|
const trimmed = mascara.trim()
|
||||||
|
if (!trimmed) return false
|
||||||
|
if (/[9a*]/i.test(trimmed) && !/[#]/.test(trimmed)) return false
|
||||||
|
return /[#0]/.test(trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMaskConfig(mascara) {
|
||||||
|
if (!mascara) return null
|
||||||
|
const isPct = mascara.includes('%')
|
||||||
|
const commaIdx = mascara.lastIndexOf(',')
|
||||||
|
if (commaIdx !== -1) {
|
||||||
|
const afterComma = mascara.substring(commaIdx + 1).replace(/[^#0]/g, '')
|
||||||
|
return { fractionDigits: afterComma.length, suffix: isPct ? ' %' : '' }
|
||||||
|
}
|
||||||
|
if (/[#0]/.test(mascara)) return { fractionDigits: 0, suffix: isPct ? ' %' : '' }
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inferirTipoDado(mascara, tipoDadoAtual) {
|
||||||
|
if (tipoDadoAtual != null) return tipoDadoAtual
|
||||||
|
if (!mascara) return TIPO_DADO_TEXTO
|
||||||
|
if (isMascaraNumerica(mascara)) return TIPO_DADO_NUMERICO
|
||||||
|
return TIPO_DADO_TEXTO
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCampoInformativo(item) {
|
||||||
|
const tipoDado = inferirTipoDado(item.mascara, item.tipoDado)
|
||||||
|
const mascara = item.mascara || ''
|
||||||
|
if (tipoDado === TIPO_DADO_DATA) {
|
||||||
|
return { tipoCampo: 'date', tipoDado, mascara, maskConfig: null }
|
||||||
|
}
|
||||||
|
if (tipoDado === TIPO_DADO_NUMERICO || isMascaraNumerica(mascara)) {
|
||||||
|
return { tipoCampo: 'number', tipoDado: TIPO_DADO_NUMERICO, mascara, maskConfig: getMaskConfig(mascara) }
|
||||||
|
}
|
||||||
|
return { tipoCampo: 'text', tipoDado: TIPO_DADO_TEXTO, mascara, maskConfig: null }
|
||||||
|
}
|
||||||
66
src/utils/formatacao.js
Normal file
66
src/utils/formatacao.js
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
export function formatarMoeda(valor) {
|
||||||
|
if (valor == null || valor === '') return '0,00'
|
||||||
|
const numero = typeof valor === 'string'
|
||||||
|
? parseFloat(valor.replace(/\./g, '').replace(',', '.'))
|
||||||
|
: valor
|
||||||
|
if (isNaN(numero)) return '0,00'
|
||||||
|
return numero.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatarMoedaInput(valor) {
|
||||||
|
if (!valor) return ''
|
||||||
|
const apenasNumeros = String(valor).replace(/\D/g, '')
|
||||||
|
return (Number(apenasNumeros) / 100).toLocaleString('pt-BR', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function obterValorNumerico(valorFormatado) {
|
||||||
|
if (!valorFormatado) return 0
|
||||||
|
return parseFloat(String(valorFormatado).replace(/\./g, '').replace(',', '.')) || 0
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatarDataParaAPI(data) {
|
||||||
|
if (!data) return new Date().toISOString().split('T')[0]
|
||||||
|
if (typeof data === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(data)) return data
|
||||||
|
try {
|
||||||
|
const dataObj = typeof data === 'string' ? new Date(data) : data
|
||||||
|
if (isNaN(dataObj.getTime())) return new Date().toISOString().split('T')[0]
|
||||||
|
const ano = dataObj.getFullYear()
|
||||||
|
const mes = String(dataObj.getMonth() + 1).padStart(2, '0')
|
||||||
|
const dia = String(dataObj.getDate()).padStart(2, '0')
|
||||||
|
return `${ano}-${mes}-${dia}`
|
||||||
|
} catch {
|
||||||
|
return new Date().toISOString().split('T')[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function periodoMesParaAPI(periodoMes) {
|
||||||
|
if (!periodoMes) return null
|
||||||
|
const [ano, mes] = periodoMes.split('-')
|
||||||
|
if (!ano || !mes) return null
|
||||||
|
return Number(`${ano}${mes}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatarDocumento(doc) {
|
||||||
|
if (!doc) return ''
|
||||||
|
const d = doc.replace(/\D/g, '')
|
||||||
|
if (d.length === 11) {
|
||||||
|
return d.replace(/(\d{3})(\d{3})(\d{3})(\d{2})/, '$1.$2.$3-$4')
|
||||||
|
}
|
||||||
|
if (d.length === 14) {
|
||||||
|
return doc.toUpperCase().replace(/[^A-Z0-9]/g, '')
|
||||||
|
.replace(/^(.{2})(.{3})(.{3})(.{4})(.{2})$/, '$1.$2.$3/$4-$5')
|
||||||
|
}
|
||||||
|
return doc
|
||||||
|
}
|
||||||
|
|
||||||
|
export function baixarPdf(buf, filename) {
|
||||||
|
const url = URL.createObjectURL(new Blob([buf], { type: 'application/pdf' }))
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = filename
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
76
src/utils/formatador.js
Normal file
76
src/utils/formatador.js
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
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])
|
||||||
|
}
|
||||||
38
src/utils/formulaCalculo.js
Normal file
38
src/utils/formulaCalculo.js
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
function avaliarExpressaoMatematica(expr) {
|
||||||
|
expr = expr.replace(/\s+/g, '')
|
||||||
|
if (!/^[0-9+\-*/().]+$/.test(expr)) throw new Error('Expressão inválida')
|
||||||
|
return new Function('return (' + expr + ')')()
|
||||||
|
}
|
||||||
|
|
||||||
|
function validarFormula(formula) {
|
||||||
|
if (!formula || typeof formula !== 'string') return false
|
||||||
|
if (!/^[0-9+\-*/().\sA-Z_]+$/.test(formula)) return false
|
||||||
|
const palavrasPerigosas = ['eval', 'function', 'constructor', 'prototype', 'window', 'document', 'global']
|
||||||
|
const formulaLower = formula.toLowerCase()
|
||||||
|
return !palavrasPerigosas.some(p => formulaLower.includes(p))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function avaliarFormula(formula, contexto = {}) {
|
||||||
|
if (!validarFormula(formula)) throw new Error('Fórmula inválida')
|
||||||
|
let formulaProcessada = formula
|
||||||
|
for (const [variavel, valor] of Object.entries(contexto)) {
|
||||||
|
formulaProcessada = formulaProcessada.replace(new RegExp(variavel, 'g'), valor.toString())
|
||||||
|
}
|
||||||
|
const resultado = avaliarExpressaoMatematica(formulaProcessada)
|
||||||
|
if (typeof resultado !== 'number' || isNaN(resultado)) throw new Error('Resultado inválido')
|
||||||
|
return resultado
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calcularValorTotal(formula, itensTributo, valoresItens, obterValorNumerico) {
|
||||||
|
if (!formula || !itensTributo || !valoresItens) return '0,00'
|
||||||
|
try {
|
||||||
|
const contexto = {}
|
||||||
|
itensTributo.forEach(item => {
|
||||||
|
contexto[item.atributo] = obterValorNumerico(valoresItens[item.id] || '0,00')
|
||||||
|
})
|
||||||
|
const resultado = avaliarFormula(formula, contexto)
|
||||||
|
return resultado.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||||
|
} catch {
|
||||||
|
return '0,00'
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user